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 @@
-
+
+> 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",
- ""
+ ";
});
});
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:\nfield:[fieldname] - By Fieldautoincrement - Uses Databases' Auto Increment featurenaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat: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:\nfield:[fieldname] - By Fieldautoincrement - Uses Databases' Auto Increment featurenaming_series: - By Naming Series (field called naming_series must be present)Prompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat: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:\nfield:[fieldname] - By Fieldnaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat: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:\nfield:[fieldname] - By Field \nautoincrement - Uses Databases' Auto Increment featurenaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat: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 = $(`
-
- `)
- .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 = $(`
+
+ `)
+ .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
+