diff --git a/.eslintrc b/.eslintrc index 937f11586c..adc4aebb28 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,7 +5,7 @@ "es6": true }, "parserOptions": { - "ecmaVersion": 9, + "ecmaVersion": 11, "sourceType": "module" }, "extends": "eslint:recommended", diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh index d16f5b62ad..f0e8016860 100644 --- a/.github/helper/install_dependencies.sh +++ b/.github/helper/install_dependencies.sh @@ -2,6 +2,13 @@ set -e +# Check for merge conflicts before proceeding +python -m compileall -f "${GITHUB_WORKSPACE}" +if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}" + then echo "Found merge conflicts" + exit 1 +fi + # install wkhtmltopdf wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz tar -xf /tmp/wkhtmltox.tar.xz -C /tmp diff --git a/.github/try-on-f-cloud-button.svg b/.github/try-on-f-cloud-button.svg index fe0bb2c52d..6a7119bdee 100644 --- a/.github/try-on-f-cloud-button.svg +++ b/.github/try-on-f-cloud-button.svg @@ -1,4 +1,4 @@ - + @@ -29,4 +29,4 @@ - + \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6c1243f64..9c7ecf989e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,6 @@ jobs: npm install @semantic-release/git @semantic-release/exec --no-save - name: Create Release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GIT_AUTHOR_NAME: "Frappe PR Bot" diff --git a/README.md b/README.md index 8c8317c8bd..4942d87e18 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@
-

-
- - - -

-

- a web framework with "batteries included" -

-
- it's pronounced - fra-pay -
+

+
+ + + +

+

+ a web framework with "batteries included" +

+
+ it's pronounced - fra-pay +
@@ -27,20 +27,24 @@ - +
- Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com) -
- +
+ + + Try in PWD +
+> Login for the PWD site: (username: Administrator, password: admin) + ## Table of Contents * [Installation](#installation) * [Contributing](#contributing) @@ -52,7 +56,7 @@ Full-stack web application framework that uses Python and MariaDB on the server * [Install via Docker](https://github.com/frappe/frappe_docker) * [Install via Frappe Bench](https://github.com/frappe/bench) * [Offical Documentation](https://frappeframework.com/docs/user/en/installation) -* [Managed Hosting on Frappe Cloud](https://frappecloud.com/deploy?apps=frappe&source=frappe_readme) +* [Managed Hosting on Frappe Cloud](https://frappecloud.com/frappe/signup) ## Contributing diff --git a/cypress/fixtures/doctype_with_phone.js b/cypress/fixtures/doctype_with_phone.js new file mode 100644 index 0000000000..c62922ade2 --- /dev/null +++ b/cypress/fixtures/doctype_with_phone.js @@ -0,0 +1,47 @@ +export default { + name: "Doctype With Phone", + actions: [], + custom: 1, + is_submittable: 1, + autoname: "field:title", + creation: '2022-03-30 06:29:07.215072', + doctype: 'DocType', + engine: 'InnoDB', + fields: [ + + { + fieldname: 'title', + fieldtype: 'Data', + label: 'title', + unique: 1, + }, + { + fieldname: 'phone', + fieldtype: 'Phone', + label: 'Phone' + } + ], + links: [], + modified: '2019-03-30 14:40:53.127615', + modified_by: 'Administrator', + naming_rule: "By fieldname", + module: 'Custom', + owner: 'Administrator', + permissions: [ + { + create: 1, + delete: 1, + email: 1, + print: 1, + read: 1, + role: 'System Manager', + share: 1, + write: 1, + submit: 1, + cancel: 1 + } + ], + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; diff --git a/cypress/integration/control_data.js b/cypress/integration/control_data.js new file mode 100644 index 0000000000..021b9032c1 --- /dev/null +++ b/cypress/integration/control_data.js @@ -0,0 +1,129 @@ +context('Data Control', () => { + 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 Data Control', + fields: [ + { + "label": "Name", + "fieldname": "name1", + "fieldtype": "Data", + "options": "Name", + "in_list_view": 1, + "reqd": 1, + }, + { + "label": "Email-ID", + "fieldname": "email", + "fieldtype": "Data", + "options": "Email", + "in_list_view": 1, + "reqd": 1, + }, + { + "label": "Phone No.", + "fieldname": "phone", + "fieldtype": "Data", + "options": "Phone", + "in_list_view": 1, + "reqd": 1, + }, + ] + }); + }); + }); + it('Verifying data control by inputting different patterns for "Name" field', () => { + 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.get('.title-text').should('have.text', 'New Test Data Control'); + cy.get('.frappe-control[data-fieldname="name1"]').find('label').should('have.class', 'reqd'); + cy.get('.frappe-control[data-fieldname="email"]').find('label').should('have.class', 'reqd'); + cy.get('.frappe-control[data-fieldname="phone"]').find('label').should('have.class', 'reqd'); + + //Checking if the status is "Not Saved" initially + cy.get('.indicator-pill').should('have.text', 'Not Saved'); + + //Inputting data in the field + cy.fill_field('name1', '@@###', 'Data'); + cy.fill_field('email', 'test@example.com', 'Data'); + cy.fill_field('phone', '9834280031', 'Data'); + + //Checking if the border color of the field changes to red + cy.get('.frappe-control[data-fieldname="name1"]').should('have.class', 'has-error'); + cy.findByRole('button', {name: 'Save'}).click(); + + //Checking for the error message + cy.get('.modal-title').should('have.text', 'Message'); + cy.get('.msgprint').should('have.text', '@@### is not a valid Name'); + cy.get('.modal').type('{esc}'); + + cy.get_field('name1', 'Data').clear({force: true}); + cy.fill_field('name1', 'Komal{}/!', 'Data'); + cy.get('.frappe-control[data-fieldname="name1"]').should('have.class', 'has-error'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.get('.modal-title').should('have.text', 'Message'); + cy.get('.msgprint').should('have.text', 'Komal{}/! is not a valid Name'); + }); + + it('Verifying data control by inputting different patterns for "Email" field', () => { + cy.get('.modal-actions > .btn-modal-close').trigger("click"); + cy.get_field('name1', 'Data').clear({force: true}); + cy.fill_field('name1', 'Komal', 'Data'); + cy.get_field('email', 'Data').clear({force: true}); + cy.fill_field('email', 'komal', 'Data'); + cy.get('.frappe-control[data-fieldname="email"]').should('have.class', 'has-error'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.get('.modal-title').should('have.text', 'Message'); + cy.get('.msgprint').should('have.text', 'komal is not a valid Email Address'); + cy.get('.modal-actions > .btn-modal-close').trigger("click"); + cy.get_field('email', 'Data').clear({force: true}); + cy.fill_field('email', 'komal@test', 'Data'); + cy.get('.frappe-control[data-fieldname="email"]').should('have.class', 'has-error'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.get('.modal-title').should('have.text', 'Message'); + cy.get('.msgprint').should('have.text', 'komal@test is not a valid Email Address'); + }); + + it('Verifying data control by inputting different patterns for "Phone" field', () => { + cy.get('.modal-actions > .btn-modal-close').trigger("click"); + cy.get_field('email', 'Data').clear({force: true}); + cy.fill_field('email', 'komal@test.com', 'Data'); + cy.get_field('phone', 'Data').clear({force: true}); + cy.fill_field('phone', 'komal', 'Data'); + cy.get('.frappe-control[data-fieldname="phone"]').should('have.class', 'has-error'); + cy.findByRole('button', {name: 'Save'}).click({force: true}); + cy.get('.modal-title').should('have.text', 'Message'); + cy.get('.msgprint').should('have.text', 'komal is not a valid Phone Number'); + cy.get('.modal-actions > .btn-modal-close').trigger("click"); + }); + + it('Inputting correct data and saving the doc', () => { + //Inputting the data as expected and saving the document + cy.get_field('name1', 'Data').clear({force: true}); + cy.get_field('email', 'Data').clear({force: true}); + cy.get_field('phone', 'Data').clear({force: true}); + cy.fill_field('name1', 'Komal', 'Data'); + cy.fill_field('email', 'komal@test.com', 'Data'); + 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.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'); + }); + + it('Deleting the doc', () => { + //Deleting the inserted document + cy.go_to_list('Test Data Control'); + cy.get('.list-row-checkbox').eq(0).click({force: true}); + 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'); + cy.get('.btn-modal-close').click(); + }); +}); \ No newline at end of file diff --git a/cypress/integration/control_dynamic_link.js b/cypress/integration/control_dynamic_link.js index cc1eb0b695..32b2c274a8 100644 --- a/cypress/integration/control_dynamic_link.js +++ b/cypress/integration/control_dynamic_link.js @@ -62,8 +62,8 @@ context('Dynamic Link', () => { "label": "Document ID", "fieldname": "doc_id", "fieldtype": "Dynamic Link", - "get_options": () => { - return "User"; + "get_options": () => { + return "User"; }, "in_list_view": 1, }] @@ -118,11 +118,16 @@ context('Dynamic Link', () => { cy.get_field('doc_type').clear(); //Entering System Settings in the Doctype field + cy.intercept('/api/method/frappe.desk.search.search_link').as('search_query'); cy.fill_field('doc_type', 'System Settings', 'Link', {delay: 500}); + cy.wait('@search_query'); + cy.get(`[data-fieldname="doc_type"] ul:visible li:first-child`) + .click({scrollBehavior: false}); + cy.get_field('doc_id').click(); //Checking if the system throws error cy.get('.modal-title').should('have.text', 'Error'); cy.get('.msgprint').should('have.text', 'System Settings is not a valid DocType for Dynamic Link'); }); -}); \ No newline at end of file +}); diff --git a/cypress/integration/control_markdown_editor.js b/cypress/integration/control_markdown_editor.js index b527d854d4..128add5397 100644 --- a/cypress/integration/control_markdown_editor.js +++ b/cypress/integration/control_markdown_editor.js @@ -16,7 +16,7 @@ context("Control Markdown Editor", () => { cy.click_modal_primary_button("Upload"); cy.get_field("main_section_md", "Markdown Editor").should( "contain", - "![](/files/sample_image.jpg)" + "![](/files/sample_image" ); }); }); diff --git a/cypress/integration/control_phone.js b/cypress/integration/control_phone.js new file mode 100644 index 0000000000..5a26decdee --- /dev/null +++ b/cypress/integration/control_phone.js @@ -0,0 +1,90 @@ +import doctype_with_phone from '../fixtures/doctype_with_phone'; + +context("Control Phone", () => { + before(() => { + cy.login(); + cy.visit("/app/website"); + }); + + function get_dialog_with_phone() { + return cy.dialog({ + title: "Phone", + fields: [{ + "fieldname": "phone", + "fieldtype": "Phone", + }] + }); + } + + it("should set flag and data", () => { + get_dialog_with_phone().as("dialog"); + cy.get(".selected-phone").click(); + cy.get(".phone-picker .phone-wrapper[id='afghanistan']").click(); + cy.get(".selected-phone").click(); + cy.get(".phone-picker .phone-wrapper[id='india']").click(); + cy.get(".selected-phone .country").should("have.text", "+91"); + cy.get(".selected-phone > img").should("have.attr", "src").and("include", "/in.svg"); + + let phone_number = "9312672712"; + cy.get(".selected-phone > img").click().first(); + cy.get_field("phone") + .first() + .click({multiple: true}); + cy.get(".frappe-control[data-fieldname=phone]") + .findByRole("textbox") + .first() + .type(phone_number, {force: true}); + + cy.get_field("phone").first().should("have.value", phone_number); + cy.get_field("phone").first().blur({force: true}); + cy.wait(100); + cy.get("@dialog").then(dialog => { + let value = dialog.get_value("phone"); + expect(value).to.equal("+91-" + phone_number); + }); + }); + + it("case insensitive search for country and clear search", () => { + let search_text = "india"; + cy.get(".selected-phone").click().first(); + cy.get(".phone-picker").findByRole("searchbox").click().type(search_text); + cy.get(".phone-section .phone-wrapper:not(.hidden)").then(i => { + cy.get(`.phone-section .phone-wrapper[id*="${search_text.toLowerCase()}"]`).then(countries => { + expect(i.length).to.equal(countries.length); + }); + }); + + cy.get(".phone-picker").findByRole("searchbox").clear().blur(); + cy.get(".phone-section .phone-wrapper").should("not.have.class", "hidden"); + }); + + it("existing document should render phone field with data", () => { + cy.visit("/app/doctype"); + cy.insert_doc("DocType", doctype_with_phone, true); + cy.clear_cache(); + + // Creating custom doctype + cy.insert_doc("DocType", doctype_with_phone, true); + cy.visit("/app/doctype-with-phone"); + cy.click_listview_primary_button("Add Doctype With Phone"); + + // create a record + cy.fill_field("title", "Test Phone 1"); + cy.fill_field("phone", "+91-9823341234"); + cy.get_field("phone").should("have.value", "9823341234"); + cy.click_doc_primary_button("Save"); + cy.get_doc("Doctype With Phone", "Test Phone 1").then((doc) => { + let value = doc.data.phone; + expect(value).to.equal("+91-9823341234"); + }); + + // open the doc from list view + cy.go_to_list("Doctype With Phone"); + cy.clear_cache(); + cy.click_listview_row_item(0); + cy.title().should("eq", "Test Phone 1"); + cy.get(".selected-phone .country").should("have.text", "+91"); + cy.get(".selected-phone > img").should("have.attr", "src").and("include", "/in.svg"); + cy.get_field("phone").should("have.value", "9823341234"); + }); +}); diff --git a/cypress/integration/customize_form.js b/cypress/integration/customize_form.js new file mode 100644 index 0000000000..70615085c3 --- /dev/null +++ b/cypress/integration/customize_form.js @@ -0,0 +1,22 @@ +context('Customize Form', () => { + before(() => { + cy.visit('/app/customize-form'); + }); + it('Changing to naming rule should update autoname', () => { + cy.fill_field("doc_type", "ToDo", "Link").blur(); + cy.click_form_section("Naming"); + const naming_rule_default_autoname_map = { + "Set by user": "prompt", + "By fieldname": "field:", + 'By "Naming Series" field': "naming_series:", + "Expression": "format:", + "Expression (old style)": "", + "Random": "hash", + "By script": "" + }; + Cypress._.forOwn(naming_rule_default_autoname_map, (value, naming_rule) => { + cy.fill_field("naming_rule", naming_rule, "Select"); + cy.get_field("autoname", "Data").should("have.value", value); + }); + }); +}); \ No newline at end of file diff --git a/cypress/integration/form.js b/cypress/integration/form.js index acaff9a191..99a4336bcb 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -27,7 +27,7 @@ context('Form', () => { cy.clear_filters(); cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur(); - cy.click_listview_row_item(0); + cy.click_listview_row_item_with_text('Test Form Contact 3'); cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist'); cy.get('.prev-doc').should('be.visible').click(); diff --git a/cypress/integration/kanban.js b/cypress/integration/kanban.js index c7f3f08336..50fc41afe3 100644 --- a/cypress/integration/kanban.js +++ b/cypress/integration/kanban.js @@ -72,14 +72,16 @@ context('Kanban Board', () => { }); - it('Drag todo', () => { - cy.intercept({ - method: 'POST', - url: 'api/method/frappe.desk.doctype.kanban_board.kanban_board.update_order_for_single_card' - }).as('drag-completed'); + // it('Drag todo', () => { + // cy.intercept({ + // method: 'POST', + // url: 'api/method/frappe.desk.doctype.kanban_board.kanban_board.update_order_for_single_card' + // }).as('drag-completed'); - cy.get('.kanban-card-body:first').drag('[data-column-value="Closed"] .kanban-cards', {force: true}); + // cy.get('.kanban-card-body') + // .contains('Test Kanban ToDo').first() + // .drag('[data-column-value="Closed"] .kanban-cards', { force: true }); - cy.wait('@drag-completed'); - }); + // cy.wait('@drag-completed'); + // }); }); \ No newline at end of file diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js index 5808bd52ef..f2a239401d 100644 --- a/cypress/integration/timeline_email.js +++ b/cypress/integration/timeline_email.js @@ -16,7 +16,7 @@ context('Timeline Email', () => { it('Adding email and verifying timeline content for email attachment', () => { cy.visit('/app/todo'); - cy.get('.list-row > .level-left > .list-subject').eq(0).click(); + cy.click_listview_row_item_with_text('Test ToDo'); //Creating a new email cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click(); @@ -47,7 +47,7 @@ context('Timeline Email', () => { it('Deleting attachment and ToDo', () => { cy.visit('/app/todo'); - cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click(); + cy.click_listview_row_item_with_text('Test ToDo'); //Removing the added attachment cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click(); diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js index fbff451305..a12d86b3d6 100644 --- a/cypress/integration/workspace.js +++ b/cypress/integration/workspace.js @@ -2,7 +2,6 @@ context('Workspace 2.0', () => { before(() => { cy.visit('/login'); cy.login(); - cy.visit('/app/website'); }); it('Navigate to page from sidebar', () => { @@ -13,6 +12,11 @@ context('Workspace 2.0', () => { }); it('Create Private Page', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.new_page' + }).as('new_page'); + cy.get('.codex-editor__redactor .ce-block'); cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); cy.fill_field('title', 'Test Private Page', 'Data'); @@ -27,12 +31,100 @@ context('Workspace 2.0', () => { cy.wait(300); cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0'); - cy.wait(500); + cy.wait('@new_page'); + }); + + it('Create Child Page', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.new_page' + }).as('new_page'); + + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); + cy.fill_field('title', 'Test Child Page', 'Data'); + cy.fill_field('parent', 'Test Private Page', 'Select'); + cy.fill_field('icon', 'edit', 'Icon'); + cy.get_open_dialog().find('.modal-header').click(); + cy.get_open_dialog().find('.btn-primary').click(); + + // check if sidebar item is added in pubic section + cy.get('.sidebar-item-container[item-name="Test Child Page"]').should('have.attr', 'item-public', '0'); + + cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); + cy.wait(300); + cy.get('.sidebar-item-container[item-name="Test Child Page"]').should('have.attr', 'item-public', '0'); + + cy.wait('@new_page'); + }); + + it('Duplicate Page', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.duplicate_page' + }).as('page_duplicated'); + cy.get('.codex-editor__redactor .ce-block'); cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); + + cy.get('.sidebar-item-container[item-name="Test Private Page"]').as('sidebar-item'); + + cy.get('@sidebar-item').find('.standard-sidebar-item').first().click(); + cy.get('@sidebar-item').find('.dropdown-btn').first().click(); + cy.get('@sidebar-item').find('.dropdown-list .dropdown-item').contains('Duplicate').first().click({force: true}); + + cy.get_open_dialog().fill_field('title', 'Duplicate Page', 'Data'); + cy.click_modal_primary_button('Duplicate'); + + cy.wait('@page_duplicated'); + }); + + it('Drag Sidebar Item', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.sort_pages' + }).as('page_sorted'); + + cy.get('.sidebar-item-container[item-name="Duplicate Page"]').as('sidebar-item'); + + cy.get('@sidebar-item').find('.standard-sidebar-item').first().click(); + cy.get('@sidebar-item').find('.drag-handle').first().move({ deltaX: 0, deltaY: 100 }); + + cy.get('.sidebar-item-container[item-name="Build"]').as('sidebar-item'); + + cy.get('@sidebar-item').find('.standard-sidebar-item').first().click(); + cy.get('@sidebar-item').find('.drag-handle').first().move({ deltaX: 0, deltaY: 100 }); + + cy.wait('@page_sorted'); + }); + + it('Edit Page Detail', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.update_page' + }).as('page_updated'); + + cy.get('.sidebar-item-container[item-name="Test Private Page"]').as('sidebar-item'); + + cy.get('@sidebar-item').find('.standard-sidebar-item').first().click(); + cy.get('@sidebar-item').find('.dropdown-btn').first().click(); + cy.get('@sidebar-item').find('.dropdown-list .dropdown-item').contains('Edit').first().click({force: true}); + + cy.get_open_dialog().fill_field('title', ' 1', 'Data'); + cy.get_open_dialog().find('input[data-fieldname="is_public"]').check(); + cy.click_modal_primary_button('Update'); + + cy.get('.standard-sidebar-section:first .sidebar-item-container[item-name="Test Private Page"]').should('not.exist'); + cy.get('.standard-sidebar-section:last .sidebar-item-container[item-name="Test Private Page 1"]').should('exist'); + + cy.wait('@page_updated'); }); it('Add New Block', () => { + cy.get('.sidebar-item-container[item-name="Duplicate Page"]').as('sidebar-item'); + + cy.get('@sidebar-item').find('.standard-sidebar-item').first().click(); + cy.get('.ce-block').click().type('{enter}'); cy.get('.block-list-container .block-list-item').contains('Heading').click(); cy.get(":focus").type('Header'); @@ -70,19 +162,24 @@ context('Workspace 2.0', () => { cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); }); - it('Delete Private Page', () => { + it('Delete Duplicate Page', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.delete_page' + }).as('page_deleted'); + cy.get('.codex-editor__redactor .ce-block'); cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); - cy.get('.sidebar-item-container[item-name="Test Private Page"]') + cy.get('.sidebar-item-container[item-name="Duplicate Page"]') .find('.sidebar-item-control .setting-btn').click(); - cy.get('.sidebar-item-container[item-name="Test Private Page"]') + cy.get('.sidebar-item-container[item-name="Duplicate Page"]') .find('.dropdown-item[title="Delete Workspace"]').click({force: true}); cy.wait(300); cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click(); - cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); - cy.get('.codex-editor__redactor .ce-block'); - cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist'); + cy.get('.sidebar-item-container[item-name="Duplicate Page"]').should('not.exist'); + + cy.wait('@page_deleted'); }); }); \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 026c622e78..95b5cbb670 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -352,6 +352,13 @@ Cypress.Commands.add('click_listview_row_item', (row_no) => { cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis').eq(row_no).click({force: true}); }); +Cypress.Commands.add('click_listview_row_item_with_text', (text) => { + cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis') + .contains(text) + .first() + .click({force: true}); +}); + Cypress.Commands.add('click_filter_button', () => { cy.get('.filter-selector > .btn').click(); }); @@ -360,6 +367,10 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => { cy.get('.primary-action').contains(btn_name).click({force: true}); }); +Cypress.Commands.add('click_doc_primary_button', (btn_name) => { + cy.get('.primary-action').contains(btn_name).click({force: true}); +}); + Cypress.Commands.add('click_timeline_action_btn', (btn_name) => { cy.get('.timeline-message-box .actions .action-btn').contains(btn_name).click(); }); @@ -367,3 +378,7 @@ Cypress.Commands.add('click_timeline_action_btn', (btn_name) => { Cypress.Commands.add('select_listview_row_checkbox', (row_no) => { cy.get('.frappe-list .select-like > .list-row-checkbox').eq(row_no).click(); }); + +Cypress.Commands.add('click_form_section', (section_name) => { + cy.get('.section-head').contains(section_name).click(); +}); diff --git a/frappe/__init__.py b/frappe/__init__.py index 97e605394b..8bd7783283 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -199,6 +199,7 @@ def init(site, sites_path=None, new_site=False): } ) local.rollback_observers = [] + local.locked_documents = [] local.before_commit = [] local.test_objects = {} @@ -231,7 +232,6 @@ def init(site, sites_path=None, new_site=False): local.cache = {} local.document_cache = {} local.meta_cache = {} - local.autoincremented_status_map = {site: -1} local.form_dict = _dict() local.session = _dict() local.dev_server = _dev_server @@ -354,11 +354,11 @@ def cache() -> "RedisWrapper": return redis_server -def get_traceback(): +def get_traceback(with_context=False): """Returns error traceback.""" from frappe.utils import get_traceback - return get_traceback() + return get_traceback(with_context=with_context) def errprint(msg): @@ -1210,18 +1210,35 @@ def reload_doc(module, dt=None, dn=None, force=False, reset_permissions=False): @whitelist() -def rename_doc(*args, **kwargs): +def rename_doc( + doctype: str, + old: str, + new: str, + force: bool = False, + merge: bool = False, + *, + ignore_if_exists: bool = False, + show_alert: bool = True, + rebuild_search: bool = True, +) -> str: """ Renames a doc(dt, old) to doc(dt, new) and updates all linked fields of type "Link" Calls `frappe.model.rename_doc.rename_doc` """ - kwargs.pop("ignore_permissions", None) - kwargs.pop("cmd", None) from frappe.model.rename_doc import rename_doc - return rename_doc(*args, **kwargs) + return rename_doc( + doctype=doctype, + old=old, + new=new, + force=force, + merge=merge, + ignore_if_exists=ignore_if_exists, + show_alert=show_alert, + rebuild_search=rebuild_search, + ) def get_module(modulename): @@ -1490,10 +1507,11 @@ def get_newargs(fn, kwargs): if hasattr(fn, "fnargs"): fnargs = fn.fnargs else: - fullargspec = inspect.getfullargspec(fn) - fnargs = fullargspec.args - fnargs.extend(fullargspec.kwonlyargs) - varkw = fullargspec.varkw + signature = inspect.signature(fn) + fnargs = list(signature.parameters) + varkw = "kwargs" in fnargs + if varkw: + fnargs.pop(-1) newargs = {} for a in kwargs: @@ -1907,7 +1925,7 @@ def attach_print( if not file_name: file_name = name - file_name = file_name.replace(" ", "").replace("/", "-") + file_name = cstr(file_name).replace(" ", "").replace("/", "-") print_settings = db.get_singles_dict("Print Settings") @@ -2069,7 +2087,6 @@ def logger( def log_error(title=None, message=None, reference_doctype=None, reference_name=None): """Log error to Error Log""" - # Parameter ALERT: # the title and message may be swapped # the better API for this is log_error(title, message), and used in many cases this way @@ -2082,20 +2099,15 @@ def log_error(title=None, message=None, reference_doctype=None, reference_name=N else: traceback = message - if not traceback: - traceback = get_traceback() - - if not title: - title = "Error" + title = title or "Error" + traceback = as_unicode(traceback or get_traceback(with_context=True)) return get_doc( - dict( - doctype="Error Log", - error=as_unicode(traceback), - method=title, - reference_doctype=reference_doctype, - reference_name=reference_name, - ) + doctype="Error Log", + error=traceback, + method=title, + reference_doctype=reference_doctype, + reference_name=reference_name, ).insert(ignore_permissions=True) @@ -2252,7 +2264,4 @@ def mock(type, size=1, locale="en"): return squashify(results) -def validate_and_sanitize_search_inputs(fn): - from frappe.desk.search import validate_and_sanitize_search_inputs as func - - return func(fn) +from frappe.desk.search import validate_and_sanitize_search_inputs # noqa diff --git a/frappe/boot.py b/frappe/boot.py index 1b8e471e00..62122ed4e5 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -11,6 +11,7 @@ from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, ge from frappe.desk.doctype.route_history.route_history import frequently_visited_links from frappe.desk.form.load import get_meta_bundle from frappe.email.inbox import get_email_accounts +from frappe.geo.country_info import get_all from frappe.model.base_document import get_controller from frappe.query_builder import DocType from frappe.query_builder.functions import Count @@ -67,6 +68,7 @@ def get_bootinfo(): bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1}) bootinfo.navbar_settings = get_navbar_settings() bootinfo.notification_settings = get_notification_settings() + get_country_codes(bootinfo) set_time_zone(bootinfo) # ipinfo @@ -384,6 +386,11 @@ def get_notification_settings(): return frappe.get_cached_doc("Notification Settings", frappe.session.user) +def get_country_codes(bootinfo): + country_codes = get_all() + bootinfo.country_codes = frappe._dict(country_codes) + + @frappe.whitelist() def get_link_title_doctypes(): dts = frappe.get_all("DocType", {"show_title_field_in_link": 1}) diff --git a/frappe/client.py b/frappe/client.py index a8223cdeee..1bad2bed2f 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -189,7 +189,10 @@ def insert(doc=None): if isinstance(doc, str): doc = json.loads(doc) - if doc.get("parenttype"): + doc = frappe._dict(doc) + if frappe.is_table(doc.doctype): + if not (doc.parenttype and doc.parent and doc.parentfield): + frappe.throw(_("parenttype, parent and parentfield are required to insert a child record")) # inserting a child record parent = frappe.get_doc(doc.parenttype, doc.parent) parent.append(doc.parentfield, doc) diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 5c7d06c93a..803ad3c140 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -99,7 +99,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -557,4 +557,4 @@ "sort_field": "modified", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index 88cc5577a6..514e3a9455 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -57,8 +57,8 @@ frappe.ui.form.on('DocType', { frm.get_docfield('fields', 'in_list_view').label = frm.doc.istable ? __('In Grid View') : __('In List View'); - frm.events.autoname(frm); - frm.events.set_naming_rule_description(frm); + frm.cscript.autoname(frm); + frm.cscript.set_naming_rule_description(frm); }, istable: (frm) => { @@ -67,80 +67,6 @@ frappe.ui.form.on('DocType', { frm.set_value('allow_rename', 0); } }, - - naming_rule: function(frm) { - // set the "autoname" property based on naming_rule - if (frm.doc.naming_rule && !frm.__from_autoname) { - - // flag to avoid recursion - frm.__from_naming_rule = true; - - if (frm.doc.naming_rule=='Set by user') { - frm.set_value('autoname', 'Prompt'); - } else if (frm.doc.naming_rule === 'Autoincrement') { - frm.set_value('autoname', 'autoincrement'); - // set allow rename to be false when using autoincrement - frm.set_value('allow_rename', 0); - } else if (frm.doc.naming_rule=='By fieldname') { - frm.set_value('autoname', 'field:'); - } else if (frm.doc.naming_rule=='By "Naming Series" field') { - frm.set_value('autoname', 'naming_series:'); - } else if (frm.doc.naming_rule=='Expression') { - frm.set_value('autoname', 'format:'); - } else if (frm.doc.naming_rule=='Expression (old style)') { - // pass - } else if (frm.doc.naming_rule=='Random') { - frm.set_value('autoname', 'hash'); - } - setTimeout(() =>frm.__from_naming_rule = false, 500); - - frm.events.set_naming_rule_description(frm); - } - - }, - - set_naming_rule_description(frm) { - let naming_rule_description = { - 'Set by user': '', - 'Autoincrement': 'Uses Auto Increment feature of database.
WARNING: After using this option, any other naming option will not be accessible.', - 'By fieldname': 'Format: field:[fieldname]. Valid fieldname must exist', - 'By "Naming Series" field': 'Format: naming_series:[fieldname]. Fieldname called naming_series must exist', - 'Expression': 'Format: format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.', - 'Expression (old style)': 'Format: EXAMPLE-.##### Series by prefix (separated by a dot)', - 'Random': '', - 'By script': '' - }; - - if (frm.doc.naming_rule) { - frm.get_field('autoname').set_description(naming_rule_description[frm.doc.naming_rule]); - } - }, - - autoname: function(frm) { - // set naming_rule based on autoname (for old doctypes where its not been set) - if (frm.doc.autoname && !frm.doc.naming_rule && !frm.__from_naming_rule) { - // flag to avoid recursion - frm.__from_autoname = true; - if (frm.doc.autoname.toLowerCase() === 'prompt') { - frm.set_value('naming_rule', 'Set by user'); - } else if (frm.doc.autoname.toLowerCase() === 'autoincrement') { - frm.set_value('naming_rule', 'Autoincrement'); - } else if (frm.doc.autoname.startsWith('field:')) { - frm.set_value('naming_rule', 'By fieldname'); - } else if (frm.doc.autoname.startsWith('naming_series:')) { - frm.set_value('naming_rule', 'By "Naming Series" field'); - } else if (frm.doc.autoname.startsWith('format:')) { - frm.set_value('naming_rule', 'Expression'); - } else if (frm.doc.autoname.toLowerCase() === 'hash') { - frm.set_value('naming_rule', 'Random'); - } else { - frm.set_value('naming_rule', 'Expression (old style)'); - } - setTimeout(() => frm.__from_autoname = false, 500); - } - - frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt'); - }, }); frappe.ui.form.on("DocField", { diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 8169a59566..b1579f35cd 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -208,7 +208,7 @@ "label": "Naming" }, { - "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. autoincrement - Uses Databases' Auto Increment feature
  3. naming_series: - By Naming Series (field called naming_series must be present
  4. Prompt - Prompt user for a name
  5. [series] - Series by prefix (separated by a dot); for example PRE.#####
  6. \n
  7. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. autoincrement - Uses Databases' Auto Increment feature
  3. naming_series: - By Naming Series (field called naming_series must be present)
  4. Prompt - Prompt user for a name
  5. [series] - Series by prefix (separated by a dot); for example PRE.#####
  6. \n
  7. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", "fieldname": "autoname", "fieldtype": "Data", "label": "Auto Name", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 1d02f09820..06ebcc7d42 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -92,10 +92,10 @@ class DocType(Document): self.check_developer_mode() - self.validate_autoname() self.validate_name() self.set_defaults_for_single_and_table() + self.set_defaults_for_autoincremented() self.scrub_field_names() self.set_default_in_list_view() self.set_default_translatable() @@ -124,6 +124,12 @@ class DocType(Document): if self.default_print_format and not self.custom: frappe.throw(_("Standard DocType cannot have default print format, use Customize Form")) + if check_if_can_change_name_type(self): + change_name_column_type( + self.name, + "bigint" if self.autoname == "autoincrement" else f"varchar({frappe.db.VARCHAR_LEN})", + ) + def validate_field_name_conflicts(self): """Check if field names dont conflict with controller properties and methods""" core_doctypes = [ @@ -184,6 +190,10 @@ class DocType(Document): self.allow_import = 0 self.permissions = [] + def set_defaults_for_autoincremented(self): + if self.autoname and self.autoname == "autoincrement": + self.allow_rename = 0 + def set_default_in_list_view(self): """Set default in-list-view for first 4 mandatory fields""" if not [d.fieldname for d in self.fields if d.in_list_view]: @@ -809,19 +819,6 @@ class DocType(Document): max_idx = frappe.db.sql("""select max(idx) from `tabDocField` where parent = %s""", self.name) return max_idx and max_idx[0][0] or 0 - def validate_autoname(self): - if not self.is_new(): - doc_before_save = self.get_doc_before_save() - if doc_before_save: - if (self.autoname == "autoincrement" and doc_before_save.autoname != "autoincrement") or ( - self.autoname != "autoincrement" and doc_before_save.autoname == "autoincrement" - ): - frappe.throw(_("Cannot change to/from Autoincrement naming rule")) - - else: - if self.autoname == "autoincrement": - self.allow_rename = 0 - def validate_name(self, name=None): if not name: name = self.name @@ -865,8 +862,13 @@ def validate_series(dt, autoname=None, name=None): if not autoname and dt.get("fields", {"fieldname": "naming_series"}): dt.autoname = "naming_series:" - elif dt.autoname == "naming_series:" and not dt.get("fields", {"fieldname": "naming_series"}): - frappe.throw(_("Invalid fieldname '{0}' in autoname").format(dt.autoname)) + elif dt.autoname and dt.autoname.startswith("naming_series:"): + fieldname = dt.autoname.split("naming_series:")[0] or "naming_series" + if not dt.get("fields", {"fieldname": fieldname}): + frappe.throw( + _("Fieldname called {0} must exist to enable autonaming").format(frappe.bold(fieldname)), + title=_("Field Missing"), + ) # validate field name if autoname field:fieldname is used # Create unique index on autoname field automatically. @@ -884,7 +886,7 @@ def validate_series(dt, autoname=None, name=None): autoname and (not autoname.startswith("field:")) and (not autoname.startswith("eval:")) - and (not autoname.lower() in ("prompt", "hash")) + and (autoname.lower() not in ("prompt", "hash")) and (not autoname.startswith("naming_series:")) and (not autoname.startswith("format:")) ): @@ -901,6 +903,51 @@ def validate_series(dt, autoname=None, name=None): frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0])) +def check_if_can_change_name_type(dt: DocType, raise_err: bool = True) -> bool: + def get_autoname_before_save(doctype: str, to_be_customized_dt: str) -> str: + if doctype == "Customize Form": + property_value = frappe.db.get_value( + "Property Setter", {"doc_type": to_be_customized_dt, "property": "autoname"}, "value" + ) + + # initially no property setter is set, + # hence getting autoname value from the doctype itself + if not property_value: + return frappe.db.get_value("DocType", to_be_customized_dt, "autoname") or "" + + return property_value + + return getattr(dt.get_doc_before_save(), "autoname", "") + + doctype_name = dt.doc_type if dt.doctype == "Customize Form" else dt.name + + if not dt.is_new(): + autoname_before_save = get_autoname_before_save(dt.doctype, doctype_name) + is_autoname_autoincrement = dt.autoname == "autoincrement" + + if ( + is_autoname_autoincrement + and autoname_before_save != "autoincrement" + or (not is_autoname_autoincrement and autoname_before_save == "autoincrement") + ): + if not frappe.get_all(doctype_name, limit=1): + # allow changing the column type if there is no data + return True + + if raise_err: + frappe.throw( + _("Can only change to/from Autoincrement naming rule when there is no data in the doctype") + ) + + return False + + +def change_name_column_type(doctype_name: str, type: str) -> None: + return frappe.db.change_column_type( + doctype_name, "name", type, True if frappe.db.db_type == "mariadb" else False + ) + + def validate_links_table_fieldnames(meta): """Validate fieldnames in Links table""" if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures: diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 7b4806da59..59475a95a7 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -524,18 +524,33 @@ class TestDocType(unittest.TestCase): dt.delete() def test_autoincremented_doctype_transition(self): - frappe.delete_doc("testy_autoinc_dt") + frappe.delete_doc_if_exists("DocType", "testy_autoinc_dt") dt = new_doctype("testy_autoinc_dt", autoname="autoincrement").insert(ignore_permissions=True) dt.autoname = "hash" + dt.save(ignore_permissions=True) + + dt_data = frappe.get_doc({"doctype": dt.name, "some_fieldname": "test data"}).insert( + ignore_permissions=True + ) + + dt.autoname = "autoincrement" + try: dt.save(ignore_permissions=True) except frappe.ValidationError as e: - self.assertEqual(e.args[0], "Cannot change to/from Autoincrement naming rule") + self.assertEqual( + e.args[0], + "Can only change to/from Autoincrement naming rule when there is no data in the doctype", + ) else: - self.fail("Shouldnt be possible to transition autoincremented doctype to any other naming rule") + self.fail( + """Shouldn't be possible to transition to/from autoincremented doctype + when data is present in doctype""" + ) finally: # cleanup + dt_data.delete(ignore_permissions=True) dt.delete(ignore_permissions=True) def test_json_field(self): diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index 548d21bb60..9312ae178b 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -49,7 +49,7 @@ "fieldname": "doctype_event", "fieldtype": "Select", "label": "DocType Event", - "options": "Before Insert\nBefore Validate\nBefore Save\nAfter Insert\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)" + "options": "Before Insert\nBefore Validate\nBefore Save\nAfter Insert\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)\nOn Payment Authorization" }, { "depends_on": "eval:doc.script_type==='API'", @@ -109,7 +109,7 @@ "link_fieldname": "server_script" } ], - "modified": "2022-04-07 19:41:23.178772", + "modified": "2022-04-27 11:42:52.032963", "modified_by": "Administrator", "module": "Core", "name": "Server Script", diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index 5300baa199..b807b43d10 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -17,6 +17,7 @@ EVENT_MAP = { "after_delete": "After Delete", "before_update_after_submit": "Before Save (Submitted Document)", "on_update_after_submit": "After Save (Submitted Document)", + "on_payment_authorized": "On Payment Authorization", } diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 61410fb1a8..0c9b87e618 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -68,6 +68,8 @@ "prepared_report_section", "enable_prepared_report_auto_deletion", "prepared_report_expiry_period", + "column_break_64", + "max_auto_email_report_per_user", "system_updates_section", "disable_system_update_notification" ], @@ -445,7 +447,7 @@ "collapsible": 1, "fieldname": "prepared_report_section", "fieldtype": "Section Break", - "label": "Prepared Report" + "label": "Reports" }, { "default": "Frappe", @@ -485,12 +487,22 @@ "fieldtype": "Select", "label": "First Day of the Week", "options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday" + }, + { + "fieldname": "column_break_64", + "fieldtype": "Column Break" + }, + { + "default": "20", + "fieldname": "max_auto_email_report_per_user", + "fieldtype": "Int", + "label": "Max auto email report per user" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2022-01-04 11:28:34.881192", + "modified": "2022-04-21 09:11:35.218721", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index add6cbb828..045a0981f3 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -123,7 +123,7 @@ "label": "Field Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1 }, { diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 4c2d207df9..4ce2c73fa3 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -152,6 +152,10 @@ frappe.ui.form.on("Customize Form", { }, __("Actions") ); + + const is_autoname_autoincrement = frm.doc.autoname === 'autoincrement'; + frm.set_df_property("naming_rule", "hidden", is_autoname_autoincrement); + frm.set_df_property("autoname", "read_only", is_autoname_autoincrement); } frm.events.setup_export(frm); diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 1ee9d4a02a..a0bc994c45 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -24,6 +24,7 @@ "fields_section_break", "fields", "naming_section", + "naming_rule", "autoname", "view_settings_section", "title_field", @@ -50,6 +51,13 @@ "sort_order" ], "fields": [ + { + "fieldname": "naming_rule", + "fieldtype": "Select", + "label": "Naming Rule", + "length": 40, + "options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" + }, { "fieldname": "doc_type", "fieldtype": "Link", @@ -279,7 +287,7 @@ "label": "Naming" }, { - "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. \n
  3. autoincrement - Uses Databases' Auto Increment feature
  4. naming_series: - By Naming Series (field called naming_series must be present
  5. Prompt - Prompt user for a name
  6. [series] - Series by prefix (separated by a dot); for example PRE.#####
  7. \n
  8. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", "fieldname": "autoname", "fieldtype": "Data", "label": "Auto Name" @@ -311,7 +319,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-01-07 16:07:06.196534", + "modified": "2022-04-21 15:36:16.772277", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 12ef945288..262542fd4b 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -11,7 +11,9 @@ import frappe import frappe.translate from frappe import _ from frappe.core.doctype.doctype.doctype import ( + change_name_column_type, check_email_append_to, + check_if_can_change_name_type, validate_fields_for_doctype, validate_series, ) @@ -159,7 +161,9 @@ class CustomizeForm(Document): def save_customization(self): if not self.doc_type: return + validate_series(self, self.autoname, self.doc_type) + can_change_name_type = check_if_can_change_name_type(self) self.flags.update_db = False self.flags.rebuild_doctype_for_global_search = False self.set_property_setters() @@ -168,6 +172,12 @@ class CustomizeForm(Document): validate_fields_for_doctype(self.doc_type) check_email_append_to(self) + if can_change_name_type: + change_name_column_type( + self.doc_type, + "bigint" if self.autoname == "autoincrement" else f"varchar({frappe.db.VARCHAR_LEN})", + ) + if self.flags.update_db: frappe.db.updatedb(self.doc_type) @@ -571,6 +581,7 @@ doctype_properties = { "email_append_to": "Check", "subject_field": "Data", "sender_field": "Data", + "naming_rule": "Data", "autoname": "Data", "show_title_field_in_link": "Check", } 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 a7a8eff950..8fa054894f 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -87,7 +87,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -477,4 +477,4 @@ "sort_field": "modified", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} diff --git a/frappe/database/database.py b/frappe/database/database.py index d4677a1295..ca4b5a5310 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -1066,7 +1066,7 @@ class Database(object): now_datetime() - relativedelta(minutes=minutes), )[0][0] - def get_db_table_columns(self, table): + def get_db_table_columns(self, table) -> List[str]: """Returns list of column names from given table.""" columns = frappe.cache().hget("table_columns", table) if columns is None: @@ -1146,18 +1146,13 @@ class Database(object): return frappe.db.is_missing_column(e) def get_descendants(self, doctype, name): - """Return descendants of the current record""" - node_location_indexes = self.get_value(doctype, name, ("lft", "rgt")) - if node_location_indexes: - lft, rgt = node_location_indexes - return self.sql_list( - """select name from `tab{doctype}` - where lft > {lft} and rgt < {rgt}""".format( - doctype=doctype, lft=lft, rgt=rgt - ) - ) - else: - # when document does not exist + """Return descendants of the group node in tree""" + from frappe.utils.nestedset import get_descendants_of + + try: + return get_descendants_of(doctype, name, ignore_permissions=True) + except Exception: + # Can only happen if document doesn't exists - kept for backward compatibility return [] def is_missing_table_or_column(self, e): @@ -1251,6 +1246,21 @@ class Database(object): values_to_insert = values[start_index : start_index + chunk_size] query.columns(fields).insert(*values_to_insert).run() + def create_sequence(self, *args, **kwargs): + from frappe.database.sequence import create_sequence + + return create_sequence(*args, **kwargs) + + def set_next_sequence_val(self, *args, **kwargs): + from frappe.database.sequence import set_next_val + + set_next_val(*args, **kwargs) + + def get_next_sequence_val(self, *args, **kwargs): + from frappe.database.sequence import get_next_val + + return get_next_val(*args, **kwargs) + def enqueue_jobs_after_commit(): from frappe.utils.background_jobs import execute_job, get_queue diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 1ae3fd8a61..28d78471d2 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -53,6 +53,7 @@ class MariaDBDatabase(Database): "Geolocation": ("longtext", ""), "Duration": ("decimal", "21,9"), "Icon": ("varchar", self.VARCHAR_LEN), + "Phone": ("varchar", self.VARCHAR_LEN), "Autocomplete": ("varchar", self.VARCHAR_LEN), "JSON": ("json", ""), } @@ -148,7 +149,7 @@ class MariaDBDatabase(Database): ) -> Union[List, Tuple]: table_name = get_table_name(doctype) null_constraint = "NOT NULL" if not nullable else "" - return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}") + return self.sql_ddl(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}") # exception types @staticmethod diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 7c95e9ffcb..784fa23c13 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -1,7 +1,6 @@ import frappe from frappe import _ from frappe.database.schema import DBTable -from frappe.database.sequence import create_sequence from frappe.model import log_types @@ -48,7 +47,7 @@ class MariaDBTable(DBTable): # By default the cache is 1000 which will mess up the sequence when # using the system after a restore. # issue link: https://jira.mariadb.org/browse/MDEV-21786 - create_sequence(self.doctype, check_not_exists=True, cache=50) + frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=50) # NOTE: not used nextval func as default as the ability to restore # database with sequences has bugs in mariadb and gives a scary error. diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 228d0f48be..d69e0bea94 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -65,6 +65,7 @@ class PostgresDatabase(Database): "Geolocation": ("text", ""), "Duration": ("decimal", "21,9"), "Icon": ("varchar", self.VARCHAR_LEN), + "Phone": ("varchar", self.VARCHAR_LEN), "Autocomplete": ("varchar", self.VARCHAR_LEN), "JSON": ("json", ""), } @@ -212,7 +213,11 @@ class PostgresDatabase(Database): ) -> Union[List, Tuple]: table_name = get_table_name(doctype) null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL" - return self.sql( + + # postgres allows ddl in transactions but since we've currently made + # things same as mariadb (raising exception on ddl commands if the transaction has any writes), + # hence using sql_ddl here for committing and then moving forward. + return self.sql_ddl( f"""ALTER TABLE "{table_name}" ALTER COLUMN "{column}" TYPE {type}, ALTER COLUMN "{column}" {null_constraint}""" @@ -381,12 +386,10 @@ def modify_query(query): # drop .0 from decimals and add quotes around them # # >>> query = "c='abcd' , a >= 45, b = -45.0, c = 40, d=4500.0, e=3500.53, f=40psdfsd, g=9092094312, h=12.00023" - # >>> re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query) + # >>> re.sub(r"([=><]+)\s*([+-]?\d+)(\.0)?(?![a-zA-Z\.\d])", r"\1 '\2'", query) # "c='abcd' , a >= '45', b = '-45', c = '40', d= '4500', e=3500.53, f=40psdfsd, g= '9092094312', h=12.00023 - query = re.sub( - r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query - ) + query = re.sub(r"([=><]+)\s*([+-]?\d+)(\.0)?(?![a-zA-Z\.\d])", r"\1 '\2'", query) return query diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 1e79bf67d8..2cae3ab82f 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -240,7 +240,7 @@ CREATE TABLE "tabDocType" ( DROP TABLE IF EXISTS "tabSeries"; CREATE TABLE "tabSeries" ( - "name" varchar(100) DEFAULT NULL, + "name" varchar(100), "current" bigint NOT NULL DEFAULT 0, PRIMARY KEY ("name") ) ; diff --git a/frappe/database/postgres/schema.py b/frappe/database/postgres/schema.py index 3432c8b548..2abd5f37c7 100644 --- a/frappe/database/postgres/schema.py +++ b/frappe/database/postgres/schema.py @@ -1,7 +1,6 @@ import frappe from frappe import _ from frappe.database.schema import DBTable, get_definition -from frappe.database.sequence import create_sequence from frappe.model import log_types from frappe.utils import cint, flt @@ -39,7 +38,7 @@ class PostgresTable(DBTable): # Since we're opening and closing connections for every transaction this results in skipping the cache # to the next non-cached value hence not using cache in postgres. # ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers - create_sequence(self.doctype, check_not_exists=True) + frappe.db.create_sequence(self.doctype, check_not_exists=True) name_column = "name bigint primary key" # TODO: set docstatus length diff --git a/frappe/database/query.py b/frappe/database/query.py index 8d8a767370..136f5c86b6 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -108,11 +108,14 @@ def change_orderby(order: str): tuple: field, order """ order = order.split() - if order[1].lower() == "asc": - orderby, order = order[0], Order.asc - return orderby, order - orderby, order = order[0], Order.desc - return orderby, order + + try: + if order[1].lower() == "asc": + return order[0], Order.asc + except IndexError: + pass + + return order[0], Order.desc OPERATOR_MAP = { @@ -175,10 +178,13 @@ class Query: """ if kwargs.get("orderby"): orderby = kwargs.get("orderby") - order = kwargs.get("order") if kwargs.get("order") else Order.desc if isinstance(orderby, str) and len(orderby.split()) > 1: - orderby, order = change_orderby(orderby) - conditions = conditions.orderby(orderby, order=order) + for ordby in orderby.split(","): + if ordby := ordby.strip(): + orderby, order = change_orderby(ordby) + conditions = conditions.orderby(orderby, order=order) + else: + conditions = conditions.orderby(orderby, order=kwargs.get("order") or Order.desc) if kwargs.get("limit"): conditions = conditions.limit(kwargs.get("limit")) @@ -288,7 +294,7 @@ class Query: table: str, fields: Union[List, Tuple], filters: Union[Dict[str, Union[str, int]], str, int] = None, - **kwargs + **kwargs, ): criterion = self.build_conditions(table, filters, **kwargs) if isinstance(fields, (list, tuple)): diff --git a/frappe/database/sequence.py b/frappe/database/sequence.py index c4789dbdaf..ede4689485 100644 --- a/frappe/database/sequence.py +++ b/frappe/database/sequence.py @@ -5,6 +5,7 @@ def create_sequence( doctype_name: str, *, slug: str = "_id_seq", + temporary=False, check_not_exists: bool = False, cycle: bool = False, cache: int = 0, @@ -14,7 +15,7 @@ def create_sequence( max_value: int = 0, ) -> str: - query = "create sequence" + query = "create sequence" if not temporary else "create temporary sequence" sequence_name = scrub(doctype_name + slug) if check_not_exists: @@ -22,29 +23,29 @@ def create_sequence( query += f" {sequence_name}" - if cache: - query += f" cache {cache}" - else: - # in postgres, the default is cache 1 - if db.db_type == "mariadb": - query += " nocache" - - if start_value: - # default is 1 - query += f" start with {start_value}" - if increment_by: # default is 1 query += f" increment by {increment_by}" if min_value: # default is 1 - query += f" min value {min_value}" + query += f" minvalue {min_value}" if max_value: - query += f" max value {max_value}" + query += f" maxvalue {max_value}" + + if start_value: + # default is 1 + query += f" start {start_value}" + + # in postgres, the default is cache 1 / no cache + if cache: + query += f" cache {cache}" + elif db.db_type == "mariadb": + query += " nocache" if not cycle: + # in postgres, default is no cycle if db.db_type == "mariadb": query += " nocycle" else: @@ -56,21 +57,23 @@ def create_sequence( def get_next_val(doctype_name: str, slug: str = "_id_seq") -> int: - if db.db_type == "postgres": - return db.sql(f"select nextval('\"{scrub(doctype_name + slug)}\"')")[0][0] - return db.sql(f"select nextval(`{scrub(doctype_name + slug)}`)")[0][0] + return db.multisql( + { + "postgres": f"select nextval('\"{scrub(doctype_name + slug)}\"')", + "mariadb": f"select nextval(`{scrub(doctype_name + slug)}`)", + } + )[0][0] def set_next_val( doctype_name: str, next_val: int, *, slug: str = "_id_seq", is_val_used: bool = False ) -> None: - if not is_val_used: - is_val_used = 0 if db.db_type == "mariadb" else "f" - else: - is_val_used = 1 if db.db_type == "mariadb" else "t" + is_val_used = "false" if not is_val_used else "true" - if db.db_type == "postgres": - db.sql(f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, '{is_val_used}')") - else: - db.sql(f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})") + db.multisql( + { + "postgres": f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, {is_val_used})", + "mariadb": f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})", + } + ) diff --git a/frappe/desk/doctype/number_card/number_card.js b/frappe/desk/doctype/number_card/number_card.js index f548388a99..79ddb71187 100644 --- a/frappe/desk/doctype/number_card/number_card.js +++ b/frappe/desk/doctype/number_card/number_card.js @@ -27,8 +27,11 @@ frappe.ui.form.on('Number Card', { frm.trigger('set_method_description'); frm.trigger('render_filters_table'); } - frm.trigger('create_add_to_dashboard_button'); frm.trigger('set_parent_document_type'); + + if (!frm.is_new()) { + frm.trigger('create_add_to_dashboard_button'); + } }, create_add_to_dashboard_button: function(frm) { diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 370b187ffe..e1b2b19026 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -20,15 +20,24 @@ class NumberCard(Document): self.name = append_number_if_name_exists("Number Card", self.name) def validate(self): - if not self.document_type: - frappe.throw(_("Document type is required to create a number card")) + if self.type == "Document Type": + if not (self.document_type and self.function): + frappe.throw(_("Document Type and Function are required to create a number card")) - if ( - self.document_type - and frappe.get_meta(self.document_type).istable - and not self.parent_document_type - ): - frappe.throw(_("Parent document type is required to create a number card")) + if ( + self.document_type + and frappe.get_meta(self.document_type).istable + and not self.parent_document_type + ): + frappe.throw(_("Parent Document Type is required to create a number card")) + + elif self.type == "Report": + if not (self.report_name and self.report_field and self.function): + frappe.throw(_("Report Name, Report Field and Fucntion are required to create a number card")) + + elif self.type == "Custom": + if not self.method: + frappe.throw(_("Method is required to create a number card")) def on_update(self): if frappe.conf.developer_mode and self.is_standard: diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index a2dbcbfbe2..284fecbe31 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -80,7 +80,14 @@ class Workspace(Document): # remove duplicate before adding for idx, link in enumerate(self.links): - if link.label == card.get("label") and link.type == "Card Break": + if link.get("label") == card.get("label") and link.get("type") == "Card Break": + # count and set number of links for the card if link_count is 0 + if link.link_count == 0: + for count, card_link in enumerate(self.links[idx + 1 :]): + if card_link.get("type") == "Card Break": + break + link.link_count = count + 1 + del self.links[idx : idx + link.link_count + 1] self.append( @@ -199,21 +206,31 @@ def update_page(name, title, icon, parent, public): doc.sequence_id = frappe.db.count("Workspace", {"public": public}, cache=True) doc.public = public doc.for_user = "" if public else doc.for_user or frappe.session.user - doc.label = "{0}-{1}".format(title, doc.for_user) if doc.for_user else title + doc.label = new_name = "{0}-{1}".format(title, doc.for_user) if doc.for_user else title doc.save(ignore_permissions=True) - if name != doc.label: - rename_doc("Workspace", name, doc.label, force=True, ignore_permissions=True) + if name != new_name: + rename_doc("Workspace", name, new_name, force=True, ignore_permissions=True) # update new name and public in child pages if child_docs: for child in child_docs: child_doc = frappe.get_doc("Workspace", child.name) child_doc.parent_page = doc.title - child_doc.public = doc.public + if child_doc.public != public: + child_doc.public = public + child_doc.for_user = "" if public else child_doc.for_user or frappe.session.user + child_doc.label = new_child_name = ( + "{0}-{1}".format(child_doc.title, child_doc.for_user) + if child_doc.for_user + else child_doc.title + ) child_doc.save(ignore_permissions=True) - return {"name": doc.title, "public": doc.public, "label": doc.label} + if child.name != new_child_name: + rename_doc("Workspace", child.name, new_child_name, force=True, ignore_permissions=True) + + return {"name": title, "public": public, "label": new_name} @frappe.whitelist() diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 894e82d117..a51fd8b1e3 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -58,6 +58,8 @@ def get_report_doc(report_name): def get_report_result(report, filters): + res = None + if report.report_type == "Query Report": res = report.execute_query_report(filters) @@ -84,7 +86,7 @@ def generate_report_result( res = get_report_result(report, filters) or [] columns, result, message, chart, report_summary, skip_total_row = ljust_list(res, 6) - columns = [get_column_as_dict(col) for col in columns] + columns = [get_column_as_dict(col) for col in (columns or [])] report_column_names = [col["fieldname"] for col in columns] # convert to list of dicts diff --git a/frappe/desk/search.py b/frappe/desk/search.py index ba4c5fb4fb..eb1a2e82ba 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -1,12 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import functools import json import re -import wrapt - -# Search import frappe from frappe import _, is_whitelisted from frappe.permissions import has_permission @@ -314,17 +312,20 @@ def relevance_sorter(key, query, as_dict): return (cstr(value).lower().startswith(query.lower()) is not True, value) -@wrapt.decorator -def validate_and_sanitize_search_inputs(fn, instance, args, kwargs): - kwargs.update(dict(zip(fn.__code__.co_varnames, args))) - sanitize_searchfield(kwargs["searchfield"]) - kwargs["start"] = cint(kwargs["start"]) - kwargs["page_len"] = cint(kwargs["page_len"]) +def validate_and_sanitize_search_inputs(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + kwargs.update(dict(zip(fn.__code__.co_varnames, args))) + sanitize_searchfield(kwargs["searchfield"]) + kwargs["start"] = cint(kwargs["start"]) + kwargs["page_len"] = cint(kwargs["page_len"]) - if kwargs["doctype"] and not frappe.db.exists("DocType", kwargs["doctype"]): - return [] + if kwargs["doctype"] and not frappe.db.exists("DocType", kwargs["doctype"]): + return [] - return fn(**kwargs) + return fn(**kwargs) + + return wrapper @frappe.whitelist() diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index 7a9af6149a..9f897a1308 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -12,6 +12,7 @@ from frappe.model.document import Document from frappe.model.naming import append_number_if_name_exists from frappe.utils import ( add_to_date, + cint, format_time, get_link_to_form, get_url_to_report, @@ -51,14 +52,18 @@ class AutoEmailReport(Document): self.email_to = "\n".join(valid) def validate_report_count(self): - """check that there are only 3 enabled reports per user""" - count = frappe.db.sql( - "select count(*) from `tabAuto Email Report` where user=%s and enabled=1", self.user - )[0][0] - max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 + count = frappe.db.count("Auto Email Report", {"user": self.user, "enabled": 1}) + + max_reports_per_user = ( + cint(frappe.local.conf.max_reports_per_user) # kept for backward compatibilty + or cint(frappe.db.get_single_value("System Settings", "max_auto_email_report_per_user")) + or 20 + ) if count > max_reports_per_user + (-1 if self.flags.in_insert else 0): - frappe.throw(_("Only {0} emailed reports are allowed per user").format(max_reports_per_user)) + msg = _("Only {0} emailed reports are allowed per user.").format(max_reports_per_user) + msg += " " + _("To allow more reports update limit in System Settings.") + frappe.throw(msg, title=_("Report limit reached")) def validate_report_format(self): """check if user has select correct report format""" diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 54f0d2372d..a357126a48 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -134,10 +134,11 @@ frappe.ui.form.on("Email Account", { show_gmail_message_for_less_secure_apps: function(frm) { frm.dashboard.clear_headline(); + let msg = __("GMail will only work if you enable 2-step authentication and use app-specific password."); + let cta = __("Read the step by step guide here."); + msg += ` ${cta}`; if (frm.doc.service==="GMail") { - frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \ - apps in Gmail settings. Read this for details'); + frm.dashboard.set_headline_alert(msg); } }, diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 61d730829b..db2ca9e32b 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -622,11 +622,13 @@ class QueueBuilder: mail_to_string = cstr(mail.as_string()) except frappe.InvalidEmailAddressError: # bad Email Address - don't add to queue - self.log_error( + frappe.log_error( title="Invalid email address", message="Invalid email address Sender: {0}, Recipients: {1}, \nTraceback: {2} ".format( self.sender, ", ".join(self.final_recipients()), traceback.format_exc() ), + reference_doctype=self.reference_doctype, + reference_name=self.reference_name, ) return diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 6aa881ed5c..b04ad4db40 100644 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -124,7 +124,7 @@ class Newsletter(WebsiteGenerator): ) def get_success_recipients(self) -> List[str]: - """Recipients who have already recieved the newsletter. + """Recipients who have already received the newsletter. Couldn't think of a better name ;) """ @@ -132,7 +132,7 @@ class Newsletter(WebsiteGenerator): "Email Queue Recipient", filters={ "status": ("in", ["Not Sent", "Sending", "Sent"]), - "parentfield": ("in", self.get_linked_email_queue()), + "parent": ("in", self.get_linked_email_queue()), }, pluck="recipient", ) diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index c62b7e84aa..81702f3a09 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -221,3 +221,24 @@ class TestNewsletter(TestNewsletterMixin, unittest.TestCase): newsletter.reload() self.assertEqual(newsletter.email_sent, 0) + + def test_retry_partially_sent_newsletter(self): + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") + frappe.db.delete("Newsletter") + + newsletter = self.get_newsletter() + newsletter.send_emails() + email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")] + self.assertEqual(len(email_queue_list), 4) + + # emulate partial send + email_queue_list[0].status = "Error" + email_queue_list[0].recipients[0].status = "Error" + email_queue_list[0].save() + newsletter.email_sent = False + + # retry + newsletter.send_emails() + email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")] + self.assertEqual(len(email_queue_list), 5) diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 07f698f740..5e2f14d9bf 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -268,9 +268,9 @@ class EMail: self.replace_sender() self.replace_sender_name() - self.recipients = [strip(r) for r in self.recipients] - self.cc = [strip(r) for r in self.cc] - self.bcc = [strip(r) for r in self.bcc] + self.recipients = [strip(r) for r in self.recipients if r not in frappe.STANDARD_USERS] + self.cc = [strip(r) for r in self.cc if r not in frappe.STANDARD_USERS] + self.bcc = [strip(r) for r in self.bcc if r not in frappe.STANDARD_USERS] for e in self.recipients + (self.cc or []) + (self.bcc or []): validate_email_address(e, True) diff --git a/frappe/geo/country_info.json b/frappe/geo/country_info.json index 23cadc2156..735dcddac3 100644 --- a/frappe/geo/country_info.json +++ b/frappe/geo/country_info.json @@ -8,7 +8,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Kabul" - ] + ], + "isd": "+93" }, "Albania": { "code": "al", @@ -20,7 +21,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Tirane" - ] + ], + "isd": "+355" }, "Algeria": { "code": "dz", @@ -32,11 +34,13 @@ "number_format": "#,###.##", "timezones": [ "Africa/Algiers" - ] + ], + "isd": "+213" }, "American Samoa": { "code": "as", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+1684" }, "Andorra": { "code": "ad", @@ -48,7 +52,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Andorra" - ] + ], + "isd": "+376" }, "Angola": { "code": "ao", @@ -60,7 +65,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Luanda" - ] + ], + "isd": "+244" }, "Anguilla": { "code": "ai", @@ -72,7 +78,8 @@ "number_format": "#,###.##", "timezones": [ "America/Anguilla" - ] + ], + "isd": "+1264" }, "Antarctica": { "code": "aq", @@ -88,7 +95,8 @@ "Antarctica/Rothera", "Antarctica/Syowa", "Antarctica/Vostok" - ] + ], + "isd": "+672" }, "Antigua and Barbuda": { "code": "ag", @@ -100,7 +108,8 @@ "number_format": "#,###.##", "timezones": [ "America/Antigua" - ] + ], + "isd": "+1268" }, "Argentina": { "code": "ar", @@ -123,7 +132,8 @@ "America/Argentina/San_Luis", "America/Argentina/Tucuman", "America/Argentina/Ushuaia" - ] + ], + "isd": "+54" }, "Armenia": { "code": "am", @@ -135,7 +145,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Yerevan" - ] + ], + "isd": "+374" }, "Aruba": { "code": "aw", @@ -147,7 +158,8 @@ "number_format": "#,###.##", "timezones": [ "America/Aruba" - ] + ], + "isd": "+297" }, "Australia": { "code": "au", @@ -170,7 +182,8 @@ "Australia/Melbourne", "Australia/Perth", "Australia/Sydney" - ] + ], + "isd": "+61" }, "Austria": { "code": "at", @@ -182,7 +195,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Vienna" - ] + ], + "isd": "+43" }, "Azerbaijan": { "code": "az", @@ -192,7 +206,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Baku" - ] + ], + "isd": "+994" }, "Bahamas": { "code": "bs", @@ -201,7 +216,8 @@ "number_format": "#,###.##", "timezones": [ "America/Nassau" - ] + ], + "isd": "+1242" }, "Bahrain": { "code": "bh", @@ -213,7 +229,8 @@ "number_format": "#,###.###", "timezones": [ "Asia/Bahrain" - ] + ], + "isd": "+973" }, "Bangladesh": { "code": "bd", @@ -225,7 +242,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Dhaka" - ] + ], + "isd": "+880" }, "Barbados": { "code": "bb", @@ -237,7 +255,8 @@ "number_format": "#,###.##", "timezones": [ "America/Barbados" - ] + ], + "isd": "+1246" }, "Belarus": { "code": "by", @@ -247,7 +266,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Minsk" - ] + ], + "isd": "+375" }, "Belgium": { "code": "be", @@ -259,7 +279,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Brussels" - ] + ], + "isd": "+32" }, "Belize": { "code": "bz", @@ -272,7 +293,8 @@ "number_format": "#,###.##", "timezones": [ "America/Belize" - ] + ], + "isd": "+501" }, "Benin": { "code": "bj", @@ -284,7 +306,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Porto-Novo" - ] + ], + "isd": "+229" }, "Bermuda": { "code": "bm", @@ -296,7 +319,8 @@ "number_format": "#,###.##", "timezones": [ "Atlantic/Bermuda" - ] + ], + "isd": "+1441" }, "Bhutan": { "code": "bt", @@ -308,13 +332,15 @@ "number_format": "#,###.##", "timezones": [ "Asia/Thimphu" - ] + ], + "isd": "+975" }, "Bolivia, Plurinational State of": { "code": "bo", "currency": "BOB", "currency_name": "Boliviano", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+591" }, "Bonaire, Sint Eustatius and Saba": { "code": "bq", @@ -329,7 +355,8 @@ "number_format": "#.###,##", "timezones": [ "Europe/Sarajevo" - ] + ], + "isd": "+387" }, "Botswana": { "code": "bw", @@ -341,11 +368,13 @@ "number_format": "#,###.##", "timezones": [ "Africa/Gaborone" - ] + ], + "isd": "+267" }, "Bouvet Island": { "code": "bv", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+47" }, "Brazil": { "code": "br", @@ -372,7 +401,8 @@ "America/Rio_Branco", "America/Santarem", "America/Sao_Paulo" - ] + ], + "isd": "+55" }, "British Indian Ocean Territory": { "code": "io", @@ -382,7 +412,8 @@ "number_format": "#,###.##", "timezones": [ "Indian/Chagos" - ] + ], + "isd": "+246" }, "Brunei Darussalam": { "code": "bn", @@ -391,7 +422,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Brunei" - ] + ], + "isd": "+673" }, "Bulgaria": { "code": "bg", @@ -403,7 +435,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Sofia" - ] + ], + "isd": "+359" }, "Burkina Faso": { "code": "bf", @@ -415,7 +448,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Ouagadougou" - ] + ], + "isd": "+226" }, "Burundi": { "code": "bi", @@ -427,7 +461,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Bujumbura" - ] + ], + "isd": "+257" }, "Cambodia": { "code": "kh", @@ -439,7 +474,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Phnom_Penh" - ] + ], + "isd": "+855" }, "Cameroon": { "code": "cm", @@ -451,7 +487,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Douala" - ] + ], + "isd": "+237" }, "Canada": { "code": "ca", @@ -491,7 +528,8 @@ "America/Whitehorse", "America/Winnipeg", "America/Yellowknife" - ] + ], + "isd": "+1" }, "Cape Verde": { "code": "cv", @@ -503,7 +541,8 @@ "number_format": "#,###.##", "timezones": [ "Atlantic/Cape_Verde" - ] + ], + "isd": "+238" }, "Cayman Islands": { "code": "ky", @@ -515,7 +554,8 @@ "number_format": "#,###.##", "timezones": [ "America/Cayman" - ] + ], + "isd": "+ 345" }, "Central African Republic": { "code": "cf", @@ -527,7 +567,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Bangui" - ] + ], + "isd": "+236" }, "Chad": { "code": "td", @@ -539,7 +580,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Ndjamena" - ] + ], + "isd": "+235" }, "Chile": { "code": "cl", @@ -552,7 +594,8 @@ "timezones": [ "America/Santiago", "Pacific/Easter" - ] + ], + "isd": "+56" }, "China": { "code": "cn", @@ -568,14 +611,16 @@ "Asia/Kashgar", "Asia/Shanghai", "Asia/Urumqi" - ] + ], + "isd": "+86" }, "Christmas Island": { "code": "cx", "number_format": "#,###.##", "timezones": [ "Indian/Christmas" - ] + ], + "isd": "+61" }, "Cocos (Keeling) Islands": { "code": "cc", @@ -585,7 +630,8 @@ "number_format": "#,###.##", "timezones": [ "Indian/Cocos" - ] + ], + "isd": "+61" }, "Colombia": { "code": "co", @@ -597,7 +643,8 @@ "number_format": "#.###,##", "timezones": [ "America/Bogota" - ] + ], + "isd": "+57" }, "Comoros": { "code": "km", @@ -609,7 +656,8 @@ "number_format": "#,###.##", "timezones": [ "Indian/Comoro" - ] + ], + "isd": "+269" }, "Congo": { "code": "cg", @@ -618,7 +666,8 @@ "currency_name": "Central African CFA Franc", "currency_symbol": "FCFA", "currency_fraction": "Centime", - "currency_fraction_units": 100 + "currency_fraction_units": 100, + "isd": "+242" }, "Congo, The Democratic Republic of the": { "code": "cd", @@ -627,7 +676,8 @@ "currency_name": "Congolese franc", "currency_symbol": "FC", "currency_fraction": "Centime", - "currency_fraction_units": 100 + "currency_fraction_units": 100, + "isd": "+243" }, "Cook Islands": { "code": "ck", @@ -637,7 +687,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Rarotonga" - ] + ], + "isd": "+682" }, "Costa Rica": { "code": "cr", @@ -649,7 +700,8 @@ "number_format": "#.###,##", "timezones": [ "America/Costa_Rica" - ] + ], + "isd": "+506" }, "Croatia": { "code": "hr", @@ -661,7 +713,8 @@ "number_format": "#.###,##", "timezones": [ "Europe/Zagreb" - ] + ], + "isd": "+385" }, "Cuba": { "code": "cu", @@ -673,7 +726,8 @@ "number_format": "#,###.##", "timezones": [ "America/Havana" - ] + ], + "isd": "+53" }, "Cura\u00e7ao": { "code": "cw", @@ -692,7 +746,8 @@ "number_format": "#.###,##", "timezones": [ "Asia/Nicosia" - ] + ], + "isd": "+357" }, "Czech Republic": { "code": "cz", @@ -704,7 +759,8 @@ "number_format": "#.###,##", "timezones": [ "Europe/Prague" - ] + ], + "isd": "+420" }, "Denmark": { "code": "dk", @@ -716,7 +772,8 @@ "number_format": "#.###,##", "timezones": [ "Europe/Copenhagen" - ] + ], + "isd": "+45" }, "Djibouti": { "code": "dj", @@ -728,7 +785,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Djibouti" - ] + ], + "isd": "+253" }, "Dominica": { "code": "dm", @@ -740,7 +798,8 @@ "number_format": "#,###.##", "timezones": [ "America/Dominica" - ] + ], + "isd": "+1767" }, "Dominican Republic": { "code": "do", @@ -752,7 +811,8 @@ "number_format": "#,###.##", "timezones": [ "America/Santo_Domingo" - ] + ], + "isd": "+1849" }, "Ecuador": { "code": "ec", @@ -763,7 +823,8 @@ "timezones": [ "America/Guayaquil", "Pacific/Galapagos" - ] + ], + "isd": "+593" }, "Egypt": { "code": "eg", @@ -775,7 +836,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Cairo" - ] + ], + "isd": "+20" }, "El Salvador": { "code": "sv", @@ -789,7 +851,8 @@ "number_format": "#,###.##", "timezones": [ "America/El_Salvador" - ] + ], + "isd": "+503" }, "Equatorial Guinea": { "code": "gq", @@ -801,7 +864,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Malabo" - ] + ], + "isd": "+240" }, "Eritrea": { "code": "er", @@ -813,7 +877,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Asmara" - ] + ], + "isd": "+291" }, "Estonia": { "code": "ee", @@ -825,7 +890,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Tallinn" - ] + ], + "isd": "+372" }, "Ethiopia": { "code": "et", @@ -837,13 +903,15 @@ "number_format": "#,###.##", "timezones": [ "Africa/Addis_Ababa" - ] + ], + "isd": "+251" }, "Falkland Islands (Malvinas)": { "code": "fk", "currency": "FKP", "currency_name": "Falkland Islands Pound", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+500" }, "Faroe Islands": { "code": "fo", @@ -853,7 +921,8 @@ "number_format": "#,###.##", "timezones": [ "Atlantic/Faroe" - ] + ], + "isd": "+298" }, "Fiji": { "code": "fj", @@ -865,7 +934,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Fiji" - ] + ], + "isd": "+679" }, "Finland": { "code": "fi", @@ -877,7 +947,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Helsinki" - ] + ], + "isd": "+358" }, "France": { "code": "fr", @@ -890,14 +961,16 @@ "date_format": "dd/mm/yyyy", "timezones": [ "Europe/Paris" - ] + ], + "isd": "+33" }, "French Guiana": { "code": "gf", "number_format": "#,###.##", "timezones": [ "America/Cayenne" - ] + ], + "isd": "+594" }, "French Polynesia": { "code": "pf", @@ -909,11 +982,13 @@ "Pacific/Gambier", "Pacific/Marquesas", "Pacific/Tahiti" - ] + ], + "isd": "+689" }, "French Southern Territories": { "code": "tf", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+262" }, "Gabon": { "code": "ga", @@ -925,7 +1000,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Libreville" - ] + ], + "isd": "+241" }, "Gambia": { "code": "gm", @@ -934,7 +1010,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Banjul" - ] + ], + "isd": "+220" }, "Georgia": { "code": "ge", @@ -944,7 +1021,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Tbilisi" - ] + ], + "isd": "+995" }, "Germany": { "code": "de", @@ -954,9 +1032,12 @@ "smallest_currency_fraction_value": 0.01, "currency_symbol": "\u20ac", "number_format": "#.###,##", + "date_format": "dd.mm.yyyy", + "time_format": "HH:mm", "timezones": [ "Europe/Berlin" - ] + ], + "isd": "+49" }, "Ghana": { "code": "gh", @@ -967,7 +1048,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Accra" - ] + ], + "isd": "+233" }, "Gibraltar": { "code": "gi", @@ -979,7 +1061,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Gibraltar" - ] + ], + "isd": "+350" }, "Greece": { "code": "gr", @@ -991,7 +1074,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Athens" - ] + ], + "isd": "+30" }, "Greenland": { "code": "gl", @@ -1001,7 +1085,8 @@ "America/Godthab", "America/Scoresbysund", "America/Thule" - ] + ], + "isd": "+299" }, "Grenada": { "code": "gd", @@ -1013,21 +1098,24 @@ "number_format": "#,###.##", "timezones": [ "America/Grenada" - ] + ], + "isd": "+1473" }, "Guadeloupe": { "code": "gp", "number_format": "#,###.##", "timezones": [ "America/Guadeloupe" - ] + ], + "isd": "+590" }, "Guam": { "code": "gu", "number_format": "#,###.##", "timezones": [ "Pacific/Guam" - ] + ], + "isd": "+1671" }, "Guatemala": { "code": "gt", @@ -1039,7 +1127,8 @@ "number_format": "#,###.##", "timezones": [ "America/Guatemala" - ] + ], + "isd": "+502" }, "Guernsey": { "code": "gg", @@ -1049,7 +1138,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/London" - ] + ], + "isd": "+44" }, "Guinea": { "code": "gn", @@ -1061,7 +1151,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Conakry" - ] + ], + "isd": "+224" }, "Guinea-Bissau": { "code": "gw", @@ -1073,7 +1164,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Bissau" - ] + ], + "isd": "+245" }, "Guyana": { "code": "gy", @@ -1085,7 +1177,8 @@ "number_format": "#,###.##", "timezones": [ "America/Guyana" - ] + ], + "isd": "+592" }, "Haiti": { "code": "ht", @@ -1098,15 +1191,18 @@ "timezones": [ "America/Guatemala", "America/Port-au-Prince" - ] + ], + "isd": "+509" }, "Heard Island and McDonald Islands": { "code": "hm", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+0" }, "Holy See (Vatican City State)": { "code": "va", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+379" }, "Honduras": { "code": "hn", @@ -1118,7 +1214,8 @@ "number_format": "#,###.##", "timezones": [ "America/Tegucigalpa" - ] + ], + "isd": "+504" }, "Hong Kong": { "code": "hk", @@ -1130,7 +1227,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Hong_Kong" - ] + ], + "isd": "+852" }, "Hungary": { "code": "hu", @@ -1143,7 +1241,8 @@ "number_format": "#.###", "timezones": [ "Europe/Budapest" - ] + ], + "isd": "+36" }, "Iceland": { "code": "is", @@ -1155,7 +1254,8 @@ "number_format": "#.###", "timezones": [ "Atlantic/Reykjavik" - ] + ], + "isd": "+354" }, "India": { "code": "in", @@ -1167,7 +1267,8 @@ "number_format": "#,##,###.##", "timezones": [ "Asia/Kolkata" - ] + ], + "isd": "+91" }, "Indonesia": { "code": "id", @@ -1182,7 +1283,8 @@ "Asia/Jayapura", "Asia/Makassar", "Asia/Pontianak" - ] + ], + "isd": "+62" }, "Iran": { "code": "ir", @@ -1192,7 +1294,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Tehran" - ] + ], + "isd": "+98" }, "Iraq": { "code": "iq", @@ -1204,7 +1307,8 @@ "number_format": "#,###.###", "timezones": [ "Asia/Baghdad" - ] + ], + "isd": "+964" }, "Ireland": { "code": "ie", @@ -1216,7 +1320,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Dublin" - ] + ], + "isd": "+353" }, "Isle of Man": { "code": "im", @@ -1226,7 +1331,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/London" - ] + ], + "isd": "+44" }, "Israel": { "code": "il", @@ -1238,7 +1344,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Jerusalem" - ] + ], + "isd": "+972" }, "Italy": { "code": "it", @@ -1251,7 +1358,8 @@ "date_format": "dd/mm/yyyy", "timezones": [ "Europe/Rome" - ] + ], + "isd": "+39" }, "Ivory Coast": { "code": "ci", @@ -1263,7 +1371,8 @@ "number_format": "#,###.##", "timeszones": [ "Africa/Abidjan" - ] + ], + "isd": "+225" }, "Jamaica": { "code": "jm", @@ -1275,7 +1384,8 @@ "number_format": "#,###.##", "timezones": [ "America/Jamaica" - ] + ], + "isd": "+1876" }, "Japan": { "code": "jp", @@ -1287,7 +1397,8 @@ "number_format": "#,###", "timezones": [ "Asia/Tokyo" - ] + ], + "isd": "+81" }, "Jersey": { "code": "je", @@ -1297,7 +1408,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/London" - ] + ], + "isd": "+44" }, "Jordan": { "code": "jo", @@ -1309,7 +1421,8 @@ "number_format": "#,###.###", "timezones": [ "Asia/Amman" - ] + ], + "isd": "+962" }, "Kazakhstan": { "code": "kz", @@ -1325,7 +1438,8 @@ "Asia/Aqtobe", "Asia/Oral", "Asia/Qyzylorda" - ] + ], + "isd": "+7" }, "Kenya": { "code": "ke", @@ -1337,7 +1451,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Nairobi" - ] + ], + "isd": "+254" }, "Kiribati": { "code": "ki", @@ -1349,19 +1464,22 @@ "Pacific/Enderbury", "Pacific/Kiritimati", "Pacific/Tarawa" - ] + ], + "isd": "+686" }, "Korea, Democratic Peoples Republic of": { "code": "kp", "currency": "KPW", "currency_name": "North Korean Won", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+850" }, "Korea, Republic of": { "code": "kr", "currency": "KRW", "currency_name": "Won", - "number_format": "#,###" + "number_format": "#,###", + "isd": "+82" }, "Kuwait": { "code": "kw", @@ -1373,7 +1491,8 @@ "number_format": "#,###.###", "timezones": [ "Asia/Kuwait" - ] + ], + "isd": "+965" }, "Kyrgyzstan": { "code": "kg", @@ -1385,16 +1504,18 @@ "number_format": "#,###.##", "timezones": [ "Asia/Bishkek" - ] + ], + "isd": "+996" }, "Lao Peoples Democratic Republic": { "code": "la", "currency": "LAK", "currency_name": "Kip", "number_format": "#,###.##", - "timezones":[ - "Asia/Vientiane" - ] + "timezones": [ + "Asia/Vientiane" + ], + "isd": "+856" }, "Latvia": { "code": "lv", @@ -1406,7 +1527,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Riga" - ] + ], + "isd": "+371" }, "Lebanon": { "code": "lb", @@ -1418,7 +1540,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Beirut" - ] + ], + "isd": "+961" }, "Lesotho": { "code": "ls", @@ -1430,7 +1553,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Maseru" - ] + ], + "isd": "+266" }, "Liberia": { "code": "lr", @@ -1442,7 +1566,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Monrovia" - ] + ], + "isd": "+231" }, "Libya": { "code": "ly", @@ -1454,7 +1579,8 @@ "number_format": "#,###.###", "timezones": [ "Africa/Tripoli" - ] + ], + "isd": "+218" }, "Liechtenstein": { "code": "li", @@ -1464,7 +1590,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Vaduz" - ] + ], + "isd": "+423" }, "Lithuania": { "code": "lt", @@ -1477,7 +1604,8 @@ "number_format": "# ###,##", "timezones": [ "Europe/Vilnius" - ] + ], + "isd": "+370" }, "Luxembourg": { "code": "lu", @@ -1489,13 +1617,15 @@ "number_format": "#,###.##", "timezones": [ "Europe/Luxembourg" - ] + ], + "isd": "+352" }, "Macao": { "code": "mo", "currency": "MOP", "currency_name": "Pataca", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+853" }, "Macedonia": { "code": "mk", @@ -1504,7 +1634,8 @@ "currency_fraction_units": 100, "currency_name": "Denar", "currency_symbol": "\u0434\u0435\u043d", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+389" }, "Madagascar": { "code": "mg", @@ -1514,7 +1645,8 @@ "number_format": "#,###.##", "timezones": [ "Indian/Antananarivo" - ] + ], + "isd": "+261" }, "Malawi": { "code": "mw", @@ -1526,7 +1658,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Blantyre" - ] + ], + "isd": "+265" }, "Malaysia": { "code": "my", @@ -1539,7 +1672,8 @@ "timezones": [ "Asia/Kuala_Lumpur", "Asia/Kuching" - ] + ], + "isd": "+60" }, "Maldives": { "code": "mv", @@ -1551,7 +1685,8 @@ "number_format": "#,###.##", "timezones": [ "Indian/Maldives" - ] + ], + "isd": "+960" }, "Mali": { "code": "ml", @@ -1563,7 +1698,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Bamako" - ] + ], + "isd": "+223" }, "Malta": { "code": "mt", @@ -1576,7 +1712,8 @@ "date_format": "dd/mm/yyyy", "timezones": [ "Europe/Malta" - ] + ], + "isd": "+356" }, "Marshall Islands": { "code": "mh", @@ -1587,14 +1724,16 @@ "timezones": [ "Pacific/Kwajalein", "Pacific/Majuro" - ] + ], + "isd": "+692" }, "Martinique": { "code": "mq", "number_format": "#,###.##", "timezones": [ "America/Martinique" - ] + ], + "isd": "+596" }, "Mauritania": { "code": "mr", @@ -1606,7 +1745,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Nouakchott" - ] + ], + "isd": "+222" }, "Mauritius": { "code": "mu", @@ -1618,14 +1758,16 @@ "number_format": "#,###", "timezones": [ "Indian/Mauritius" - ] + ], + "isd": "+230" }, "Mayotte": { "code": "yt", "number_format": "#,###.##", "timezones": [ "Indian/Mayotte" - ] + ], + "isd": "+262" }, "Mexico": { "code": "mx", @@ -1648,17 +1790,20 @@ "America/Ojinaga", "America/Santa_Isabel", "America/Tijuana" - ] + ], + "isd": "+52" }, "Micronesia, Federated States of": { "code": "fm", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+691" }, "Moldova, Republic of": { "code": "md", "currency": "MDL", "currency_name": "Moldovan Leu", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+373" }, "Monaco": { "code": "mc", @@ -1670,7 +1815,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Monaco" - ] + ], + "isd": "+377" }, "Mongolia": { "code": "mn", @@ -1685,7 +1831,8 @@ "Asia/Choibalsan", "Asia/Hovd", "Asia/Ulaanbaatar" - ] + ], + "isd": "+976" }, "Montenegro": { "code": "me", @@ -1697,7 +1844,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Belgrade" - ] + ], + "isd": "+382" }, "Montserrat": { "code": "ms", @@ -1709,7 +1857,8 @@ "number_format": "#,###.##", "timezones": [ "America/Montserrat" - ] + ], + "isd": "+1664" }, "Morocco": { "code": "ma", @@ -1721,7 +1870,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Casablanca" - ] + ], + "isd": "+212" }, "Mozambique": { "code": "mz", @@ -1732,7 +1882,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Maputo" - ] + ], + "isd": "+258" }, "Myanmar": { "code": "mm", @@ -1741,7 +1892,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Rangoon" - ] + ], + "isd": "+95" }, "Namibia": { "code": "na", @@ -1753,7 +1905,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Windhoek" - ] + ], + "isd": "+264" }, "Nauru": { "code": "nr", @@ -1763,7 +1916,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Nauru" - ] + ], + "isd": "+674" }, "Nepal": { "code": "np", @@ -1775,7 +1929,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Kathmandu" - ] + ], + "isd": "+977" }, "Netherlands": { "code": "nl", @@ -1787,7 +1942,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Amsterdam" - ] + ], + "isd": "+31" }, "New Caledonia": { "code": "nc", @@ -1797,7 +1953,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Noumea" - ] + ], + "isd": "+687" }, "New Zealand": { "code": "nz", @@ -1810,7 +1967,8 @@ "timezones": [ "Pacific/Auckland", "Pacific/Chatham" - ] + ], + "isd": "+64" }, "Nicaragua": { "code": "ni", @@ -1822,7 +1980,8 @@ "number_format": "#,###.##", "timezones": [ "America/Managua" - ] + ], + "isd": "+505" }, "Niger": { "code": "ne", @@ -1834,7 +1993,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Niamey" - ] + ], + "isd": "+227" }, "Nigeria": { "code": "ng", @@ -1846,7 +2006,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Lagos" - ] + ], + "isd": "+234" }, "Niue": { "code": "nu", @@ -1856,21 +2017,24 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Niue" - ] + ], + "isd": "+683" }, "Norfolk Island": { "code": "nf", "number_format": "#,###.##", "timezones": [ "Pacific/Norfolk" - ] + ], + "isd": "+672" }, "Northern Mariana Islands": { "code": "mp", "number_format": "#,###.##", "timezones": [ "Pacific/Saipan" - ] + ], + "isd": "+1670" }, "Norway": { "code": "no", @@ -1882,7 +2046,8 @@ "number_format": "#.###,##", "timezones": [ "Europe/Oslo" - ] + ], + "isd": "+47" }, "Oman": { "code": "om", @@ -1894,7 +2059,8 @@ "number_format": "#,###.###", "timezones": [ "Asia/Muscat" - ] + ], + "isd": "+968" }, "Pakistan": { "code": "pk", @@ -1906,7 +2072,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Karachi" - ] + ], + "isd": "+92" }, "Palau": { "code": "pw", @@ -1917,11 +2084,13 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Palau" - ] + ], + "isd": "+680" }, "Palestinian Territory, Occupied": { "code": "ps", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+970" }, "Panama": { "code": "pa", @@ -1931,7 +2100,8 @@ "number_format": "#,###.##", "timezones": [ "America/Panama" - ] + ], + "isd": "+507" }, "Papua New Guinea": { "code": "pg", @@ -1943,7 +2113,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Port_Moresby" - ] + ], + "isd": "+675" }, "Paraguay": { "code": "py", @@ -1955,7 +2126,8 @@ "number_format": "#,###.##", "timezones": [ "America/Asuncion" - ] + ], + "isd": "+595" }, "Peru": { "code": "pe", @@ -1967,7 +2139,8 @@ "number_format": "#,###.##", "timezones": [ "America/Lima" - ] + ], + "isd": "+51" }, "Philippines": { "code": "ph", @@ -1980,14 +2153,16 @@ "number_format": "#,###.##", "timezones": [ "Asia/Manila" - ] + ], + "isd": "+63" }, "Pitcairn": { "code": "pn", "number_format": "#,###.##", "timezones": [ "Pacific/Pitcairn" - ] + ], + "isd": "+64" }, "Poland": { "code": "pl", @@ -1998,7 +2173,8 @@ "number_format": "#.###,##", "timezones": [ "Europe/Warsaw" - ] + ], + "isd": "+48" }, "Portugal": { "code": "pt", @@ -2012,14 +2188,16 @@ "Atlantic/Azores", "Atlantic/Madeira", "Europe/Lisbon" - ] + ], + "isd": "+351" }, "Puerto Rico": { "code": "pr", "number_format": "#,###.##", "timezones": [ "America/Puerto_Rico" - ] + ], + "isd": "+1939" }, "Qatar": { "code": "qa", @@ -2031,7 +2209,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Qatar" - ] + ], + "isd": "+974" }, "Romania": { "code": "ro", @@ -2043,13 +2222,15 @@ "number_format": "#,###.##", "timezones": [ "Europe/Bucharest" - ] + ], + "isd": "+40" }, "Russian Federation": { "code": "ru", "currency": "RUB", "currency_name": "Russian Ruble", - "number_format": "#.###,##" + "number_format": "#.###,##", + "isd": "+7" }, "Rwanda": { "code": "rw", @@ -2061,21 +2242,25 @@ "number_format": "#,###.##", "timezones": [ "Africa/Kigali" - ] + ], + "isd": "+250" }, "R\u00e9union": { "code": "re", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+262" }, "Saint Barth\u00e9lemy": { "code": "bl", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+590" }, "Saint Helena, Ascension and Tristan da Cunha": { "code": "sh", "currency": "SHP", "currency_name": "Saint Helena Pound", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+290" }, "Saint Kitts and Nevis": { "code": "kn", @@ -2087,7 +2272,8 @@ "number_format": "#,###.##", "timezones": [ "America/St_Kitts" - ] + ], + "isd": "+1869" }, "Saint Lucia": { "code": "lc", @@ -2099,15 +2285,18 @@ "number_format": "#,###.##", "timezones": [ "America/St_Lucia" - ] + ], + "isd": "+1758" }, "Saint Martin (French part)": { "code": "mf", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+590" }, "Saint Pierre and Miquelon": { "code": "pm", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+508" }, "Saint Vincent and the Grenadines": { "code": "vc", @@ -2119,7 +2308,8 @@ "number_format": "#,###.##", "timezones": [ "America/St_Vincent" - ] + ], + "isd": "+1784" }, "Samoa": { "code": "ws", @@ -2131,7 +2321,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Apia" - ] + ], + "isd": "+685" }, "San Marino": { "code": "sm", @@ -2143,13 +2334,15 @@ "number_format": "#,###.##", "timezones": [ "Europe/Rome" - ] + ], + "isd": "+378" }, "Sao Tome and Principe": { "code": "st", "currency": "STD", "currency_name": "Dobra", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+239" }, "Saudi Arabia": { "code": "sa", @@ -2161,7 +2354,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Riyadh" - ] + ], + "isd": "+966" }, "Senegal": { "code": "sn", @@ -2173,7 +2367,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Dakar" - ] + ], + "isd": "+221" }, "Serbia": { "code": "rs", @@ -2185,7 +2380,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Belgrade" - ] + ], + "isd": "+381" }, "Seychelles": { "code": "sc", @@ -2197,7 +2393,8 @@ "number_format": "#,###.##", "timezones": [ "Indian/Mahe" - ] + ], + "isd": "+248" }, "Sierra Leone": { "code": "sl", @@ -2209,7 +2406,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Freetown" - ] + ], + "isd": "+232" }, "Singapore": { "code": "sg", @@ -2221,7 +2419,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Singapore" - ] + ], + "isd": "+65" }, "Sint Maarten (Dutch part)": { "code": "sx", @@ -2237,7 +2436,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Bratislava" - ] + ], + "isd": "+421" }, "Slovenia": { "code": "si", @@ -2249,7 +2449,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Belgrade" - ] + ], + "isd": "+386" }, "Solomon Islands": { "code": "sb", @@ -2261,7 +2462,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Guadalcanal" - ] + ], + "isd": "+677" }, "Somalia": { "code": "so", @@ -2273,7 +2475,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Mogadishu" - ] + ], + "isd": "+252" }, "South Africa": { "code": "za", @@ -2286,14 +2489,16 @@ "number_format": "# ###.##", "timezones": [ "Africa/Johannesburg" - ] + ], + "isd": "+27" }, "South Georgia and the South Sandwich Islands": { "code": "gs", "currency_fraction": "Penny", "currency_fraction_units": 100, "currency_symbol": "\u00a3", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+500" }, "South Sudan": { "code": "ss", @@ -2303,7 +2508,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Juba" - ] + ], + "isd": "+211" }, "Spain": { "code": "es", @@ -2317,7 +2523,8 @@ "Africa/Ceuta", "Atlantic/Canary", "Europe/Madrid" - ] + ], + "isd": "+34" }, "Sri Lanka": { "code": "lk", @@ -2329,7 +2536,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Colombo" - ] + ], + "isd": "+94" }, "Sudan": { "code": "sd", @@ -2339,7 +2547,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Khartoum" - ] + ], + "isd": "+249" }, "Suriname": { "code": "sr", @@ -2350,11 +2559,13 @@ "number_format": "#,###.##", "timezones": [ "America/Paramaribo" - ] + ], + "isd": "+597" }, "Svalbard and Jan Mayen": { "code": "sj", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+47" }, "Swaziland": { "code": "sz", @@ -2366,7 +2577,8 @@ "number_format": "#, ###.##", "timezones": [ "Africa/Mbabane" - ] + ], + "isd": "+268" }, "Sweden": { "code": "se", @@ -2378,7 +2590,8 @@ "number_format": "#.###,##", "timezones": [ "Europe/Stockholm" - ] + ], + "isd": "+46" }, "Switzerland": { "code": "ch", @@ -2391,19 +2604,22 @@ "number_format": "#'###.##", "timezones": [ "Europe/Zurich" - ] + ], + "isd": "+41" }, "Syria": { "code": "sy", "currency": "SYP", "currency_name": "Syrian Pound", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+963" }, "Taiwan": { "code": "tw", "currency": "TWD", "date_format": "yyyy-mm-dd", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+886" }, "Tajikistan": { "code": "tj", @@ -2413,13 +2629,15 @@ "number_format": "#,###.##", "timezones": [ "Asia/Dushanbe" - ] + ], + "isd": "+992" }, "Tanzania": { "code": "tz", "currency": "TZS", "currency_name": "Tanzanian Shilling", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+255" }, "Thailand": { "code": "th", @@ -2431,11 +2649,13 @@ "number_format": "#,###.##", "timezones": [ "Asia/Bangkok" - ] + ], + "isd": "+66" }, "Timor-Leste": { "code": "tl", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+670" }, "Togo": { "code": "tg", @@ -2447,14 +2667,16 @@ "number_format": "#,###.##", "timezones": [ "Africa/Lome" - ] + ], + "isd": "+228" }, "Tokelau": { "code": "tk", "number_format": "#,###.##", "timezones": [ "Pacific/Fakaofo" - ] + ], + "isd": "+690" }, "Tonga": { "code": "to", @@ -2466,7 +2688,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Tongatapu" - ] + ], + "isd": "+676" }, "Trinidad and Tobago": { "code": "tt", @@ -2478,7 +2701,8 @@ "number_format": "#,###.##", "timezones": [ "America/Port_of_Spain" - ] + ], + "isd": "+1868" }, "Tunisia": { "code": "tn", @@ -2490,7 +2714,8 @@ "number_format": "#,###.###", "timezones": [ "Africa/Tunis" - ] + ], + "isd": "+216" }, "Turkey": { "code": "tr", @@ -2501,7 +2726,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/Istanbul" - ] + ], + "isd": "+90" }, "Turkmenistan": { "code": "tm", @@ -2513,14 +2739,16 @@ "number_format": "#,###.##", "timezones": [ "Asia/Ashgabat" - ] + ], + "isd": "+993" }, "Turks and Caicos Islands": { "code": "tc", "currency_fraction": "Cent", "currency_fraction_units": 100, "currency_symbol": "$", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+1649" }, "Tuvalu": { "code": "tv", @@ -2530,7 +2758,8 @@ "number_format": "#,###.##", "timezones": [ "Pacific/Funafuti" - ] + ], + "isd": "+688" }, "Uganda": { "code": "ug", @@ -2542,7 +2771,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Kampala" - ] + ], + "isd": "+256" }, "Ukraine": { "code": "ua", @@ -2557,7 +2787,8 @@ "Europe/Simferopol", "Europe/Uzhgorod", "Europe/Zaporozhye" - ] + ], + "isd": "+380" }, "United Arab Emirates": { "code": "ae", @@ -2569,7 +2800,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Dubai" - ] + ], + "isd": "+971" }, "United Kingdom": { "code": "gb", @@ -2581,7 +2813,8 @@ "number_format": "#,###.##", "timezones": [ "Europe/London" - ] + ], + "isd": "+44" }, "United States": { "code": "us", @@ -2624,7 +2857,8 @@ "America/Sitka", "America/Yakutat", "Pacific/Honolulu" - ] + ], + "isd": "+1" }, "United States Minor Outlying Islands": { "code": "um", @@ -2640,7 +2874,8 @@ "number_format": "#.###,##", "timezones": [ "America/Montevideo" - ] + ], + "isd": "+598" }, "Uzbekistan": { "code": "uz", @@ -2653,7 +2888,8 @@ "timezones": [ "Asia/Samarkand", "Asia/Tashkent" - ] + ], + "isd": "+998" }, "Vanuatu": { "code": "vu", @@ -2665,7 +2901,8 @@ "number_format": "#,###", "timezones": [ "Pacific/Efate" - ] + ], + "isd": "+678" }, "Venezuela, Bolivarian Republic of": { "code": "ve", @@ -2673,28 +2910,33 @@ "currency": "VEF", "currency_symbol": "Bs.", "currency_fraction": "Centimos", - "currency_fraction_units": 100 + "currency_fraction_units": 100, + "isd": "+58" }, "Vietnam": { "code": "vn", "currency": "VND", "currency_name": "Dong", - "number_format": "#.###" + "number_format": "#.###", + "isd": "+84" }, "Virgin Islands, British": { "code": "vg", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+1284" }, "Virgin Islands, U.S.": { "code": "vi", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+1340" }, "Wallis and Futuna": { "code": "wf", "currency_fraction": "Centime", "currency_fraction_units": 100, "currency_symbol": "Fr", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+681" }, "Western Sahara": { "code": "eh", @@ -2714,7 +2956,8 @@ "number_format": "#,###.##", "timezones": [ "Asia/Aden" - ] + ], + "isd": "+967" }, "Zambia": { "code": "zm", @@ -2726,7 +2969,8 @@ "number_format": "#,###.##", "timezones": [ "Africa/Lusaka" - ] + ], + "isd": "+260" }, "Zimbabwe": { "code": "zw", @@ -2738,10 +2982,12 @@ "number_format": "# ###.##", "timezones": [ "Africa/Harare" - ] + ], + "isd": "+263" }, "\u00c5land Islands": { "code": "ax", - "number_format": "#,###.##" + "number_format": "#,###.##", + "isd": "+358" } } diff --git a/frappe/hooks.py b/frappe/hooks.py index d3de3877ba..f7a67dc7ec 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -226,7 +226,6 @@ scheduler_events = { "frappe.sessions.clear_expired_sessions", "frappe.email.doctype.notification.notification.trigger_daily_alerts", "frappe.utils.scheduler.restrict_scheduler_events_if_dormant", - "frappe.email.doctype.auto_email_report.auto_email_report.send_daily", "frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.remove_unverified_record", "frappe.desk.form.document_follow.send_daily_updates", "frappe.social.doctype.energy_point_settings.energy_point_settings.allocate_review_points", @@ -241,6 +240,7 @@ scheduler_events = { "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", "frappe.utils.change_log.check_for_update", "frappe.integrations.doctype.s3_backup_settings.s3_backup_settings.take_backups_daily", + "frappe.email.doctype.auto_email_report.auto_email_report.send_daily", "frappe.integrations.doctype.google_drive.google_drive.daily_backup", ], "weekly_long": [ diff --git a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py index c4ffb74325..cc620aa32c 100644 --- a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py +++ b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py @@ -140,7 +140,7 @@ class RazorpaySettings(Document): headers={"content-type": "application/json"}, ) if not resp.get("id"): - frappe.log_error(str(resp), "Razorpay Failed while creating subscription") + frappe.log_error(message=str(resp), title="Razorpay Failed while creating subscription") except: frappe.log_error(frappe.get_traceback()) # failed @@ -179,7 +179,7 @@ class RazorpaySettings(Document): frappe.flags.status = "created" return kwargs else: - frappe.log_error(str(resp), "Razorpay Failed while creating subscription") + frappe.log_error(message=str(resp), title="Razorpay Failed while creating subscription") except: frappe.log_error(frappe.get_traceback()) @@ -281,7 +281,7 @@ class RazorpaySettings(Document): self.flags.status_changed_to = "Verified" else: - frappe.log_error(str(resp), "Razorpay Payment not authorized") + frappe.log_error(message=str(resp), title="Razorpay Payment not authorized") except: frappe.log_error(frappe.get_traceback()) diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index bd607e7119..29991fa403 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -36,10 +36,14 @@ data_fieldtypes = ( "Geolocation", "Duration", "Icon", + "Phone", "Autocomplete", "JSON", ) +float_like_fields = {"Float", "Currency", "Percent"} +datetime_fields = {"Datetime", "Date", "Time"} + attachment_fieldtypes = ( "Attach", "Attach Image", diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 02eb2ab38c..a272dedd02 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -2,10 +2,18 @@ # License: MIT. See LICENSE import datetime import json +from typing import Dict, List import frappe from frappe import _ -from frappe.model import child_table_fields, default_fields, display_fieldtypes, table_fields +from frappe.model import ( + child_table_fields, + datetime_fields, + default_fields, + display_fieldtypes, + float_like_fields, + table_fields, +) from frappe.model.docstatus import DocStatus from frappe.model.naming import set_new_name from frappe.model.utils.link_count import notify_link_count @@ -233,7 +241,6 @@ class BaseDocument(object): raise AttributeError(key) value = get_controller(value["doctype"])(value) - value.init_valid_columns() value.parent = self.name value.parenttype = self.doctype @@ -252,10 +259,11 @@ class BaseDocument(object): def get_valid_dict( self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False - ): + ) -> Dict: d = frappe._dict() for fieldname in self.meta.get_valid_columns(): - d[fieldname] = self.get(fieldname) + # column is valid, we can use getattr + d[fieldname] = getattr(self, fieldname, None) # if no need for sanitization and value is None, continue if not sanitize and d[fieldname] is None: @@ -263,25 +271,24 @@ class BaseDocument(object): df = self.meta.get_field(fieldname) - if df and df.get("is_virtual"): - if ignore_virtual: - del d[fieldname] - continue + if df: + if getattr(df, "is_virtual", False): + if ignore_virtual: + del d[fieldname] + continue - from frappe.utils.safe_exec import get_safe_globals + if d[fieldname] is None and (options := getattr(df, "options", None)): + from frappe.utils.safe_exec import get_safe_globals - if d[fieldname] is None: - if df.get("options"): d[fieldname] = frappe.safe_eval( - code=df.get("options"), + code=options, eval_globals=get_safe_globals(), eval_locals={"doc": self}, ) - else: - _val = getattr(self, fieldname, None) - if _val and not callable(_val): - d[fieldname] = _val - elif df: + + if isinstance(d[fieldname], list) and df.fieldtype not in table_fields: + frappe.throw(_("Value for {0} cannot be a list").format(_(df.label))) + if df.fieldtype == "Check": d[fieldname] = 1 if cint(d[fieldname]) else 0 @@ -291,25 +298,20 @@ class BaseDocument(object): elif df.fieldtype == "JSON" and isinstance(d[fieldname], dict): d[fieldname] = json.dumps(d[fieldname], sort_keys=True, indent=4, separators=(",", ": ")) - elif df.fieldtype in ("Currency", "Float", "Percent") and not isinstance(d[fieldname], float): + elif df.fieldtype in float_like_fields and not isinstance(d[fieldname], float): d[fieldname] = flt(d[fieldname]) - elif df.fieldtype in ("Datetime", "Date", "Time") and d[fieldname] == "": + elif (df.fieldtype in datetime_fields and d[fieldname] == "") or ( + getattr(df, "unique", False) and cstr(d[fieldname]).strip() == "" + ): d[fieldname] = None - elif df.get("unique") and cstr(d[fieldname]).strip() == "": - # unique empty field should be set to None - d[fieldname] = None - - if isinstance(d[fieldname], list) and df.fieldtype not in table_fields: - frappe.throw(_("Value for {0} cannot be a list").format(_(df.label))) - if convert_dates_to_str and isinstance( d[fieldname], (datetime.datetime, datetime.date, datetime.time, datetime.timedelta) ): d[fieldname] = str(d[fieldname]) - if d[fieldname] is None and ignore_nulls: + if ignore_nulls and d[fieldname] is None: del d[fieldname] return d @@ -329,7 +331,7 @@ class BaseDocument(object): if key not in self.__dict__: self.__dict__[key] = None - def get_valid_columns(self): + def get_valid_columns(self) -> List[str]: if self.doctype not in frappe.local.valid_columns: if self.doctype in DOCTYPES_FOR_DOCTYPE: from frappe.model.meta import get_table_columns @@ -342,12 +344,12 @@ class BaseDocument(object): return frappe.local.valid_columns[self.doctype] - def is_new(self): + def is_new(self) -> bool: return self.get("__islocal") @property def docstatus(self): - return DocStatus(self.get("docstatus")) + return DocStatus(cint(self.get("docstatus"))) @docstatus.setter def docstatus(self, value): @@ -359,8 +361,8 @@ class BaseDocument(object): no_default_fields=False, convert_dates_to_str=False, no_child_table_fields=False, - ): - doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str) + ) -> Dict: + doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str, ignore_nulls=no_nulls) doc["doctype"] = self.doctype for df in self.meta.get_table_fields(): @@ -375,20 +377,15 @@ class BaseDocument(object): for d in children ] - if no_nulls: - for k in list(doc): - if doc[k] is None: - del doc[k] - if no_default_fields: - for k in list(doc): - if k in default_fields: - del doc[k] + for key in default_fields: + if key in doc: + del doc[key] if no_child_table_fields: - for k in list(doc): - if k in child_table_fields: - del doc[k] + for key in child_table_fields: + if key in doc: + del doc[key] for key in ( "_user_tags", @@ -398,8 +395,8 @@ class BaseDocument(object): "__run_link_triggers", "__unsaved", ): - if self.get(key): - doc[key] = self.get(key) + if value := getattr(self, key, None): + doc[key] = value return doc @@ -771,6 +768,10 @@ class BaseDocument(object): def _validate_data_fields(self): # data_field options defined in frappe.model.data_field_options + for phone_field in self.meta.get_phone_fields(): + phone = self.get(phone_field.fieldname) + frappe.utils.validate_phone_number_with_country_code(phone, phone_field.fieldname) + for data_field in self.meta.get_data_fields(): data = self.get(data_field.fieldname) data_field_options = data_field.get("options") diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index a7d9536ebc..acb63b5bfa 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -213,7 +213,7 @@ class DatabaseQuery(object): # left join parent, child tables for child in self.tables[1:]: - parent_name = self.cast_name(f"{self.tables[0]}.name") + parent_name = cast_name(f"{self.tables[0]}.name") args.tables += f" {self.join} {child} on ({child}.parent = {parent_name})" if self.grouped_or_conditions: @@ -225,6 +225,7 @@ class DatabaseQuery(object): args.conditions += (" or " if args.conditions else "") + " or ".join(self.or_conditions) self.set_field_tables() + self.cast_name_fields() fields = [] @@ -385,16 +386,8 @@ class DatabaseQuery(object): ] # add tables from fields if self.fields: - for i, field in enumerate(self.fields): - # add cast in locate/strpos - func_found = False - for func in sql_functions: - if func in field.lower(): - self.fields[i] = self.cast_name(field, func) - func_found = True - break - - if func_found or not ("tab" in field and "." in field): + for field in self.fields: + if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field): continue table_name = field.split(".")[0] @@ -406,38 +399,6 @@ class DatabaseQuery(object): if table_name not in self.tables: self.append_table(table_name) - def cast_name( - self, - column: str, - sql_function: str = "", - ) -> str: - if frappe.db.db_type == "postgres": - if "name" in column.lower(): - if "cast(" not in column.lower() or "::" not in column: - if not sql_function: - return f"cast({column} as varchar)" - - elif sql_function == "locate(": - return re.sub( - r"locate\(([^,]+),([^)]+)\)", - r"locate(\1, cast(\2 as varchar))", - column, - flags=re.IGNORECASE, - ) - - elif sql_function == "strpos(": - return re.sub( - r"strpos\(([^,]+),([^)]+)\)", - r"strpos(cast(\1 as varchar), \2)", - column, - flags=re.IGNORECASE, - ) - - elif sql_function == "ifnull(": - return re.sub(r"ifnull\(([^,]+)", r"ifnull(cast(\1 as varchar)", column, flags=re.IGNORECASE) - - return column - def append_table(self, table_name): self.tables.append(table_name) doctype = table_name[4:-1] @@ -462,6 +423,10 @@ class DatabaseQuery(object): if "." not in field and not _in_standard_sql_methods(field): self.fields[idx] = f"{self.tables[0]}.{field}" + def cast_name_fields(self): + for i, field in enumerate(self.fields): + self.fields[i] = cast_name(field) + def get_table_columns(self): try: return get_table_columns(self.doctype) @@ -541,10 +506,7 @@ class DatabaseQuery(object): if tname not in self.tables: self.append_table(tname) - if "ifnull(" in f.fieldname: - column_name = self.cast_name(f.fieldname, "ifnull(") - else: - column_name = self.cast_name(f"{tname}.`{f.fieldname}`") + column_name = cast_name(f.fieldname if "ifnull(" in f.fieldname else f"{tname}.`{f.fieldname}`") if f.operator.lower() in additional_filters_config: f.update(get_additional_filter_field(additional_filters_config, f, f.value)) @@ -766,7 +728,10 @@ class DatabaseQuery(object): return self.match_filters def get_share_condition(self): - return f"`tab{self.doctype}`.name in ({', '.join(frappe.db.escape(s, percent=False) for s in self.shared)})" + return ( + cast_name(f"`tab{self.doctype}`.name") + + f" in ({', '.join(frappe.db.escape(s, percent=False) for s in self.shared)})" + ) def add_user_permissions(self, user_permissions): meta = frappe.get_meta(self.doctype) @@ -794,7 +759,9 @@ class DatabaseQuery(object): if frappe.get_system_settings("apply_strict_user_permissions"): condition = "" else: - empty_value_condition = f"ifnull(`tab{self.doctype}`.`{df.get('fieldname')}`, '')=''" + empty_value_condition = cast_name( + f"ifnull(`tab{self.doctype}`.`{df.get('fieldname')}`, '')=''" + ) condition = empty_value_condition + " or " for permission in user_permission_values: @@ -815,7 +782,7 @@ class DatabaseQuery(object): if docs: values = ", ".join(frappe.db.escape(doc, percent=False) for doc in docs) - condition += f"`tab{self.doctype}`.`{df.get('fieldname')}` in ({values})" + condition += cast_name(f"`tab{self.doctype}`.`{df.get('fieldname')}`") + f" in ({values})" match_conditions.append(f"({condition})") match_filters[df.get("options")] = docs @@ -933,6 +900,40 @@ class DatabaseQuery(object): update_user_settings(self.doctype, user_settings) +def cast_name(column: str) -> str: + """Casts name field to varchar for postgres + + Handles majorly 4 cases: + 1. locate + 2. strpos + 3. ifnull + 4. coalesce + + Uses regex substitution. + + Example: + input - "ifnull(`tabBlog Post`.`name`, '')=''" + output - "ifnull(cast(`tabBlog Post`.`name` as varchar), '')=''" """ + + if frappe.db.db_type == "mariadb": + return column + + kwargs = {"string": column, "flags": re.IGNORECASE} + if "cast(" not in column.lower() and "::" not in column: + if re.search(r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\s*\)", **kwargs): + return re.sub( + r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\)", r"locate(\1, cast(\2 as varchar))", **kwargs + ) + + elif match := re.search(r"(strpos|ifnull|coalesce)\(\s*([`\"]?name[`\"]?)\s*,", **kwargs): + func = match.groups()[0] + return re.sub(rf"{func}\(\s*([`\"]?name[`\"]?)\s*,", rf"{func}(cast(\1 as varchar),", **kwargs) + + return re.sub(r"([`\"]?tab[\w`\" -]+\.[`\"]?name[`\"]?)(?!\w)", r"cast(\1 as varchar)", **kwargs) + + return column + + def check_parent_permission(parent, child_doctype): if parent: # User may pass fake parent and get the information from the child table diff --git a/frappe/model/document.py b/frappe/model/document.py index 67e1de0932..c5e61563f8 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -989,6 +989,16 @@ class Document(BaseDocument): self.docstatus = DocStatus.cancelled() return self.save() + @whitelist.__func__ + def _rename( + self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True + ): + """Rename the document. Triggers frappe.rename_doc, then reloads.""" + from frappe.model.rename_doc import rename_doc + + self.name = rename_doc(doc=self, new=name, merge=merge, force=force, validate=validate_rename) + self.reload() + @whitelist.__func__ def submit(self): """Submit the document. Sets `docstatus` = 1, then saves.""" @@ -999,6 +1009,13 @@ class Document(BaseDocument): """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 + ): + """Rename the document to `name`. This transforms the current object.""" + return self._rename(name=name, merge=merge, force=force, validate_rename=validate_rename) + def delete(self, ignore_permissions=False): """Delete document.""" frappe.delete_doc( @@ -1398,21 +1415,22 @@ class Document(BaseDocument): # See: Stock Reconciliation from frappe.utils.background_jobs import enqueue - if hasattr(self, "_" + action): - action = "_" + action + if hasattr(self, f"_{action}"): + action = f"_{action}" - if file_lock.lock_exists(self.get_signature()): + try: + self.lock() + except frappe.DocumentLockedError: frappe.throw( _("This document is currently queued for execution. Please try again"), title=_("Document Queued"), ) - self.lock() - enqueue( + return enqueue( "frappe.model.document.execute_action", - doctype=self.doctype, - name=self.name, - action=action, + __doctype=self.doctype, + __name=self.name, + __action=action, **kwargs, ) @@ -1433,10 +1451,13 @@ class Document(BaseDocument): if lock_exists: raise frappe.DocumentLockedError file_lock.create_lock(signature) + frappe.local.locked_documents.append(self) def unlock(self): """Delete the lock file for this document""" file_lock.delete_lock(self.get_signature()) + if self in frappe.local.locked_documents: + frappe.local.locked_documents.remove(self) # validation helpers def validate_from_to_dates(self, from_date_field, to_date_field): @@ -1495,12 +1516,12 @@ class Document(BaseDocument): return f"{doctype}({name})" -def execute_action(doctype, name, action, **kwargs): +def execute_action(__doctype, __name, __action, **kwargs): """Execute an action on a document (called by background worker)""" - doc = frappe.get_doc(doctype, name) + doc = frappe.get_doc(__doctype, __name) doc.unlock() try: - getattr(doc, action)(**kwargs) + getattr(doc, __action)(**kwargs) except Exception: frappe.db.rollback() @@ -1511,4 +1532,4 @@ def execute_action(doctype, name, action, **kwargs): msg = "
" + frappe.get_traceback() + "
" doc.add_comment("Comment", _("Action Failed") + "

" + msg) - doc.notify_update() + doc.notify_update() diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 407f7d0811..232d0a8d58 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -162,6 +162,9 @@ class Meta(Document): def get_data_fields(self): return self.get("fields", {"fieldtype": "Data"}) + def get_phone_fields(self): + return self.get("fields", {"fieldtype": "Phone"}) + def get_dynamic_link_fields(self): if not hasattr(self, "_dynamic_link_fields"): self._dynamic_link_fields = self.get("fields", {"fieldtype": "Dynamic Link"}) diff --git a/frappe/model/naming.py b/frappe/model/naming.py index aa502f5a4c..bb93244a66 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -14,6 +14,11 @@ if TYPE_CHECKING: from frappe.model.meta import Meta +# NOTE: This is used to keep track of status of sites +# whether `log_types` have autoincremented naming set for the site or not. +autoincremented_site_status_map = {} + + def set_new_name(doc): """ Sets the `name` property for the document based on various rules. @@ -35,9 +40,7 @@ def set_new_name(doc): doc.name = None if is_autoincremented(doc.doctype, meta): - from frappe.database.sequence import get_next_val - - doc.name = get_next_val(doc.doctype) + doc.name = frappe.db.get_next_sequence_val(doc.doctype) return if getattr(doc, "amended_from", None): @@ -72,12 +75,11 @@ def set_new_name(doc): doc.name = validate_name(doc.doctype, doc.name, meta.get_field("name_case")) -def is_autoincremented(doctype: str, meta: "Meta" = None): +def is_autoincremented(doctype: str, meta: Optional["Meta"] = None) -> bool: + """Checks if the doctype has autoincrement autoname set""" + if doctype in log_types: - if ( - frappe.local.autoincremented_status_map.get(frappe.local.site) is None - or frappe.local.autoincremented_status_map[frappe.local.site] == -1 - ): + if autoincremented_site_status_map.get(frappe.local.site) is None: if ( frappe.db.sql( f"""select data_type FROM information_schema.columns @@ -85,22 +87,19 @@ def is_autoincremented(doctype: str, meta: "Meta" = None): )[0][0] == "bigint" ): - frappe.local.autoincremented_status_map[frappe.local.site] = 1 + autoincremented_site_status_map[frappe.local.site] = 1 return True else: - frappe.local.autoincremented_status_map[frappe.local.site] = 0 + autoincremented_site_status_map[frappe.local.site] = 0 - elif frappe.local.autoincremented_status_map[frappe.local.site]: + elif autoincremented_site_status_map[frappe.local.site]: return True else: if not meta: meta = frappe.get_meta(doctype) - if getattr(meta, "issingle", False): - return False - - if meta.autoname == "autoincrement": + if not getattr(meta, "issingle", False) and meta.autoname == "autoincrement": return True return False @@ -329,11 +328,9 @@ def validate_name(doctype: str, name: Union[int, str], case: Optional[str] = Non if isinstance(name, int): if is_autoincremented(doctype): - from frappe.database.sequence import set_next_val - - # this will set the sequence val to be the provided name and set it to be used - # so that the sequence will start from the next val of the setted val(name) - set_next_val(doctype, name, is_val_used=True) + # this will set the sequence value to be the provided name/value and set it to be used + # so that the sequence will start from the next value + frappe.db.set_next_sequence_val(doctype, name, is_val_used=True) return name frappe.throw(_("Invalid name type (integer) for varchar name column"), frappe.NameError) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index dee364ae8d..a0cd10f967 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -4,12 +4,15 @@ from typing import TYPE_CHECKING, Dict, List, Optional import frappe from frappe import _, bold +from frappe.model.document import Document from frappe.model.dynamic_links import get_dynamic_link_map from frappe.model.naming import validate_name from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data from frappe.query_builder import Field -from frappe.utils import cint +from frappe.query_builder.utils import DocType, Table +from frappe.utils.data import sbool from frappe.utils.password import rename_password +from frappe.utils.scheduler import is_scheduler_inactive if TYPE_CHECKING: from frappe.model.meta import Meta @@ -23,10 +26,19 @@ def update_document_title( title: Optional[str] = None, name: Optional[str] = None, merge: bool = False, + enqueue: bool = False, **kwargs, ) -> str: """ - Update title from header in form view + Update the name or title of a document. Returns `name` if document was renamed, + `docname` if renaming operation was queued. + + :param doctype: DocType of the document + :param docname: Name of the document + :param title: New Title of the document + :param name: New Name of the document + :param merge: Merge the current Document with the existing one if exists + :param enqueue: Enqueue the rename operation, title is updated in current process """ # to maintain backwards API compatibility @@ -38,6 +50,10 @@ def update_document_title( if not isinstance(obj, (str, type(None))): frappe.throw(f"{obj=} must be of type str or None") + # handle bad API usages + merge = sbool(merge) + enqueue = sbool(enqueue) + doc = frappe.get_doc(doctype, docname) doc.check_permission(permtype="write") @@ -49,11 +65,34 @@ def update_document_title( name_updated = updated_name and (updated_name != doc.name) if name_updated: - docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge) + if enqueue and not is_scheduler_inactive(): + current_name = doc.name + + # before_name hook may have DocType specific validations or transformations + transformed_name = doc.run_method("before_rename", current_name, updated_name, merge) + if isinstance(transformed_name, dict): + transformed_name = transformed_name.get("new") + transformed_name = transformed_name or updated_name + + # run rename validations before queueing + # use savepoints to avoid partial renames / commits + validate_rename( + doctype=doctype, + old=current_name, + new=transformed_name, + meta=doc.meta, + merge=merge, + save_point=True, + ) + + doc.queue_action("rename", name=transformed_name, merge=merge) + else: + doc.rename(updated_name, merge=merge) if title_updated: try: - frappe.db.set_value(doctype, docname, title_field, updated_title) + setattr(doc, title_field, updated_title) + doc.save() frappe.msgprint(_("Saved"), alert=True, indicator="green") except Exception as e: if frappe.db.is_duplicate_entry(e): @@ -64,44 +103,64 @@ def update_document_title( ) raise - return docname + return doc.name def rename_doc( - doctype: str, - old: str, - new: str, + doctype: Optional[str] = None, + old: Optional[str] = None, + new: str = None, force: bool = False, merge: bool = False, ignore_permissions: bool = False, ignore_if_exists: bool = False, show_alert: bool = True, rebuild_search: bool = True, + doc: Optional[Document] = None, + validate: bool = True, ) -> str: - """Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link".""" - if not frappe.db.exists(doctype, old): - frappe.errprint(_("Failed: {0} to {1} because {0} doesn't exist.").format(old, new)) - return + """Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link". - if ignore_if_exists and frappe.db.exists(doctype, new): - frappe.errprint(_("Failed: {0} to {1} because {1} already exists.").format(old, new)) - return + doc: Document object to be renamed. + new: New name for the record. If None, and doctype is specified, new name may be automatically generated via before_rename hooks. + doctype: DocType of the document. Not required if doc is passed. + old: Current name of the document. Not required if doc is passed. + force: Allow even if document is not allowed to be renamed. + merge: Merge with existing document of new name. + ignore_permissions: Ignore user permissions while renaming. + ignore_if_exists: Don't raise exception if document with new name already exists. This will quietely overwrite the existing document. + show_alert: Display alert if document is renamed successfully. + rebuild_search: Rebuild linked doctype search after renaming. + validate: Validate before renaming. If False, it is assumed that the caller has already validated. + """ + old_usage_style = doctype and old and new + new_usage_style = doc and new - if old == new: - frappe.errprint( - _("Ignored: {0} to {1} no changes made because old and new name are the same.").format(old, new) + if not (new_usage_style or old_usage_style): + raise TypeError( + "{doctype, old, new} or {doc, new} are required arguments for frappe.model.rename_doc" ) - return - force = cint(force) - merge = cint(merge) + old = old or doc.name + doctype = doctype or doc.doctype + force = sbool(force) + merge = sbool(merge) meta = frappe.get_meta(doctype) - # call before_rename - old_doc = frappe.get_doc(doctype, old) - out = old_doc.run_method("before_rename", old, new, merge) or {} - new = (out.get("new") or new) if isinstance(out, dict) else (out or new) - new = validate_rename(doctype, new, meta, merge, force, ignore_permissions) + if validate: + old_doc = doc or frappe.get_doc(doctype, old) + out = old_doc.run_method("before_rename", old, new, merge) or {} + new = (out.get("new") or new) if isinstance(out, dict) else (out or new) + new = validate_rename( + doctype=doctype, + old=old, + new=new, + meta=meta, + merge=merge, + force=force, + ignore_permissions=ignore_permissions, + ignore_if_exists=ignore_if_exists, + ) if not merge: rename_parent_and_child(doctype, old, new, meta) @@ -139,11 +198,12 @@ def rename_doc( rename_password(doctype, old, new) # update user_permissions - frappe.db.sql( - """UPDATE `tabDefaultValue` SET `defvalue`=%s WHERE `parenttype`='User Permission' - AND `defkey`=%s AND `defvalue`=%s""", - (new, doctype, old), - ) + DefaultValue = DocType("DefaultValue") + frappe.qb.update(DefaultValue).set(DefaultValue.defvalue, new).where( + (DefaultValue.parenttype == "User Permission") + & (DefaultValue.defkey == doctype) + & (DefaultValue.defvalue == old) + ).run() if merge: new_doc.add_comment("Edit", _("merged {0} into {1}").format(frappe.bold(old), frappe.bold(new))) @@ -207,15 +267,13 @@ def update_user_settings(old: str, new: str, link_fields: List[Dict]) -> None: # find the user settings for the linked doctypes linked_doctypes = {d.parent for d in link_fields if not d.issingle} - user_settings_details = frappe.db.sql( - """SELECT `user`, `doctype`, `data` - FROM `__UserSettings` - WHERE `data` like %s - AND `doctype` IN ('{doctypes}')""".format( - doctypes="', '".join(linked_doctypes) - ), - (old), - as_dict=1, + UserSettings = Table("__UserSettings") + + user_settings_details = ( + frappe.qb.from_(UserSettings) + .select("user", "doctype", "data") + .where(UserSettings.data.like(old) & UserSettings.doctype.isin(linked_doctypes)) + .run(as_dict=True) ) # create the dict using the doctype name as key and values as list of the user settings @@ -240,37 +298,33 @@ def update_customizations(old: str, new: str) -> None: def update_attachments(doctype: str, old: str, new: str) -> None: - try: - if old != "File Data" and doctype != "DocType": - frappe.db.sql( - """update `tabFile` set attached_to_name=%s - where attached_to_name=%s and attached_to_doctype=%s""", - (new, old, doctype), - ) - except frappe.db.ProgrammingError as e: - if not frappe.db.is_column_missing(e): - raise + if doctype != "DocType": + File = DocType("File") + + frappe.qb.update(File).set(File.attached_to_name, new).where( + (File.attached_to_name == old) & (File.attached_to_doctype == doctype) + ).run() def rename_versions(doctype: str, old: str, new: str) -> None: - frappe.db.sql( - """UPDATE `tabVersion` SET `docname`=%s WHERE `ref_doctype`=%s AND `docname`=%s""", - (new, doctype, old), - ) + Version = DocType("Version") + + frappe.qb.update(Version).set(Version.docname, new).where( + (Version.docname == old) & (Version.ref_doctype == doctype) + ).run() def rename_eps_records(doctype: str, old: str, new: str) -> None: - epl = frappe.qb.DocType("Energy Point Log") - ( - frappe.qb.update(epl) - .set(epl.reference_name, new) - .where((epl.reference_doctype == doctype) & (epl.reference_name == old)) + EPL = DocType("Energy Point Log") + + frappe.qb.update(EPL).set(EPL.reference_name, new).where( + (EPL.reference_doctype == doctype) & (EPL.reference_name == old) ).run() def rename_parent_and_child(doctype: str, old: str, new: str, meta: "Meta") -> None: - # rename the doc - frappe.db.sql("UPDATE `tab{0}` SET `name`={1} WHERE `name`={1}".format(doctype, "%s"), (new, old)) + frappe.qb.update(doctype).set("name", new).where(Field("name") == old).run() + update_autoname_field(doctype, new, meta) update_child_docs(old, new, meta) @@ -280,20 +334,36 @@ def update_autoname_field(doctype: str, new: str, meta: "Meta") -> None: if meta.get("autoname"): field = meta.get("autoname").split(":") if field and field[0] == "field": - frappe.db.sql( - "UPDATE `tab{0}` SET `{1}`={2} WHERE `name`={2}".format(doctype, field[1], "%s"), (new, new) - ) + frappe.qb.update(doctype).set(field[1], new).where(Field("name") == new).run() def validate_rename( - doctype: str, new: str, meta: "Meta", merge: bool, force: bool, ignore_permissions: bool + doctype: str, + old: str, + new: str, + meta: "Meta", + merge: bool, + force: bool = False, + ignore_permissions: bool = False, + ignore_if_exists: bool = False, + save_point=False, ) -> str: # using for update so that it gets locked and someone else cannot edit it while this rename is going on! + if save_point: + _SAVE_POINT = f"validate_rename_{frappe.generate_hash(length=8)}" + frappe.db.savepoint(_SAVE_POINT) + exists = ( frappe.qb.from_(doctype).where(Field("name") == new).for_update().select("name").run(pluck=True) ) exists = exists[0] if exists else None + if not frappe.db.exists(doctype, old): + frappe.throw(_("Can't rename {0} to {1} because {0} doesn't exist.").format(old, new)) + + if old == new: + frappe.throw(_("No changes made because old and new name are the same.").format(old, new)) + if merge and not exists: frappe.throw(_("{0} {1} does not exist, select a new target to merge").format(doctype, new)) @@ -301,7 +371,7 @@ def validate_rename( # for fixing case, accents exists = None - if (not merge) and exists: + if not merge and exists and not ignore_if_exists: frappe.throw(_("Another {0} with name {1} exists, select another name").format(doctype, new)) if not ( @@ -315,6 +385,9 @@ def validate_rename( # validate naming like it's done in doc.py new = validate_name(doctype, new) + if save_point: + frappe.db.rollback(save_point=_SAVE_POINT) + return new @@ -337,9 +410,7 @@ def rename_doctype(doctype: str, old: str, new: str) -> None: def update_child_docs(old: str, new: str, meta: "Meta") -> None: # update "parent" for df in meta.get_table_fields(): - frappe.db.sql( - "update `tab%s` set parent=%s where parent=%s" % (df.options, "%s", "%s"), (new, old) - ) + frappe.qb.update(df.options).set("parent", new).where(Field("parent") == old).run() def update_link_field_values(link_fields: List[Dict], old: str, new: str, doctype: str) -> None: @@ -384,57 +455,46 @@ def get_link_fields(doctype: str) -> List[Dict]: frappe.flags.link_fields = {} if doctype not in frappe.flags.link_fields: - link_fields = frappe.db.sql( - """\ - select parent, fieldname, - (select issingle from tabDocType dt - where dt.name = df.parent) as issingle - from tabDocField df - where - df.options=%s and df.fieldtype='Link'""", - (doctype,), - as_dict=1, + dt = DocType("DocType") + df = DocType("DocField") + cf = DocType("Custom Field") + ps = DocType("Property Setter") + + st_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == df.parent).as_("issingle") + standard_fields = ( + frappe.qb.from_(df) + .select(df.parent, df.fieldname, st_issingle) + .where((df.options == doctype) & (df.fieldtype == "Link")) + .run(as_dict=True) ) - # get link fields from tabCustom Field - custom_link_fields = frappe.db.sql( - """\ - select dt as parent, fieldname, - (select issingle from tabDocType dt - where dt.name = df.dt) as issingle - from `tabCustom Field` df - where - df.options=%s and df.fieldtype='Link'""", - (doctype,), - as_dict=1, + cf_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == cf.dt).as_("issingle") + custom_fields = ( + frappe.qb.from_(cf) + .select(cf.dt.as_("parent"), cf.fieldname, cf_issingle) + .where((cf.options == doctype) & (cf.fieldtype == "Link")) + .run(as_dict=True) ) - # add custom link fields list to link fields list - link_fields += custom_link_fields - - # remove fields whose options have been changed using property setter - property_setter_link_fields = frappe.db.sql( - """\ - select ps.doc_type as parent, ps.field_name as fieldname, - (select issingle from tabDocType dt - where dt.name = ps.doc_type) as issingle - from `tabProperty Setter` ps - where - ps.property_type='options' and - ps.field_name is not null and - ps.value=%s""", - (doctype,), - as_dict=1, + ps_issingle = ( + frappe.qb.from_(dt).select(dt.issingle).where(dt.name == ps.doc_type).as_("issingle") + ) + property_setter_fields = ( + frappe.qb.from_(ps) + .select(ps.doc_type.as_("parent"), ps.field_name.as_("fieldname"), ps_issingle) + .where((ps.property == "options") & (ps.value == doctype) & (ps.field_name.notnull())) + .run(as_dict=True) ) - link_fields += property_setter_link_fields - - frappe.flags.link_fields[doctype] = link_fields + frappe.flags.link_fields[doctype] = standard_fields + custom_fields + property_setter_fields return frappe.flags.link_fields[doctype] def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None: + CustomField = DocType("Custom Field") + PropertySetter = DocType("Property Setter") + if frappe.conf.developer_mode: for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"): doctype = frappe.get_doc("DocType", name) @@ -446,23 +506,18 @@ def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None: if save: doctype.save() else: - frappe.db.sql( - """update `tabDocField` set options=%s - where fieldtype=%s and options=%s""", - (new, fieldtype, old), - ) + DocField = DocType("DocField") + frappe.qb.update(DocField).set(DocField.options, new).where( + (DocField.fieldtype == fieldtype) & (DocField.options == old) + ).run() - frappe.db.sql( - """update `tabCustom Field` set options=%s - where fieldtype=%s and options=%s""", - (new, fieldtype, old), - ) + frappe.qb.update(CustomField).set(CustomField.options, new).where( + (CustomField.fieldtype == fieldtype) & (CustomField.options == old) + ).run() - frappe.db.sql( - """update `tabProperty Setter` set value=%s - where property='options' and value=%s""", - (new, old), - ) + frappe.qb.update(PropertySetter).set(PropertySetter.value, new).where( + (PropertySetter.property == "options") & (PropertySetter.value == old) + ).run() def get_select_fields(old: str, new: str) -> List[Dict]: @@ -470,108 +525,87 @@ def get_select_fields(old: str, new: str) -> List[Dict]: get select type fields where doctype's name is hardcoded as new line separated list """ + df = DocType("DocField") + dt = DocType("DocType") + cf = DocType("Custom Field") + ps = DocType("Property Setter") + # get link fields from tabDocField - select_fields = frappe.db.sql( - """ - select parent, fieldname, - (select issingle from tabDocType dt - where dt.name = df.parent) as issingle - from tabDocField df - where - df.parent != %s and df.fieldtype = 'Select' and - df.options like {0} """.format( - frappe.db.escape("%" + old + "%") - ), - (new,), - as_dict=1, + st_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == df.parent).as_("issingle") + standard_fields = ( + frappe.qb.from_(df) + .select(df.parent, df.fieldname, st_issingle) + .where((df.parent != new) & (df.fieldtype == "Select") & (df.options.like(f"%{old}%"))) + .run(as_dict=True) ) # get link fields from tabCustom Field - custom_select_fields = frappe.db.sql( - """ - select dt as parent, fieldname, - (select issingle from tabDocType dt - where dt.name = df.dt) as issingle - from `tabCustom Field` df - where - df.dt != %s and df.fieldtype = 'Select' and - df.options like {0} """.format( - frappe.db.escape("%" + old + "%") - ), - (new,), - as_dict=1, + cf_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == cf.dt).as_("issingle") + custom_select_fields = ( + frappe.qb.from_(cf) + .select(cf.dt.as_("parent"), cf.fieldname, cf_issingle) + .where((cf.dt != new) & (cf.fieldtype == "Select") & (cf.options.like(f"%{old}%"))) + .run(as_dict=True) ) - # add custom link fields list to link fields list - select_fields += custom_select_fields - # remove fields whose options have been changed using property setter - property_setter_select_fields = frappe.db.sql( - """ - select ps.doc_type as parent, ps.field_name as fieldname, - (select issingle from tabDocType dt - where dt.name = ps.doc_type) as issingle - from `tabProperty Setter` ps - where - ps.doc_type != %s and - ps.property_type='options' and - ps.field_name is not null and - ps.value like {0} """.format( - frappe.db.escape("%" + old + "%") - ), - (new,), - as_dict=1, + ps_issingle = ( + frappe.qb.from_(dt).select(dt.issingle).where(dt.name == ps.doc_type).as_("issingle") + ) + property_setter_select_fields = ( + frappe.qb.from_(ps) + .select(ps.doc_type.as_("parent"), ps.field_name.as_("fieldname"), ps_issingle) + .where( + (ps.doc_type != new) + & (ps.property == "options") + & (ps.field_name.notnull()) + & (ps.value.like(f"%{old}%")) + ) + .run(as_dict=True) ) - select_fields += property_setter_select_fields - - return select_fields + return standard_fields + custom_select_fields + property_setter_select_fields def update_select_field_values(old: str, new: str): - frappe.db.sql( - """ - update `tabDocField` set options=replace(options, %s, %s) - where - parent != %s and fieldtype = 'Select' and - (options like {0} or options like {1})""".format( - frappe.db.escape("%" + "\n" + old + "%"), frappe.db.escape("%" + old + "\n" + "%") - ), - (old, new, new), - ) + from frappe.query_builder.functions import Replace - frappe.db.sql( - """ - update `tabCustom Field` set options=replace(options, %s, %s) - where - dt != %s and fieldtype = 'Select' and - (options like {0} or options like {1})""".format( - frappe.db.escape("%" + "\n" + old + "%"), frappe.db.escape("%" + old + "\n" + "%") - ), - (old, new, new), - ) + DocField = DocType("DocField") + CustomField = DocType("Custom Field") + PropertySetter = DocType("Property Setter") - frappe.db.sql( - """ - update `tabProperty Setter` set value=replace(value, %s, %s) - where - doc_type != %s and field_name is not null and - property='options' and - (value like {0} or value like {1})""".format( - frappe.db.escape("%" + "\n" + old + "%"), frappe.db.escape("%" + old + "\n" + "%") - ), - (old, new, new), - ) + frappe.qb.update(DocField).set(DocField.options, Replace(DocField.options, old, new)).where( + (DocField.fieldtype == "Select") + & (DocField.parent != new) + & (DocField.options.like(f"%\n{old}%") | DocField.options.like(f"%{old}\n%")) + ).run() + + frappe.qb.update(CustomField).set( + CustomField.options, Replace(CustomField.options, old, new) + ).where( + (CustomField.fieldtype == "Select") + & (CustomField.dt != new) + & (CustomField.options.like(f"%\n{old}%") | CustomField.options.like(f"%{old}\n%")) + ).run() + + frappe.qb.update(PropertySetter).set( + PropertySetter.value, Replace(PropertySetter.value, old, new) + ).where( + (PropertySetter.property == "options") + & (PropertySetter.field_name.notnull()) + & (PropertySetter.doc_type != new) + & (PropertySetter.value.like(f"%\n{old}%") | PropertySetter.value.like(f"%{old}\n%")) + ).run() def update_parenttype_values(old: str, new: str): - child_doctypes = frappe.db.get_all( + child_doctypes = frappe.get_all( "DocField", fields=["options", "fieldname"], filters={"parent": new, "fieldtype": ["in", frappe.model.table_fields]}, ) - custom_child_doctypes = frappe.db.get_all( + custom_child_doctypes = frappe.get_all( "Custom Field", fields=["options", "fieldname"], filters={"dt": new, "fieldtype": ["in", frappe.model.table_fields]}, @@ -586,35 +620,30 @@ def update_parenttype_values(old: str, new: str): pluck="value", ) - child_doctypes = list(d["options"] for d in child_doctypes) - child_doctypes += property_setter_child_doctypes + child_doctypes = set(list(d["options"] for d in child_doctypes) + property_setter_child_doctypes) for doctype in child_doctypes: - frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old)) + Table = DocType(doctype) + frappe.qb.update(Table).set(Table.parenttype, new).where(Table.parenttype == old).run() def rename_dynamic_links(doctype: str, old: str, new: str): + Singles = DocType("Singles") for df in get_dynamic_link_map().get(doctype, []): # dynamic link in single, just one value to check if frappe.get_meta(df.parent).issingle: refdoc = frappe.db.get_singles_dict(df.parent) if refdoc.get(df.options) == doctype and refdoc.get(df.fieldname) == old: - - frappe.db.sql( - """update tabSingles set value=%s where - field=%s and value=%s and doctype=%s""", - (new, df.fieldname, old, df.parent), - ) + frappe.qb.update(Singles).set(Singles.value, new).where( + (Singles.field == df.fieldname) & (Singles.doctype == df.parent) & (Singles.value == old) + ).run() else: # because the table hasn't been renamed yet! parent = df.parent if df.parent != new else old - frappe.db.sql( - """update `tab{parent}` set {fieldname}=%s - where {options}=%s and {fieldname}=%s""".format( - parent=parent, fieldname=df.fieldname, options=df.options - ), - (new, doctype, old), - ) + + frappe.qb.update(parent).set(df.fieldname, new).where( + (Field(df.options) == doctype) & (Field(df.fieldname) == old) + ).run() def bulk_rename( diff --git a/frappe/parallel_test_runner.py b/frappe/parallel_test_runner.py index 65c8eb470b..4fd03773ef 100644 --- a/frappe/parallel_test_runner.py +++ b/frappe/parallel_test_runner.py @@ -46,7 +46,7 @@ class ParallelTestRunner: if hasattr(test_module, "global_test_dependencies"): for doctype in test_module.global_test_dependencies: - make_test_records(doctype) + make_test_records(doctype, commit=True) elapsed = time.time() - start_time elapsed = click.style(f" ({elapsed:.03}s)", fg="red") @@ -76,7 +76,7 @@ class ParallelTestRunner: def create_test_dependency_records(self, module, path, filename): if hasattr(module, "test_dependencies"): for doctype in module.test_dependencies: - make_test_records(doctype) + make_test_records(doctype, commit=True) if os.path.basename(os.path.dirname(path)) == "doctype": # test_data_migration_connector.py > data_migration_connector.json @@ -86,7 +86,7 @@ class ParallelTestRunner: with open(test_record_file_path, "r") as f: doc = json.loads(f.read()) doctype = doc["name"] - make_test_records(doctype) + make_test_records(doctype, commit=True) def get_module(self, path, filename): app_path = frappe.get_pymodule_path(self.app) diff --git a/frappe/public/js/frappe/doctype/index.js b/frappe/public/js/frappe/doctype/index.js index 9fe8957c60..09f020f370 100644 --- a/frappe/public/js/frappe/doctype/index.js +++ b/frappe/public/js/frappe/doctype/index.js @@ -5,7 +5,6 @@ frappe.provide("frappe.model"); apply to both DocType form and customize form. */ frappe.model.DocTypeController = class DocTypeController extends frappe.ui.form.Controller { - max_attachments() { if (!this.frm.doc.max_attachments) { return; @@ -20,4 +19,74 @@ frappe.model.DocTypeController = class DocTypeController extends frappe.ui.form. __("Number of attachment fields are more than {}, limit updated to {}.", [label, no_of_attach_fields])); } } -} + + naming_rule() { + // set the "autoname" property based on naming_rule + if (this.frm.doc.naming_rule && !this.frm.__from_autoname) { + + // flag to avoid recursion + this.frm.__from_naming_rule = true; + + const naming_rule_default_autoname_map = { + "Autoincrement": "autoincrement", + "Set by user": "prompt", + "By fieldname": "field:", + 'By "Naming Series" field': "naming_series:", + "Expression": "format:", + "Expression (sld style)": "", + "Random": "hash", + "By script": "" + }; + this.frm.set_value("autoname", naming_rule_default_autoname_map[this.frm.doc.naming_rule] || ""); + setTimeout(() => (this.frm.__from_naming_rule = false), 500); + + this.set_naming_rule_description(); + } + + } + + set_naming_rule_description() { + let naming_rule_description = { + 'Set by user': '', + 'Autoincrement': 'Uses Auto Increment feature of database.
WARNING: After using this option, any other naming option will not be accessible.', + 'By fieldname': 'Format: field:[fieldname]. Valid fieldname must exist', + 'By "Naming Series" field': 'Format: naming_series:[fieldname]. Default fieldname is naming_series', + 'Expression': 'Format: format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.', + 'Expression (old style)': 'Format: EXAMPLE-.##### Series by prefix (separated by a dot)', + 'Random': '', + 'By script': '' + }; + + if (this.frm.doc.naming_rule) { + this.frm.get_field('autoname').set_description(naming_rule_description[this.frm.doc.naming_rule]); + } + } + + autoname() { + // set naming_rule based on autoname (for old doctypes where its not been set) + if (this.frm.doc.autoname && !this.frm.doc.naming_rule && !this.frm.__from_naming_rule) { + // flag to avoid recursion + this.frm.__from_autoname = true; + const autoname = this.frm.doc.autoname.toLowerCase(); + + if (autoname === "prompt") + this.frm.set_value("naming_rule", "Set by user"); + else if (autoname === "autoincrement") + this.frm.set_value("naming_rule", "Autoincrement"); + else if (autoname.startsWith("field:")) + this.frm.set_value("naming_rule", "By fieldname"); + else if (autoname.startsWith("naming_series:")) + this.frm.set_value("naming_rule", 'By "Naming Series" field'); + else if (autoname.startsWith("format:")) + this.frm.set_value("naming_rule", "Expression"); + else if (autoname === "hash") + this.frm.set_value("naming_rule", "Random"); + else + this.frm.set_value("naming_rule", "Expression (old style)"); + + setTimeout(() => (this.frm.__from_autoname = false), 500); + } + + this.frm.set_df_property('fields', 'reqd', this.frm.doc.autoname !== 'Prompt'); + } +}; diff --git a/frappe/public/js/frappe/form/controls/base_input.js b/frappe/public/js/frappe/form/controls/base_input.js index b7fe61b385..5a5af389ee 100644 --- a/frappe/public/js/frappe/form/controls/base_input.js +++ b/frappe/public/js/frappe/form/controls/base_input.js @@ -65,11 +65,7 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control }; var update_input = function() { - if (me.doctype && me.docname) { - me.set_input(me.value); - } else { - me.set_input(me.value || null); - } + me.set_input(me.value); }; if (me.disp_status != "None") { diff --git a/frappe/public/js/frappe/form/controls/control.js b/frappe/public/js/frappe/form/controls/control.js index 8db382dd91..fd0f5dd860 100644 --- a/frappe/public/js/frappe/form/controls/control.js +++ b/frappe/public/js/frappe/form/controls/control.js @@ -39,6 +39,7 @@ import './multiselect_list'; import './rating'; import './duration'; import './icon'; +import './phone'; import './json'; frappe.ui.form.make_control = function (opts) { diff --git a/frappe/public/js/frappe/form/controls/icon.js b/frappe/public/js/frappe/form/controls/icon.js index 7ab2e11f24..000731fa52 100644 --- a/frappe/public/js/frappe/form/controls/icon.js +++ b/frappe/public/js/frappe/form/controls/icon.js @@ -11,7 +11,7 @@ frappe.ui.form.ControlIcon = class ControlIcon extends frappe.ui.form.ControlDat get_all_icons() { frappe.symbols = []; $("#frappe-symbols > symbol[id]").each(function() { - frappe.symbols.push(this.id.replace('icon-', '')); + this.id.includes('icon-') && frappe.symbols.push(this.id.replace('icon-', '')); }); } diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 2295cad41a..2081a301c3 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -237,7 +237,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat no_spinner: true, args: args, callback: function(r) { - if(!me.$input.is(":focus")) { + if (!window.Cypress && !me.$input.is(":focus")) { return; } r.results = me.merge_duplicates(r.results); diff --git a/frappe/public/js/frappe/form/controls/phone.js b/frappe/public/js/frappe/form/controls/phone.js new file mode 100644 index 0000000000..d67b449ac8 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/phone.js @@ -0,0 +1,197 @@ + +import PhonePicker from '../../phone_picker/phone_picker'; + +frappe.ui.form.ControlPhone = class ControlPhone extends frappe.ui.form.ControlData { + + make_input() { + super.make_input(); + this.setup_country_code_picker(); + this.input_events(); + } + + input_events() { + this.$input.keydown((e) => { + const key_code = e.keyCode; + if ([frappe.ui.keyCode.BACKSPACE].includes(key_code)) { + if (this.$input.val().length == 0) { + this.country_code_picker.reset(); + } + } + }); + + // Replaces code when selected and removes previously selected. + this.country_code_picker.on_change = (country) => { + if (!country) { + return this.reset_inputx(); + } + const country_code = frappe.boot.country_codes[country].code; + const country_isd = frappe.boot.country_codes[country].isd; + this.set_flag(country_code); + this.$icon = this.selected_icon.find('svg'); + this.$flag = this.selected_icon.find('img'); + + if (!this.$icon.hasClass('hide')) { + this.$icon.toggleClass('hide'); + } + if (!this.$flag.length) { + this.selected_icon.prepend(this.get_country_flag(country)); + } + if (!this.$isd.length) { + this.selected_icon.append($(` ${country_isd}`)); + } else { + this.$isd.text(country_isd); + } + if (this.$input.val()) { + this.set_value(this.get_country(country) +'-'+ this.$input.val()); + } + this.update_padding(); + // hide popover and focus input + this.$wrapper.popover('hide'); + this.$input.focus(); + }; + + this.$wrapper.find('.selected-phone').on('click', (e) => { + this.$wrapper.popover('toggle'); + e.stopPropagation(); + + $('body').on('click.phone-popover', (ev) => { + if (!$(ev.target).parents().is('.popover')) { + this.$wrapper.popover('hide'); + } + }); + $(window).on('hashchange.phone-popover', () => { + this.$wrapper.popover('hide'); + }); + }); + } + + setup_country_code_picker() { + let picker_wrapper = $('
'); + this.country_code_picker = new PhonePicker({ + parent: picker_wrapper, + countries: frappe.boot.country_codes + }); + + this.$wrapper.popover({ + trigger: 'manual', + offset: `${-this.$wrapper.width() / 4.5}, 5`, + boundary: 'viewport', + placement: 'bottom', + template: ` +
+
+
+
+ `, + content: () => picker_wrapper, + html: true + }).on('show.bs.popover', () => { + setTimeout(() => { + this.country_code_picker.refresh(); + this.country_code_picker.search_input.focus(); + }, 10); + }).on('hidden.bs.popover', () => { + $('body').off('click.phone-popover'); + $(window).off('hashchange.phone-popover'); + }); + + // Default icon when nothing is selected. + this.selected_icon = this.$wrapper.find('.selected-phone'); + let input_value = this.get_input_value(); + if (!this.selected_icon.length) { + this.selected_icon = $(`
${frappe.utils.icon("down", "sm")}
`); + this.selected_icon.insertAfter(this.$input); + this.selected_icon.append($(``)); + this.$isd = this.selected_icon.find('.country'); + if (input_value && input_value.split("-").length == 2) { + this.$isd.text(this.value.split("-")[0]); + } + } + } + + refresh() { + super.refresh(); + // Previously opened doc values showing up on a new doc + + if (this.frm && this.frm.doc.__islocal && !this.get_value()) { + this.reset_input(); + } + } + + reset_input() { + this.$input.val(""); + this.$wrapper.find('.country').text(""); + if (this.selected_icon.find('svg').hasClass('hide')) { + this.selected_icon.find('svg').toggleClass('hide'); + this.selected_icon.find('img').addClass('hide'); + } + this.$input.css("padding-left", 30); + } + + set_formatted_input(value) { + if (value && value.includes('-') && value.split('-').length == 2) { + let isd = this.value.split("-")[0]; + this.get_country_code_and_change_flag(isd); + this.country_code_picker.set_country(isd); + this.country_code_picker.refresh(); + if (this.country_code_picker.country && this.country_code_picker.country !== this.$isd.text()) { + this.$isd.length && this.$isd.text(isd); + } + this.update_padding(); + this.$input.val(value.split('-').pop()); + + } else if (this.$isd.text().trim() && this.value) { + let code_number = this.$isd.text() + '-' + value; + this.set_value(code_number); + } + } + + get_value() { + return this.value; + } + + set_flag(country_code) { + this.selected_icon.find('img').attr('src', `https://flagcdn.com/${country_code}.svg`); + this.$icon = this.selected_icon.find('img'); + this.$icon.hasClass('hide') && this.$icon.toggleClass('hide'); + } + + // country_code for India is 'in' + get_country_code_and_change_flag(isd) { + let country_data = frappe.boot.country_codes; + let flag = this.selected_icon.find('img'); + for (const country in country_data) { + if (Object.values(country_data[country]).includes(isd)) { + let code = country_data[country].code; + flag = this.selected_icon.find('img'); + if (!flag.length) { + this.selected_icon.prepend(this.get_country_flag(country)); + this.selected_icon.find('svg').addClass('hide'); + } else { + this.set_flag(code); + } + } + } + } + + get_country(country) { + const country_codes = frappe.boot.country_codes; + return country_codes[country].isd; + } + + get_country_flag(country) { + const country_codes = frappe.boot.country_codes; + let code = country_codes[country].code; + return frappe.utils.flag(code); + } + + update_padding() { + let len = this.$isd.text().length; + let diff = len - 2; + if (len > 2) { + this.$input.css("padding-left", 60 + (diff * 7)); + } else { + this.$input.css("padding-left", 60); + } + } +}; diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index 6e3dd3eb0b..0731bdf8fb 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -375,7 +375,7 @@ frappe.ui.form.Dashboard = class FormDashboard { } set_open_count() { - if (!this.data.transactions || !this.data.fieldname) { + if (!this.data || (!this.data.transactions || !this.data.fieldname)) { return; } diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 0c8939cf5d..2572f7b2e3 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -179,7 +179,7 @@ frappe.ui.form.Form = class FrappeForm { grid_shortcut_keys.forEach(row => { frappe.ui.keys.add_shortcut({ shortcut: row.shortcut, - page: this, + page: this.page, description: __(row.description), ignore_inputs: true, condition: () => !this.is_new() @@ -248,7 +248,7 @@ frappe.ui.form.Form = class FrappeForm { // on main doc frappe.model.on(me.doctype, "*", function(fieldname, value, doc, skip_dirty_trigger=false) { // set input - if (cstr(doc.name) === me.docname) { + if (doc.name == me.docname) { if (!skip_dirty_trigger) { me.dirty(); } @@ -273,7 +273,7 @@ frappe.ui.form.Form = class FrappeForm { // using $.each to preserve df via closure $.each(table_fields, function(i, df) { frappe.model.on(df.options, "*", function(fieldname, value, doc) { - if(doc.parent===me.docname && doc.parentfield===df.fieldname) { + if (doc.parent == me.docname && doc.parentfield === df.fieldname) { me.dirty(); me.fields_dict[df.fieldname].grid.set_value(fieldname, value, doc); return me.script_manager.trigger(fieldname, doc.doctype, doc.name); @@ -356,7 +356,7 @@ frappe.ui.form.Form = class FrappeForm { // check permissions if (!this.has_read_permission()) { - frappe.show_not_permitted(__(this.doctype) + " " + __(this.docname)); + frappe.show_not_permitted(__(this.doctype) + " " + __(cstr(this.docname))); return; } @@ -1765,12 +1765,15 @@ frappe.ui.form.Form = class FrappeForm { // scroll to input frappe.utils.scroll_to($el, true, 15); - // highlight input - $el.addClass('has-error'); + // focus if text field + $el.find('input, select, textarea').focus(); + + // highlight control inside field + let control_element = $el.find('.form-control') + control_element.addClass('highlight'); setTimeout(() => { - $el.removeClass('has-error'); - $el.find('input, select, textarea').focus(); - }, 1000); + control_element.removeClass('highlight'); + }, 2000); } setup_docinfo_change_listener() { diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 4bba8ae7ad..a1c3dce91f 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -289,19 +289,23 @@ export default class GridRow { var me = this; if(this.doc && !this.grid.df.in_place_edit) { // remove row - if(!this.open_form_button) { - this.open_form_button = $(` -
- ${frappe.utils.icon('edit', 'xs')} - -
- `) - .appendTo($('
').appendTo(this.row)) - .on('click', function() { - me.toggle_view(); return false; - }); + if (!this.open_form_button) { + this.open_form_button = $('
').appendTo(this.row); - if(this.is_too_small()) { + if (!this.configure_columns) { + this.open_form_button = $(` +
+ ${frappe.utils.icon('edit', 'xs')} + +
+ `) + .appendTo(this.open_form_button) + .on('click', function() { + me.toggle_view(); return false; + }); + } + + if (this.is_too_small()) { // narrow this.open_form_button.css({'margin-right': '-2px'}); } @@ -310,7 +314,9 @@ export default class GridRow { } add_column_configure_button() { - if (this.configure_columns) { + if (this.grid.df.in_place_edit && !this.frm) return; + + if (this.configure_columns && this.frm) { this.configure_columns_button = $(`
${frappe.utils.icon('setting-gear', 'sm', '', 'filter: opacity(0.5)')} @@ -320,6 +326,10 @@ export default class GridRow { .on('click', () => { this.configure_dialog_for_columns_selector(); }); + } else if (this.configure_columns && !this.frm) { + this.configure_columns_button = $(` +
+ `).appendTo(this.row); } } diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 578956f0ca..403abf0981 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -98,7 +98,7 @@ frappe.ui.form.Layout = class Layout { // remove previous color this.message.removeClass(this.message_color); } - this.message_color = (color && ['yellow', 'blue', 'red'].includes(color)) ? color : 'blue'; + this.message_color = (color && ['yellow', 'blue', 'red', 'green', 'orange'].includes(color)) ? color : 'blue'; if (html) { if (html.substr(0, 1)!=='<') { // wrap in a block @@ -439,7 +439,7 @@ frappe.ui.form.Layout = class Layout { } handle_tab(doctype, fieldname, shift) { - let grid_row = null, + let grid_row = null, prev = null, fields = this.fields_list, focused = false; diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index 61922a2422..92d2759f7f 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -151,7 +151,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { } is_child_selection_enabled() { - return this.dialog.fields_dict['allow_child_item_selection'].get_value(); + return this.dialog.fields_dict['allow_child_item_selection']?.get_value(); } toggle_child_selection() { diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index e55eb9fdeb..a19062d209 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -84,16 +84,15 @@ frappe.ui.form.Toolbar = class Toolbar { message: __("Unchanged") }); } - rename_document_title(new_name, new_title, merge=false) { + rename_document_title(input_name, input_title, merge=false) { + let confirm_message = null; const docname = this.frm.doc.name; const title_field = this.frm.meta.title_field || ''; const doctype = this.frm.doctype; - let confirm_message=null; - - if (new_name) { + if (input_name) { const warning = __("This cannot be undone"); - const message = __("Are you sure you want to merge {0} with {1}?", [docname.bold(), new_name.bold()]); + const message = __("Are you sure you want to merge {0} with {1}?", [docname.bold(), input_name.bold()]); confirm_message = `${message}
${warning}`; } @@ -101,22 +100,45 @@ frappe.ui.form.Toolbar = class Toolbar { return frappe.xcall("frappe.model.rename_doc.update_document_title", { doctype, docname, - name: new_name, - title: new_title, + name: input_name, + title: input_title, + enqueue: true, merge, freeze: true, freeze_message: __("Updating related fields...") }).then(new_docname => { - if (new_name != docname) { - $(document).trigger("rename", [doctype, docname, new_docname || new_name]); + const reload_form = (input_name) => { + $(document).trigger("rename", [doctype, docname, input_name]); if (locals[doctype] && locals[doctype][docname]) delete locals[doctype][docname]; + this.frm.reload_doc(); + } + + // handle document renaming queued action + if (input_name && (new_docname == docname)) { + frappe.socketio.doc_subscribe(doctype, input_name); + frappe.realtime.on("doc_update", data => { + if (data.doctype == doctype && data.name == input_name) { + reload_form(input_name); + frappe.show_alert({ + message: __('Document renamed from {0} to {1}', [docname.bold(), input_name.bold()]), + indicator: 'success', + }); + } + }); + frappe.show_alert( + __('Document renaming from {0} to {1} has been queued', [docname.bold(), input_name.bold()]) + ); + } + + // handle document sync rename action + if (input_name && ((new_docname || input_name) != docname)) { + reload_form(new_docname || input_name); } - this.frm.reload_doc(); }); }; return new Promise((resolve, reject) => { - if (new_title === this.frm.doc[title_field] && new_name === docname) { + if (input_title === this.frm.doc[title_field] && input_name === docname) { this.show_unchanged_document_alert(); resolve(); } else if (merge) { @@ -323,7 +345,7 @@ frappe.ui.form.Toolbar = class Toolbar { } // New - if(p[CREATE] && !this.frm.meta.issingle) { + if (p[CREATE] && !this.frm.meta.issingle && !this.frm.meta.in_create) { this.page.add_menu_item(__("New {0}", [__(me.frm.doctype)]), function() { frappe.new_doc(me.frm.doctype, true); }, true, { @@ -569,7 +591,8 @@ frappe.ui.form.Toolbar = class Toolbar { primary_action: ({ fieldname }) => { dialog.hide(); this.frm.scroll_to_field(fieldname); - } + }, + animate: false, }); dialog.show(); diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index 3b95a4b3f1..ad7a1181f6 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -403,7 +403,7 @@ $.extend(frappe.model, { } }); } else { - if(typeof filters==="string" && locals[doctype] && locals[doctype][filters]) { + if (["number", "string"].includes(typeof filters) && locals[doctype] && locals[doctype][filters]) { return locals[doctype][filters][fieldname]; } else { var l = frappe.get_list(doctype, filters); diff --git a/frappe/public/js/frappe/phone_picker/phone_picker.js b/frappe/public/js/frappe/phone_picker/phone_picker.js new file mode 100644 index 0000000000..e96d34b991 --- /dev/null +++ b/frappe/public/js/frappe/phone_picker/phone_picker.js @@ -0,0 +1,103 @@ +class PhonePicker { + constructor(opts) { + this.parent = opts.parent; + this.width = opts.width; + this.height = opts.height; + this.country = opts.country; + opts.country && this.set_country(opts.country); + this.countries = opts.countries; + this.setup_picker(); + } + + refresh() { + this.update_icon_selected(true); + } + + setup_picker() { + this.phone_picker_wrapper = $(` +
+
+ + ${frappe.utils.icon('search', "sm")} +
+
+
+
+
+ `); + this.parent.append(this.phone_picker_wrapper); + this.phone_wrapper = this.phone_picker_wrapper.find('.phones'); + this.search_input = this.phone_picker_wrapper.find('.search-phones > input'); + this.refresh(); + this.setup_countries(); + } + + setup_countries() { + Object.entries(this.countries).forEach(([country, info]) => { + if (!info.isd) { + return; + } + let $country = $(` +
+ ${frappe.utils.flag(info.code)} + ${country} (${info.isd}) +
+ `); + this.phone_wrapper.append($country); + const set_values = () => { + this.set_country(country); + this.update_icon_selected(); + }; + $country.on('click', () => { + set_values(); + }); + $country.hover(() => { + $country.toggleClass("bg-gray-100"); + }); + this.search_input.keydown((e) => { + const key_code = e.keyCode; + if ([13].includes(key_code)) { + e.preventDefault(); + set_values(); + } + }); + this.search_input.keyup((e) => { + e.preventDefault(); + this.filter_icons(); + }); + + this.search_input.on('search', () => { + this.filter_icons(); + }); + }); + } + + filter_icons() { + let value = this.search_input.val(); + if (!value) { + this.phone_wrapper.find(".phone-wrapper").removeClass('hidden'); + } else { + this.phone_wrapper.find(".phone-wrapper").addClass('hidden'); + this.phone_wrapper.find(`.phone-wrapper[id*='${value.toLowerCase()}']`).removeClass('hidden'); + } + } + + update_icon_selected(silent) { + !silent && this.on_change && this.on_change(this.get_country()); + } + + set_country(country) { + this.country = country || ''; + } + + get_country() { + return this.country; + } + + reset() { + this.set_country(); + this.update_icon_selected(); + } +} + +export default PhonePicker; diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js index eb3dcc4f89..178d1a65cb 100644 --- a/frappe/public/js/frappe/ui/field_group.js +++ b/frappe/public/js/frappe/ui/field_group.js @@ -22,15 +22,17 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout { super.make(); this.refresh(); // set default - $.each(this.fields_list, (_, field) => { - if (!is_null(field.df.default)) { - let def_value = field.df.default; + $.each(this.fields_list, function(i, field) { + if (field.df["default"]) { + let def_value = field.df["default"]; - if (def_value === "Today" && field.df.fieldtype === "Date") { + if (def_value == 'Today' && field.df["fieldtype"] == 'Date') { def_value = frappe.datetime.get_today(); } - this.set_value(field.df.fieldname, def_value); + field.set_input(def_value); + // if default and has depends_on, render its fields. + me.refresh_dependency(); } }) @@ -127,7 +129,6 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout { if (f) { f.set_value(val).then(() => { f.set_input(val); - f.refresh(); this.refresh_dependency(); resolve(); }); diff --git a/frappe/public/js/frappe/ui/keyboard.js b/frappe/public/js/frappe/ui/keyboard.js index e28a8f680d..85ce248175 100644 --- a/frappe/public/js/frappe/ui/keyboard.js +++ b/frappe/public/js/frappe/ui/keyboard.js @@ -37,7 +37,7 @@ frappe.ui.keys.add_shortcut = ({shortcut, action, description, page, target, con if (is_input_focused && !ignore_inputs) return; if (!condition()) return; - if (!page || page.wrapper.is(':visible')) { + if (action && (!page || page.wrapper.is(':visible'))) { let prevent_default = action(e); // prevent default if true is explicitly returned // or nothing returned (undefined) @@ -221,11 +221,11 @@ frappe.ui.keys.add_shortcut({ }); frappe.ui.keys.on('escape', function(e) { - close_grid_and_dialog(); + handle_escape_key(); }); frappe.ui.keys.on('esc', function(e) { - close_grid_and_dialog(); + handle_escape_key(); }); frappe.ui.keys.on('enter', function(e) { @@ -293,6 +293,11 @@ frappe.ui.keyCode = { BACKSPACE: 8 } +function handle_escape_key() { + close_grid_and_dialog(); + document.activeElement?.blur(); +} + function close_grid_and_dialog() { // close open grid row var open_row = $(".grid-row-open"); @@ -308,10 +313,3 @@ function close_grid_and_dialog() { return false; } } - -// blur when escape is pressed on dropdowns -$(document).on('keydown', '.dropdown-toggle', (e) => { - if (e.which === frappe.ui.keyCode.ESCAPE) { - $(e.currentTarget).blur(); - } -}); diff --git a/frappe/public/js/frappe/ui/sort_selector.js b/frappe/public/js/frappe/ui/sort_selector.js index 879466e8f7..837454ed09 100644 --- a/frappe/public/js/frappe/ui/sort_selector.js +++ b/frappe/public/js/frappe/ui/sort_selector.js @@ -132,7 +132,7 @@ frappe.ui.SortSelector = class SortSelector { // bold, mandatory and fields that are available in list view meta.fields.forEach(function(df) { if ( - (df.mandatory || df.bold || df.in_list_view) + (df.mandatory || df.bold || df.in_list_view || df.reqd) && frappe.model.is_value_type(df.fieldtype) && frappe.perm.has_perm(me.doctype, df.permlevel, "read") ) { diff --git a/frappe/public/js/frappe/utils/dashboard_utils.js b/frappe/public/js/frappe/utils/dashboard_utils.js index 85a4048a47..c4b094b216 100644 --- a/frappe/public/js/frappe/utils/dashboard_utils.js +++ b/frappe/public/js/frappe/utils/dashboard_utils.js @@ -249,7 +249,7 @@ frappe.dashboard_utils = { {args: values} ).then(()=> { let dashboard_route_html = - `${values.dashboard}`; + `${values.dashboard}`; let message = __("{0} {1} added to Dashboard {2}", [doctype, values.name, dashboard_route_html]); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 0514576380..4690012dc2 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1192,6 +1192,12 @@ Object.assign(frappe.utils, { `; }, + flag(country_code) { + return ``; + }, + make_chart(wrapper, custom_options={}) { let chart_args = { type: 'bar', diff --git a/frappe/public/js/frappe/views/file/file_view.js b/frappe/public/js/frappe/views/file/file_view.js index b351ce6109..de06e6013e 100644 --- a/frappe/public/js/frappe/views/file/file_view.js +++ b/frappe/public/js/frappe/views/file/file_view.js @@ -390,7 +390,7 @@ frappe.views.FileView = class FileView extends frappe.views.ListView { return `
- diff --git a/frappe/public/js/frappe/views/workspace/blocks/block.js b/frappe/public/js/frappe/views/workspace/blocks/block.js index 9605d30c81..1df6b707fe 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/block.js +++ b/frappe/public/js/frappe/views/workspace/blocks/block.js @@ -7,7 +7,7 @@ export default class Block { make(block, block_name, widget_type = block) { let block_data = this.config.page_data[block+'s'].items.find(obj => { - return frappe.utils.unescape_html(obj.label) == frappe.utils.unescape_html(block_name); + return frappe.utils.unescape_html(obj.label) == frappe.utils.unescape_html(__(block_name)); }); if (!block_data) return false; this.wrapper.innerHTML = ''; diff --git a/frappe/public/js/frappe/views/workspace/blocks/card.js b/frappe/public/js/frappe/views/workspace/blocks/card.js index 9ce6ce8b4d..4b46b12890 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/card.js +++ b/frappe/public/js/frappe/views/workspace/blocks/card.js @@ -31,7 +31,7 @@ export default class Card extends Block { this.new('card', 'links'); if (this.data && this.data.card_name) { - let has_data = this.make('card', __(this.data.card_name), 'links'); + let has_data = this.make('card', this.data.card_name, 'links'); if (!has_data) return; } diff --git a/frappe/public/js/frappe/views/workspace/blocks/chart.js b/frappe/public/js/frappe/views/workspace/blocks/chart.js index ccef1fa15f..cb688f48ed 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/chart.js +++ b/frappe/public/js/frappe/views/workspace/blocks/chart.js @@ -32,7 +32,7 @@ export default class Chart extends Block { this.new('chart'); if (this.data && this.data.chart_name) { - let has_data = this.make('chart', __(this.data.chart_name)); + let has_data = this.make('chart', this.data.chart_name); if (!has_data) return; } diff --git a/frappe/public/js/frappe/views/workspace/blocks/onboarding.js b/frappe/public/js/frappe/views/workspace/blocks/onboarding.js index c0ba529853..c76141996f 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/onboarding.js +++ b/frappe/public/js/frappe/views/workspace/blocks/onboarding.js @@ -73,7 +73,7 @@ export default class Onboarding extends Block { make(block, block_name) { let block_data = this.config.page_data['onboardings'].items.find(obj => { - return obj.label == block_name; + return obj.label == __(block_name); }); if (!block_data) return false; this.wrapper.innerHTML = ''; diff --git a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js index 2be5da0d4b..ef9bfa8cf9 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js +++ b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js @@ -51,7 +51,7 @@ export default class Shortcut extends Block { this.new('shortcut'); if (this.data && this.data.shortcut_name) { - let has_data = this.make('shortcut', __(this.data.shortcut_name)); + let has_data = this.make('shortcut', this.data.shortcut_name); if (!has_data) return; } diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index d1ba75227b..bba29ffaf9 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -228,30 +228,35 @@ class CardDialog extends WidgetDialog { } process_data(data) { - data.links.map((item, idx) => { - let message = ''; - let row = idx+1; + let message = ''; - if (!item.link_type) { - message = "Following fields have missing values:

    "; - message += `
  • Link Type in Row ${row}
  • `; - } + if (!data.links) { + message = "You must add atleast one link."; + } else { + data.links.map((item, idx) => { + let row = idx+1; - if (!item.link_to) { - message += `
  • Link To in Row ${row}
  • `; - } + if (!item.link_type) { + message = "Following fields have missing values:

      "; + message += `
    • Link Type in Row ${row}
    • `; + } - if (message) { - message += "
    "; - frappe.throw({ - message: __(message), - title: __("Missing Values Required"), - indicator: 'orange' - }); - } + if (!item.link_to) { + message += `
  • Link To in Row ${row}
  • `; + } - item.label = item.label ? item.label : item.link_to; - }); + item.label = item.label ? item.label : item.link_to; + }); + } + + if (message) { + message += "
"; + frappe.throw({ + message: __(message), + title: __("Missing Values Required"), + indicator: 'orange' + }); + } data.label = data.label ? data.label : data.chart_name; return data; diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss index fcc924650e..1135fbb23d 100644 --- a/frappe/public/scss/common/controls.scss +++ b/frappe/public/scss/common/controls.scss @@ -2,6 +2,7 @@ @import "color_picker"; @import "icon_picker"; @import "datepicker"; +@import "phone_picker"; // password .form-control[data-fieldtype="Password"] { @@ -343,11 +344,10 @@ textarea.form-control { .duration-picker { position: absolute; z-index: 999; - border-radius: var(--border-radius); box-shadow: var(--shadow-sm); background: var(--popover-bg); - + width: max-content; &:after, &:before { border: solid transparent; @@ -466,4 +466,4 @@ button.data-pill { top: 0; right: 0; cursor: pointer; -} \ No newline at end of file +} diff --git a/frappe/public/scss/common/phone_picker.scss b/frappe/public/scss/common/phone_picker.scss new file mode 100644 index 0000000000..f2131e8b84 --- /dev/null +++ b/frappe/public/scss/common/phone_picker.scss @@ -0,0 +1,144 @@ +.phone-picker { + font-size: var(--text-xs); + color: var(--text-muted); + --phone-picker-width: 290px; + width: var(--phone-picker-width); + .phones { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + overflow-y: scroll; + max-height: 210px; + cursor: pointer; + + /* Hide scrollbar for IE, Edge and Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } + + .phone-wrapper { + display: flex; + width: 290px; + height: 30px; + text-align: center; + align-items: center; + border-radius: 0.375rem; + padding: 0.5rem; + + img { + height: 15px; + } + .country { + display: flex; + margin-left: 0.6rem; + flex-grow: 1; + width: 290px; + } + } + } + + .search-phones { + position: relative; + + input[type='search'] { + height: inherit; + padding-left: 30px; + } + + .search-phone { + position: absolute; + top: 7px; + left: 7px; + } + } +} + +.phone-picker-popover { + max-width: 325px; + left: 29px !important; + .picker-arrow { + left: 15px !important; + } + @media (max-width: 992px) { + max-width: 325px; + left: 48px !important; + } +} + + +.frappe-control[data-fieldtype='Phone'] +{ + input { + padding-left: 30px; + } + .selected-phone { + display: flex; + cursor: pointer; + height: 20px; + border-radius: 5px; + position: absolute; + top: calc(50% + 2px); + left: 8px; + content: ' '; + align-items: center; + z-index: 1; + + .country { + display: flex; + margin-left: 0.6rem; + align-items: flex-end; + flex-grow: 1; + } + + img { + height: 15px; + } + + } + .like-disabled-input { + .phone-value { + padding-left: 25px; + } + .selected-phone { + top: 20%; + cursor: default; + } + } +} + +.modal-body { + .frappe-control[data-fieldtype='Phone'] + { + .selected-phone { + top: calc(50% - 0.5px); + } + } +} + +.data-row.row { + .selected-phone { + top: calc(50% - 10.1px); + z-index: 2; + } +} + +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgba(244,245,246,var(--tw-bg-opacity)); +} + +.dt-cell__content { + .selected-phone { + display: contents; + } +} + +.dt-cell__edit, .filter-field { + .selected-phone { + top: 5.5px !important; + } +} diff --git a/frappe/public/scss/common/quill.scss b/frappe/public/scss/common/quill.scss index 3d5ce61c15..9aab2e9fcd 100644 --- a/frappe/public/scss/common/quill.scss +++ b/frappe/public/scss/common/quill.scss @@ -7,6 +7,7 @@ font-family: inherit; } +/*rtl:begin:ignore*/ .ql-editor { font-family: var(--font-stack); color: var(--text-color); @@ -22,7 +23,15 @@ a[href] { text-decoration: underline; } + .ql-direction-rtl { + direction: rtl; + + .table { + direction: ltr; + } + } } +/*rtl:end:ignore*/ + .ql-toolbar.ql-snow { border-top-left-radius: var(--border-radius); @@ -70,6 +79,7 @@ min-height: 0; max-height: none; overflow: hidden; + resize: none; } } diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index 71cfa88db1..69aee9b350 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -55,6 +55,10 @@ def DocType(*args, **kwargs): return frappe.qb.DocType(*args, **kwargs) +def Table(*args, **kwargs): + return frappe.qb.Table(*args, **kwargs) + + def patch_query_execute(): """Patch the Query Builder with helper execute method This excludes the use of `frappe.db.sql` method while diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 509be36f86..96feac532f 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -226,7 +226,7 @@ def run_tests_for_doctype( if force: for name in frappe.db.sql_list("select name from `tab%s`" % doctype): frappe.delete_doc(doctype, name, force=True) - make_test_records(doctype, verbose=verbose, force=force) + make_test_records(doctype, verbose=verbose, force=force, commit=True) modules.append(importlib.import_module(test_module)) return _run_unittest( @@ -245,7 +245,7 @@ def run_tests_for_module( module = importlib.import_module(module) if hasattr(module, "test_dependencies"): for doctype in module.test_dependencies: - make_test_records(doctype, verbose=verbose) + make_test_records(doctype, verbose=verbose, commit=True) frappe.db.commit() return _run_unittest( @@ -330,7 +330,7 @@ def _add_test(app, path, filename, verbose, test_suite=None, ui_tests=False): if hasattr(module, "test_dependencies"): for doctype in module.test_dependencies: - make_test_records(doctype, verbose=verbose) + make_test_records(doctype, verbose=verbose, commit=True) is_ui_test = True if hasattr(module, "TestDriver") else False @@ -346,12 +346,12 @@ def _add_test(app, path, filename, verbose, test_suite=None, ui_tests=False): with open(txt_file, "r") as f: doc = json.loads(f.read()) doctype = doc["name"] - make_test_records(doctype, verbose) + make_test_records(doctype, verbose, commit=True) test_suite.addTest(unittest.TestLoader().loadTestsFromModule(module)) -def make_test_records(doctype, verbose=0, force=False): +def make_test_records(doctype, verbose=0, force=False, commit=False): if not frappe.db: frappe.connect() @@ -364,8 +364,8 @@ def make_test_records(doctype, verbose=0, force=False): if options not in frappe.local.test_objects: frappe.local.test_objects[options] = [] - make_test_records(options, verbose, force) - make_test_records_for_doctype(options, verbose, force) + make_test_records(options, verbose, force, commit=commit) + make_test_records_for_doctype(options, verbose, force, commit=commit) def get_modules(doctype): @@ -405,7 +405,7 @@ def get_dependencies(doctype): return options_list -def make_test_records_for_doctype(doctype, verbose=0, force=False): +def make_test_records_for_doctype(doctype, verbose=0, force=False, commit=False): if not force and doctype in get_test_record_log(): return @@ -420,17 +420,19 @@ def make_test_records_for_doctype(doctype, verbose=0, force=False): elif hasattr(test_module, "test_records"): if doctype in frappe.local.test_objects: frappe.local.test_objects[doctype] += make_test_objects( - doctype, test_module.test_records, verbose, force + doctype, test_module.test_records, verbose, force, commit=commit ) else: frappe.local.test_objects[doctype] = make_test_objects( - doctype, test_module.test_records, verbose, force + doctype, test_module.test_records, verbose, force, commit=commit ) else: test_records = frappe.get_test_records(doctype) if test_records: - frappe.local.test_objects[doctype] += make_test_objects(doctype, test_records, verbose, force) + frappe.local.test_objects[doctype] += make_test_objects( + doctype, test_records, verbose, force, commit=commit + ) elif verbose: print_mandatory_fields(doctype) @@ -438,7 +440,7 @@ def make_test_records_for_doctype(doctype, verbose=0, force=False): add_to_test_record_log(doctype) -def make_test_objects(doctype, test_records=None, verbose=None, reset=False): +def make_test_objects(doctype, test_records=None, verbose=None, reset=False, commit=False): """Make test objects from given list of `test_records` or from `test_records.json`""" records = [] @@ -495,7 +497,8 @@ def make_test_objects(doctype, test_records=None, verbose=None, reset=False): records.append(d.name) - frappe.db.commit() + if commit: + frappe.db.commit() return records diff --git a/frappe/tests/test_client.py b/frappe/tests/test_client.py index c86c482651..677f59a366 100644 --- a/frappe/tests/test_client.py +++ b/frappe/tests/test_client.py @@ -141,3 +141,40 @@ class TestClient(unittest.TestCase): self.assertEqual(get("ToDo", filters=filters_json).description, "test") todo.delete() + + def test_client_insert(self): + from frappe.client import insert + + def get_random_title(): + return "test-{0}".format(frappe.generate_hash()) + + # test insert dict + doc = {"doctype": "Note", "title": get_random_title(), "content": "test"} + note1 = insert(doc) + self.assertTrue(note1) + + # test insert json + doc["title"] = get_random_title() + json_doc = frappe.as_json(doc) + note2 = insert(json_doc) + self.assertTrue(note2) + + # test insert child doc without parent fields + child_doc = {"doctype": "Note Seen By", "user": "Administrator"} + with self.assertRaises(frappe.ValidationError): + insert(child_doc) + + # test insert child doc with parent fields + child_doc = { + "doctype": "Note Seen By", + "user": "Administrator", + "parenttype": "Note", + "parent": note1.name, + "parentfield": "seen_by", + } + note3 = insert(child_doc) + self.assertTrue(note3) + + # cleanup + frappe.delete_doc("Note", note1.name) + frappe.delete_doc("Note", note2.name) diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 5b469cd5db..6cba55c425 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -87,6 +87,15 @@ class TestDB(unittest.TestCase): frappe.db.get_values("User", filters=[["name", "=", "Administrator"]], fieldname="email"), ) + # test multiple orderby's + delimiter = '"' if frappe.db.db_type == "postgres" else "`" + self.assertIn( + "ORDER BY {deli}creation{deli} DESC,{deli}modified{deli} ASC,{deli}name{deli} DESC".format( + deli=delimiter + ), + frappe.db.get_value("DocType", "DocField", order_by="creation desc, modified asc, name", run=0), + ) + def test_get_value_limits(self): # check both dict and list style filters diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 8bdd66a045..19a8c445f8 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -61,10 +61,12 @@ class TestReportview(unittest.TestCase): in build_match_conditions(as_condition=False) ) # get as conditions - self.assertEqual( - build_match_conditions(as_condition=True), - """(((ifnull(`tabBlog Post`.`name`, '')='' or `tabBlog Post`.`name` in ('-test-blog-post-1', '-test-blog-post'))))""", - ) + if frappe.db.db_type == "mariadb": + assertion_string = """(((ifnull(`tabBlog Post`.`name`, '')='' or `tabBlog Post`.`name` in ('-test-blog-post-1', '-test-blog-post'))))""" + else: + assertion_string = """(((ifnull(cast(`tabBlog Post`.`name` as varchar), '')='' or cast(`tabBlog Post`.`name` as varchar) in ('-test-blog-post-1', '-test-blog-post'))))""" + + self.assertEqual(build_match_conditions(as_condition=True), assertion_string) frappe.set_user("Administrator") @@ -619,19 +621,22 @@ class TestReportview(unittest.TestCase): def test_cast_name(self): from frappe.core.doctype.doctype.test_doctype import new_doctype + frappe.delete_doc_if_exists("DocType", "autoinc_dt_test") dt = new_doctype("autoinc_dt_test", autoname="autoincrement").insert(ignore_permissions=True) query = DatabaseQuery("autoinc_dt_test").execute( - fields=["locate('1', `tabautoinc_dt_test`.`name`)", "`tabautoinc_dt_test`.`name`"], + fields=["locate('1', `tabautoinc_dt_test`.`name`)", "name", "locate('1', name)"], filters={"name": 1}, run=False, ) if frappe.db.db_type == "postgres": - self.assertTrue('strpos( cast( "tabautoinc_dt_test"."name" as varchar), \'1\')' in query) + self.assertTrue('strpos( cast("tabautoinc_dt_test"."name" as varchar), \'1\')' in query) + self.assertTrue("strpos( cast(name as varchar), '1')" in query) self.assertTrue('where cast("tabautoinc_dt_test"."name" as varchar) = \'1\'' in query) else: self.assertTrue("locate('1', `tabautoinc_dt_test`.`name`)" in query) + self.assertTrue("locate('1', name)" in query) self.assertTrue("where `tabautoinc_dt_test`.`name` = 1" in query) dt.delete(ignore_permissions=True) @@ -639,23 +644,53 @@ class TestReportview(unittest.TestCase): def test_fieldname_starting_with_int(self): from frappe.core.doctype.doctype.test_doctype import new_doctype + frappe.delete_doc_if_exists("DocType", "dt_with_int_named_fieldname") + frappe.delete_doc_if_exists("DocType", "table_dt") + + table_dt = new_doctype( + "table_dt", istable=1, fields=[{"label": "1field", "fieldname": "2field", "fieldtype": "Data"}] + ).insert() + dt = new_doctype( "dt_with_int_named_fieldname", - fields=[{"label": "1field", "fieldname": "1field", "fieldtype": "Int"}], + fields=[ + {"label": "1field", "fieldname": "1field", "fieldtype": "Data"}, + { + "label": "2table_field", + "fieldname": "2table_field", + "fieldtype": "Table", + "options": table_dt.name, + }, + ], ).insert(ignore_permissions=True) - frappe.get_doc({"doctype": "dt_with_int_named_fieldname", "1field": 10}).insert( + dt_data = frappe.get_doc({"doctype": "dt_with_int_named_fieldname", "1field": "10"}).insert( ignore_permissions=True ) query = DatabaseQuery("dt_with_int_named_fieldname") - self.assertTrue(query.execute(filters={"1field": 10})) + self.assertTrue(query.execute(filters={"1field": "10"})) self.assertTrue(query.execute(filters={"1field": ["like", "1%"]})) self.assertTrue(query.execute(filters={"1field": ["in", "1,2,10"]})) self.assertTrue(query.execute(filters={"1field": ["is", "set"]})) self.assertFalse(query.execute(filters={"1field": ["not like", "1%"]})) + self.assertTrue(query.execute(filters=[["table_dt", "2field", "is", "not set"]])) + frappe.get_doc( + { + "doctype": table_dt.name, + "2field": "10", + "parent": dt_data.name, + "parenttype": dt_data.doctype, + "parentfield": "2table_field", + } + ).insert(ignore_permissions=True) + + self.assertTrue(query.execute(filters=[["table_dt", "2field", "is", "set"]])) + + # cleanup dt.delete() + table_dt.delete() def add_child_table_to_blog_post(): diff --git a/frappe/tests/test_permissions.py b/frappe/tests/test_permissions.py index 70297a4f54..4164b0be36 100644 --- a/frappe/tests/test_permissions.py +++ b/frappe/tests/test_permissions.py @@ -24,25 +24,26 @@ test_dependencies = ["Blogger", "Blog Post", "User", "Contact", "Salutation"] class TestPermissions(FrappeTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.clear_cache(doctype="Blog Post") + user = frappe.get_doc("User", "test1@example.com") + user.add_roles("Website Manager") + user.add_roles("System Manager") + + user = frappe.get_doc("User", "test2@example.com") + user.add_roles("Blogger") + + user = frappe.get_doc("User", "test3@example.com") + user.add_roles("Sales User") + + user = frappe.get_doc("User", "testperm@example.com") + user.add_roles("Website Manager") + def setUp(self): frappe.clear_cache(doctype="Blog Post") - if not frappe.flags.permission_user_setup_done: - user = frappe.get_doc("User", "test1@example.com") - user.add_roles("Website Manager") - user.add_roles("System Manager") - - user = frappe.get_doc("User", "test2@example.com") - user.add_roles("Blogger") - - user = frappe.get_doc("User", "test3@example.com") - user.add_roles("Sales User") - - user = frappe.get_doc("User", "testperm@example.com") - user.add_roles("Website Manager") - - frappe.flags.permission_user_setup_done = True - reset("Blogger") reset("Blog Post") diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index 38fc7b32bd..8bf76b3e13 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -107,8 +107,25 @@ class TestRenameDoc(unittest.TestCase): def setUp(self): frappe.flags.link_fields = {} + if self._testMethodName == "test_doc_rename_method": + self.property_setter = frappe.get_doc( + { + "doctype": "Property Setter", + "doctype_or_field": "DocType", + "doc_type": self.test_doctype, + "property": "allow_rename", + "property_type": "Check", + "value": "1", + } + ).insert() + super().setUp() + def tearDown(self) -> None: + if self._testMethodName == "test_doc_rename_method": + self.property_setter.delete() + return super().tearDown() + def test_rename_doc(self): """Rename an existing document via frappe.rename_doc""" old_name = choice(self.available_documents) @@ -247,3 +264,12 @@ class TestRenameDoc(unittest.TestCase): update_linked_doctypes("User", "ToDo", "str", "str") self.assertTrue("Function frappe.model.rename_doc.update_linked_doctypes" in stdout.getvalue()) + + def test_doc_rename_method(self): + name = choice(self.available_documents) + new_name = f"{name}-{frappe.generate_hash(length=4)}" + doc = frappe.get_doc(self.test_doctype, name) + doc.rename(new_name, merge=frappe.db.exists(self.test_doctype, new_name)) + self.assertEqual(doc.name, new_name) + self.available_documents.append(new_name) + self.available_documents.remove(name) diff --git a/frappe/tests/test_sequence.py b/frappe/tests/test_sequence.py new file mode 100644 index 0000000000..a60e4b1ac9 --- /dev/null +++ b/frappe/tests/test_sequence.py @@ -0,0 +1,54 @@ +import psycopg2 +import pymysql + +import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestSequence(FrappeTestCase): + def generate_sequence_name(self) -> str: + return self._testMethodName + "_" + frappe.generate_hash(length=5) + + def test_set_next_val(self): + seq_name = self.generate_sequence_name() + frappe.db.create_sequence(seq_name, check_not_exists=True, temporary=True) + + next_val = frappe.db.get_next_sequence_val(seq_name) + frappe.db.set_next_sequence_val(seq_name, next_val + 1) + self.assertEqual(next_val + 1, frappe.db.get_next_sequence_val(seq_name)) + + next_val = frappe.db.get_next_sequence_val(seq_name) + frappe.db.set_next_sequence_val(seq_name, next_val + 1, is_val_used=True) + self.assertEqual(next_val + 2, frappe.db.get_next_sequence_val(seq_name)) + + def test_create_sequence(self): + seq_name = self.generate_sequence_name() + frappe.db.create_sequence(seq_name, max_value=2, cycle=True, temporary=True) + frappe.db.get_next_sequence_val(seq_name) + frappe.db.get_next_sequence_val(seq_name) + self.assertEqual(1, frappe.db.get_next_sequence_val(seq_name)) + + seq_name = self.generate_sequence_name() + frappe.db.create_sequence(seq_name, max_value=2, temporary=True) + frappe.db.get_next_sequence_val(seq_name) + frappe.db.get_next_sequence_val(seq_name) + + try: + frappe.db.get_next_sequence_val(seq_name) + except pymysql.err.OperationalError as e: + self.assertEqual(e.args[0], 4084) + except psycopg2.errors.SequenceGeneratorLimitExceeded: + pass + else: + self.fail("NEXTVAL didn't raise any error upon sequence's end") + + # without this, we're not able to move further + # as postgres doesn't allow moving further in a transaction + # when an error occurs + frappe.db.rollback() + + seq_name = self.generate_sequence_name() + frappe.db.create_sequence(seq_name, min_value=10, max_value=20, increment_by=5, temporary=True) + self.assertEqual(10, frappe.db.get_next_sequence_val(seq_name)) + self.assertEqual(15, frappe.db.get_next_sequence_val(seq_name)) + self.assertEqual(20, frappe.db.get_next_sequence_val(seq_name)) diff --git a/frappe/tests/test_test_utils.py b/frappe/tests/test_test_utils.py new file mode 100644 index 0000000000..4e5c424ca6 --- /dev/null +++ b/frappe/tests/test_test_utils.py @@ -0,0 +1,34 @@ +import frappe +from frappe.tests.utils import FrappeTestCase, change_settings + + +class TestTestUtils(FrappeTestCase): + SHOW_TRANSACTION_COMMIT_WARNINGS = True + + def test_document_assertions(self): + + currency = frappe.new_doc("Currency") + currency.currency_name = "STONKS" + currency.smallest_currency_fraction_value = 0.420_001 + currency.save() + + self.assertDocumentEqual(currency.as_dict(), currency) + + def test_thread_locals(self): + frappe.flags.temp_flag_to_be_discarded = True + + def test_temp_setting_changes(self): + current_setting = frappe.get_system_settings("logout_on_password_reset") + + with change_settings("System Settings", {"logout_on_password_reset": int(not current_setting)}): + updated_settings = frappe.get_system_settings("logout_on_password_reset") + self.assertNotEqual(current_setting, updated_settings) + + restored_settings = frappe.get_system_settings("logout_on_password_reset") + self.assertEqual(current_setting, restored_settings) + + +def tearDownModule(): + """assertions for ensuring tests didn't leave state behind""" + assert "temp_flag_to_be_discarded" not in frappe.flags + assert not frappe.db.exists("Currency", "STONKS") diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index 4cb3e95a6d..494ba8872d 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -137,12 +137,10 @@ def create_contact_records(): @frappe.whitelist() def create_multiple_todo_records(): - values = [] if frappe.db.get_all("ToDo", {"description": "Multiple ToDo 1"}): return - for index in range(1, 1002): - values.append(("100{}".format(index), "Multiple ToDo {}".format(index))) + values = [("100{}".format(i), "Multiple ToDo {}".format(i)) for i in range(1, 1002)] frappe.db.bulk_insert("ToDo", fields=["name", "description"], values=set(values)) diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index bad368afd0..7d00a0c1f9 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -1,23 +1,87 @@ import copy +import datetime import signal import unittest from contextlib import contextmanager import frappe +from frappe.model.base_document import BaseDocument +from frappe.utils import cint + +datetime_like_types = (datetime.datetime, datetime.date, datetime.time, datetime.timedelta) class FrappeTestCase(unittest.TestCase): """Base test class for Frappe tests.""" - @classmethod - def setUpClass(cls) -> None: - frappe.db.commit() - return super().setUpClass() + SHOW_TRANSACTION_COMMIT_WARNINGS = False @classmethod - def tearDownClass(cls) -> None: - frappe.db.rollback() - return super().tearDownClass() + def setUpClass(cls) -> None: + # flush changes done so far to avoid flake + frappe.db.commit() + frappe.db.begin() + if cls.SHOW_TRANSACTION_COMMIT_WARNINGS: + frappe.db.add_before_commit(_commit_watcher) + + # enqueue teardown actions (executed in LIFO order) + cls.addClassCleanup(_restore_thread_locals, copy.deepcopy(frappe.local.flags)) + cls.addClassCleanup(_rollback_db) + + return super().setUpClass() + + # --- Frappe Framework specific assertions + def assertDocumentEqual(self, expected, actual): + """Compare a (partial) expected document with actual Document.""" + + if isinstance(expected, BaseDocument): + expected = expected.as_dict() + + for field, value in expected.items(): + if isinstance(value, list): + actual_child_docs = actual.get(field) + self.assertEqual(len(value), len(actual_child_docs), msg=f"{field} length should be same") + for exp_child, actual_child in zip(value, actual_child_docs): + self.assertDocumentEqual(exp_child, actual_child) + else: + self._compare_field(value, actual.get(field), actual, field) + + def _compare_field(self, expected, actual, doc, field): + msg = f"{field} should be same." + + if isinstance(expected, float): + precision = doc.precision(field) + self.assertAlmostEqual(expected, actual, f"{field} should be same to {precision} digits") + elif isinstance(expected, (bool, int)): + self.assertEqual(expected, cint(actual), msg=msg) + elif isinstance(expected, datetime_like_types): + self.assertEqual(str(expected), str(actual), msg=msg) + else: + self.assertEqual(expected, actual, msg=msg) + + +def _commit_watcher(): + import traceback + + print("Warning:, transaction committed during tests.") + traceback.print_stack(limit=5) + + +def _rollback_db(): + frappe.local.before_commit = [] + frappe.local.rollback_observers = [] + frappe.db.value_cache = {} + frappe.db.rollback() + + +def _restore_thread_locals(flags): + frappe.local.flags = flags + frappe.local.error_log = [] + frappe.local.message_log = [] + frappe.local.debug_log = [] + frappe.local.realtime_log = [] + frappe.local.conf = frappe._dict(frappe.get_site_config()) + frappe.local.cache = {} @contextmanager diff --git a/frappe/translate.py b/frappe/translate.py index d95c8eb3e8..0ebf4eaf1b 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -22,7 +22,7 @@ from pypika.terms import PseudoColumn import frappe from frappe.model.utils import InvalidIncludePath, render_include from frappe.query_builder import DocType, Field -from frappe.utils import get_bench_path, is_html, strip, strip_html_tags +from frappe.utils import cstr, get_bench_path, is_html, strip, strip_html_tags TRANSLATE_PATTERN = re.compile( r"_\([\s\n]*" # starts with literal `_(`, ignore following whitespace/newlines @@ -319,11 +319,11 @@ def get_translation_dict_from_file(path, lang, app): elif len(item) in [2, 3]: translation_map[item[0]] = strip(item[1]) elif item: - raise Exception( - "Bad translation in '{app}' for language '{lang}': {values}".format( - app=app, lang=lang, values=repr(item).encode("utf-8") - ) + msg = "Bad translation in '{app}' for language '{lang}': {values}".format( + app=app, lang=lang, values=cstr(item) ) + frappe.log_error(message=msg, title="Error in translation file") + frappe.msgprint(msg) return translation_map diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 3e62589664..1af0ec6a39 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -18,6 +18,7 @@ from typing import Generator, Iterable from urllib.parse import quote, urlparse from redis.exceptions import ConnectionError +from traceback_with_variables import iter_exc_lines from werkzeug.test import Client import frappe @@ -82,6 +83,33 @@ def extract_email_id(email): return email_id +def validate_phone_number_with_country_code(phone_number, fieldname): + from phonenumbers import NumberParseException, is_valid_number, parse + + from frappe import _ + + if not phone_number: + return + + valid_number = False + error_message = _("Phone Number {0} set in field {1} is not valid.") + error_title = _("Invalid Phone Number") + try: + if valid_number := is_valid_number(parse(phone_number)): + return True + except NumberParseException as e: + if e.error_type == NumberParseException.INVALID_COUNTRY_CODE: + error_message = _("Please select a country code for field {1}.") + error_title = _("Country Code Required") + finally: + if not valid_number: + frappe.throw( + error_message.format(frappe.bold(phone_number), frappe.bold(fieldname)), + title=error_title, + exc=frappe.InvalidPhoneNumberError, + ) + + def validate_phone_number(phone_number, throw=False): """Returns True if valid phone number""" if not phone_number: @@ -255,7 +283,7 @@ def get_gravatar(email): return gravatar_url -def get_traceback() -> str: +def get_traceback(with_context=False) -> str: """ Returns the traceback of the Exception """ @@ -264,14 +292,19 @@ def get_traceback() -> str: if not any([exc_type, exc_value, exc_tb]): return "" - trace_list = traceback.format_exception(exc_type, exc_value, exc_tb) - bench_path = get_bench_path() + "/" + if with_context: + trace_list = iter_exc_lines() + tb = "\n".join(trace_list) + else: + trace_list = traceback.format_exception(exc_type, exc_value, exc_tb) + tb = "".join(cstr(t) for t in trace_list) - return "".join(cstr(t) for t in trace_list).replace(bench_path, "") + bench_path = get_bench_path() + "/" + return tb.replace(bench_path, "") def log(event, details): - frappe.logger().info(details) + frappe.logger(event).info(details) def dict_to_str(args, sep="&"): diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index b2795e16c3..bc89e5279e 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -174,6 +174,11 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, frappe.db.commit() finally: + # background job hygiene: release file locks if unreleased + # if this breaks something, move it to failed jobs alone - gavin@frappe.io + for doc in frappe.local.locked_documents: + doc.unlock() + frappe.monitor.stop() if is_async: frappe.destroy() diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index e2579444bd..927ae9c2db 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -461,7 +461,7 @@ class BackupGenerator: ) if self.verbose: - print(command + "\n") + print(command.replace(args.password, "*" * 10) + "\n") frappe.utils.execute_in_shell(command, low_priority=True) diff --git a/frappe/utils/csvutils.py b/frappe/utils/csvutils.py index 547372778b..b61e209b72 100644 --- a/frappe/utils/csvutils.py +++ b/frappe/utils/csvutils.py @@ -215,7 +215,10 @@ def get_csv_content_from_google_sheets(url): def validate_google_sheets_url(url): - if "docs.google.com/spreadsheets" not in url: + from urllib.parse import urlparse + + u = urlparse(url) + if u.scheme != "https" or u.netloc != "docs.google.com" or "/spreadsheets/" not in u.path: frappe.throw( _('"{0}" is not a valid Google Sheets URL').format(url), title=_("Invalid URL"), diff --git a/frappe/utils/fixtures.py b/frappe/utils/fixtures.py index f00d310c9d..42e86e9f11 100644 --- a/frappe/utils/fixtures.py +++ b/frappe/utils/fixtures.py @@ -17,11 +17,9 @@ def sync_fixtures(app=None): frappe.flags.in_fixtures = True for app in apps: - if os.path.exists(frappe.get_app_path(app, "fixtures")): - fixture_files = sorted(os.listdir(frappe.get_app_path(app, "fixtures"))) - for fname in fixture_files: - if fname.endswith(".json"): - import_doc(frappe.get_app_path(app, "fixtures", fname)) + fixtures_path = frappe.get_app_path(app, "fixtures") + if os.path.exists(fixtures_path): + import_doc(fixtures_path) import_custom_scripts(app) diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index aa6fa8b67f..30cf38bcf9 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -15,6 +15,9 @@ import frappe.utils.data from frappe import _ from frappe.frappeclient import FrappeClient from frappe.handler import execute_cmd +from frappe.model.delete_doc import delete_doc +from frappe.model.mapper import get_mapped_doc +from frappe.model.rename_doc import rename_doc from frappe.modules import scrub from frappe.utils.background_jobs import enqueue, get_jobs from frappe.website.utils import get_next_link, get_shade, get_toc @@ -110,12 +113,16 @@ def get_safe_globals(): errprint=frappe.errprint, qb=frappe.qb, get_meta=frappe.get_meta, + new_doc=frappe.new_doc, get_doc=frappe.get_doc, + get_mapped_doc=get_mapped_doc, + get_last_doc=frappe.get_last_doc, get_cached_doc=frappe.get_cached_doc, get_list=frappe.get_list, get_all=frappe.get_all, get_system_settings=frappe.get_system_settings, - rename_doc=frappe.rename_doc, + rename_doc=rename_doc, + delete_doc=delete_doc, utils=datautils, get_url=frappe.utils.get_url, render_template=frappe.render_template, @@ -139,6 +146,7 @@ def get_safe_globals(): ), make_get_request=frappe.integrations.utils.make_get_request, make_post_request=frappe.integrations.utils.make_post_request, + get_payment_gateway_controller=frappe.integrations.utils.get_payment_gateway_controller, socketio_port=frappe.conf.socketio_port, get_hooks=get_hooks, enqueue=safe_enqueue, diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index d1cda3d0fc..156f2ab04d 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -24,6 +24,15 @@ from frappe.utils.background_jobs import get_jobs DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" +def cprint(*args, **kwargs): + """Prints only if called from STDOUT""" + try: + os.get_terminal_size() + print(*args, **kwargs) + except Exception: + pass + + def start_scheduler(): """Run enqueue_events_for_all_sites every 2 minutes (default). Specify scheduler_interval in seconds in common_site_config.json""" @@ -94,9 +103,11 @@ def enqueue_events(site): def is_scheduler_inactive(): if frappe.local.conf.maintenance_mode: + cprint("Maintenance mode is ON") return True if frappe.local.conf.pause_scheduler: + cprint("frappe.conf.pause_scheduler is SET") return True if is_scheduler_disabled(): @@ -107,9 +118,15 @@ def is_scheduler_inactive(): def is_scheduler_disabled(): if frappe.conf.disable_scheduler: + cprint("frappe.conf.disable_scheduler is SET") return True - return not frappe.utils.cint(frappe.db.get_single_value("System Settings", "enable_scheduler")) + scheduler_disabled = not frappe.utils.cint( + frappe.db.get_single_value("System Settings", "enable_scheduler") + ) + if scheduler_disabled: + cprint("SystemSettings.enable_scheduler is UNSET") + return scheduler_disabled def toggle_scheduler(enable): diff --git a/frappe/website/doctype/blog_post/test_blog_post.py b/frappe/website/doctype/blog_post/test_blog_post.py index 0eddad4bfe..558795458b 100644 --- a/frappe/website/doctype/blog_post/test_blog_post.py +++ b/frappe/website/doctype/blog_post/test_blog_post.py @@ -1,12 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import re -import unittest from bs4 import BeautifulSoup import frappe from frappe.custom.doctype.customize_form.customize_form import reset_customization +from frappe.tests.utils import FrappeTestCase from frappe.utils import random_string, set_request from frappe.website.doctype.blog_post.blog_post import get_blog_list from frappe.website.serve import get_response @@ -16,7 +16,7 @@ from frappe.website.website_generator import WebsiteGenerator test_dependencies = ["Blog Post"] -class TestBlogPost(unittest.TestCase): +class TestBlogPost(FrappeTestCase): def setUp(self): reset_customization("Blog Post") @@ -61,7 +61,7 @@ class TestBlogPost(unittest.TestCase): category_page_link = list(soup.find_all("a", href=re.compile(blog.blog_category)))[0] category_page_url = category_page_link["href"] - cached_value = frappe.db.value_cache[("DocType", "Blog Post", "name")] + cached_value = frappe.db.value_cache.get(("DocType", "Blog Post", "name")) frappe.db.value_cache[("DocType", "Blog Post", "name")] = (("Blog Post",),) # Visit the category page (by following the link found in above stage) diff --git a/frappe/website/doctype/help_article/templates/help_article.html b/frappe/website/doctype/help_article/templates/help_article.html index 9ef9a398f5..105b63e651 100644 --- a/frappe/website/doctype/help_article/templates/help_article.html +++ b/frappe/website/doctype/help_article/templates/help_article.html @@ -5,7 +5,7 @@ {% endblock %} {% block page_content %} -
+
By {{ author }} on {{ frappe.format_date(creation) }}
{{ level }} diff --git a/frappe/www/unsubscribe.py b/frappe/www/unsubscribe.py index bae54f740d..d679bb3319 100644 --- a/frappe/www/unsubscribe.py +++ b/frappe/www/unsubscribe.py @@ -8,7 +8,7 @@ no_cache = True def get_context(context): frappe.flags.ignore_permissions = True # Called for confirmation. - if "email" in frappe.form_dict: + if "email" in frappe.form_dict and frappe.request.method == "GET": if verify_request(): user_email = frappe.form_dict["email"] context.email = user_email @@ -18,7 +18,7 @@ def get_context(context): context.status = "waiting_for_confirmation" # Called when form is submitted. - elif "user_email" in frappe.form_dict: + elif "user_email" in frappe.form_dict and frappe.request.method == "POST": context.status = "unsubscribed" email = frappe.form_dict["user_email"] email_group = get_email_groups(email) diff --git a/requirements.txt b/requirements.txt index c77ab1d424..88ad0562b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -63,12 +63,14 @@ semantic-version~=2.8.5 sqlparse~=0.4.1 stripe~=2.56.0 terminaltables~=3.1.0 +traceback-with-variables~=2.0.4 urllib3~=1.26.4 Werkzeug~=2.0.3 Whoosh~=2.7.4 -wrapt~=1.12.1 xlrd~=2.0.1 zxcvbn-python~=4.4.24 tenacity~=8.0.1 cairocffi==1.2.0 WeasyPrint==52.5 +phonenumbers==8.12.40 +