diff --git a/.github/helper/flake8.conf b/.github/helper/flake8.conf new file mode 100644 index 0000000000..a7f1f70da3 --- /dev/null +++ b/.github/helper/flake8.conf @@ -0,0 +1,76 @@ +[flake8] +ignore = + B001, + B007, + B009, + B010, + B950, + E101, + E111, + E114, + E116, + E117, + E121, + E122, + E123, + E124, + E125, + E126, + E127, + E128, + E131, + E201, + E202, + E203, + E211, + E221, + E222, + E223, + E224, + E225, + E226, + E228, + E231, + E241, + E242, + E251, + E261, + E262, + E265, + E266, + E271, + E272, + E273, + E274, + E301, + E302, + E303, + E305, + E306, + E402, + E501, + E502, + E701, + E702, + E703, + E741, + F401, + F403, + F405, + W191, + W291, + W292, + W293, + W391, + W503, + W504, + E711, + E129, + F841, + E713, + E712, + E722, + + +max-line-length = 200 +exclude=.github/helper/semgrep_rules,test_*.py diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index 90f4608a22..f68ef5046f 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -7,17 +7,27 @@ import sys import urllib.request -def get_files_list(pr_number, repo="frappe/frappe"): - req = urllib.request.Request(f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files") +def fetch_pr_data(pr_number, repo, endpoint): + api_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}" + + if endpoint: + api_url += f"/{endpoint}" + + req = urllib.request.Request(api_url) res = urllib.request.urlopen(req) - dump = json.loads(res.read().decode('utf8')) - return [change["filename"] for change in dump] + return json.loads(res.read().decode('utf8')) + +def get_files_list(pr_number, repo="frappe/frappe"): + return [change["filename"] for change in fetch_pr_data(pr_number, repo, "files")] def get_output(command, shell=True): print(command) command = shlex.split(command) return subprocess.check_output(command, shell=shell, encoding="utf8").strip() +def has_skip_ci_label(pr_number, repo="frappe/frappe"): + return any([label["name"] for label in fetch_pr_data(pr_number, repo, "")["labels"] if label["name"] == "Skip CI"]) + def is_py(file): return file.endswith("py") @@ -59,6 +69,10 @@ if __name__ == "__main__": if ci_files_changed: print("CI related files were updated, running all build processes.") + elif has_skip_ci_label(pr_number, repo): + print("Found `Skip CI` label on pr, stopping build process.") + sys.exit(0) + elif only_docs_changed: print("Only docs were updated, stopping build process.") sys.exit(0) @@ -67,12 +81,8 @@ if __name__ == "__main__": print("Only Frontend code was updated; Stopping Python build process.") sys.exit(0) - elif build_type == "ui": - if only_py_changed: - print("Only Python code was updated, stopping Cypress build process.") - sys.exit(0) - elif updated_py_file_count > 0: - # both frontend and backend code were updated - os.system('echo "::set-output name=build-server::strawberry"') + elif build_type == "ui" and only_py_changed: + print("Only Python code was updated, stopping Cypress build process.") + sys.exit(0) os.system('echo "::set-output name=build::strawberry"') diff --git a/.mergify.yml b/.mergify.yml index 7f4c084e30..f1333362a8 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -6,6 +6,7 @@ pull_request_rules: - author!=surajshetty3416 - author!=gavindsouza - author!=deepeshgarg007 + - author!=ankush - or: - base=version-13 - base=version-12 @@ -13,7 +14,7 @@ pull_request_rules: close: comment: message: | - @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. + @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch - name: Automatic merge on CI success and review @@ -53,7 +54,7 @@ pull_request_rules: {{ title }} (#{{ number }}) {{ body }} - + - name: backport to develop conditions: - label="backport develop" @@ -92,4 +93,4 @@ pull_request_rules: branches: - version-12-hotfix assignees: - - "{{ author }}" \ No newline at end of file + - "{{ author }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b39f1ca85d..e8a44f0d1e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,15 @@ repos: rev: 5.9.1 hooks: - id: isort - exclude: ".*setup.py$" + + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + additional_dependencies: [ + 'flake8-bugbear', + ] + args: ['--config', '.github/helper/flake8.conf'] ci: autoupdate_schedule: weekly diff --git a/cypress/integration/control_color.js b/cypress/integration/control_color.js new file mode 100644 index 0000000000..8d55003618 --- /dev/null +++ b/cypress/integration/control_color.js @@ -0,0 +1,77 @@ +context('Control Color', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + function get_dialog_with_color() { + return cy.dialog({ + title: 'Color', + fields: [{ + label: 'Color', + fieldname: 'color', + fieldtype: 'Color' + }] + }); + } + + it('Verifying if the color control is selecting correct', () => { + get_dialog_with_color().as('dialog'); + cy.findByPlaceholderText('Choose a color').click(); + + ///Selecting a color from the color palette + cy.get('[style="background-color: rgb(79, 157, 217);"]').click(); + + //Checking if the css attribute is correct + cy.get('.color-map').should('have.css', 'color', 'rgb(79, 157, 217)'); + cy.get('.hue-map').should('have.css', 'color', 'rgb(0, 145, 255)'); + + //Checking if the correct color is being selected + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('color'); + expect(value).to.equal('#4F9DD9'); + }); + + //Selecting a color + cy.get('[style="background-color: rgb(203, 41, 41);"]').click(); + + //Checking if the correct css is being selected + cy.get('.color-map').should('have.css', 'color', 'rgb(203, 41, 41)'); + cy.get('.hue-map').should('have.css', 'color', 'rgb(255, 0, 0)'); + + //Checking if the correct color is being selected + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('color'); + expect(value).to.equal('#CB2929'); + }); + + //Selecting color from the palette + cy.get('.color-map > .color-selector').click(65, 87, {force: true}); + cy.get('.color-map').should('have.css', 'color', 'rgb(56, 0, 0)'); + + //Checking if the expected color is selected and getting displayed + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('color'); + expect(value).to.equal('#380000'); + }); + + //Selecting the color from the hue map + cy.get('.hue-map > .hue-selector').click(35, -1, {force: true}); + cy.get('.color-map').should('have.css', 'color', 'rgb(56, 45, 0)'); + cy.get('.hue-map').should('have.css', 'color', 'rgb(255, 204, 0)'); + cy.get('.color-map > .color-selector').click(55, 12, {force: true}); + cy.get('.color-map').should('have.css', 'color', 'rgb(46, 37, 0)'); + + //Checking if the correct color is being displayed + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('color'); + expect(value).to.equal('#2e2500'); + }); + + //Clearing the field and checking if the field contains the placeholder "Choose a color" + cy.get('.input-with-feedback').click({force: true}); + cy.get_field('color', 'Color').type('{selectall}').clear(); + cy.get_field('color', 'Color').invoke('attr', 'placeholder').should('contain', 'Choose a color'); + + }); +}); \ No newline at end of file diff --git a/cypress/integration/control_data.js b/cypress/integration/control_data.js index 021b9032c1..01f9168667 100644 --- a/cypress/integration/control_data.js +++ b/cypress/integration/control_data.js @@ -59,7 +59,7 @@ context('Data Control', () => { //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.hide_dialog(); cy.get_field('name1', 'Data').clear({force: true}); cy.fill_field('name1', 'Komal{}/!', 'Data'); @@ -67,10 +67,10 @@ context('Data Control', () => { 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'); + cy.hide_dialog(); }); 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}); @@ -79,17 +79,17 @@ context('Data Control', () => { 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.hide_dialog(); 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'); + cy.hide_dialog(); }); 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}); @@ -98,7 +98,7 @@ context('Data Control', () => { 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"); + cy.hide_dialog(); }); it('Inputting correct data and saving the doc', () => { @@ -124,6 +124,5 @@ context('Data Control', () => { 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_link.js b/cypress/integration/control_link.js index 7a7e94d2f5..44153f7e4a 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -20,7 +20,21 @@ context('Control Link', () => { 'label': 'Select ToDo', 'fieldname': 'link', 'fieldtype': 'Link', - 'options': 'ToDo' + 'options': 'ToDo', + } + ] + }); + } + + function get_dialog_with_user_link() { + return cy.dialog({ + title: 'Link', + fields: [ + { + 'label': 'Select User', + 'fieldname': 'link', + 'fieldtype': 'Link', + 'options': 'User', } ] }); @@ -29,6 +43,24 @@ context('Control Link', () => { it('should set the valid value', () => { get_dialog_with_link().as('dialog'); + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "User", + "property": "translate_link_fields", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "0" + }, true); + + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "ToDo", + "property": "show_title_field_in_link", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "0" + }, true); + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); @@ -88,7 +120,8 @@ context('Control Link', () => { cy.get('@input').type(todos[0]).blur(); cy.wait('@validate_link'); cy.get('@input').focus(); - cy.findByTitle('Open Link') + cy.wait(500); // wait for arrow to show + cy.get('.frappe-control[data-fieldname=link] .btn-open') .should('be.visible') .click(); cy.location('pathname').should('eq', `/app/todo/${todos[0]}`); @@ -96,7 +129,15 @@ context('Control Link', () => { }); it('show title field in link', () => { - get_dialog_with_link().as('dialog'); + + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "User", + "property": "translate_link_fields", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "0" + }, true); cy.insert_doc("Property Setter", { "doctype": "Property Setter", @@ -107,6 +148,10 @@ context('Control Link', () => { "value": "1" }, true); + cy.clear_cache(); + cy.wait(500); + + get_dialog_with_link().as('dialog'); cy.window().its('frappe').then(frappe => { if (!frappe.boot) { frappe.boot = { @@ -134,8 +179,6 @@ context('Control Link', () => { expect(value).to.eq(todos[0]); expect(label).to.eq('this is a test todo for link'); - - cy.remove_doc("Property Setter", "ToDo-main-show_title_field_in_link"); }); }); }); @@ -143,6 +186,7 @@ context('Control Link', () => { it('should update dependant fields (via fetch_from)', () => { cy.get('@todos').then(todos => { cy.visit(`/app/todo/${todos[0]}`); + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input'); @@ -167,7 +211,9 @@ context('Control Link', () => { .should("eq", null); // set valid value again - cy.get('@input').clear().type('Administrator', {delay: 100}).blur(); + cy.get('@input').clear().focus(); + cy.wait('@search_link'); + cy.get('@input').type('Administrator', {delay: 100}).blur(); cy.wait('@validate_link'); cy.window() @@ -214,4 +260,130 @@ context('Control Link', () => { "contain", "" ); }); + + it('show translated text for link with show_title_field_in_link enabled', () => { + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "ToDo", + "property": "translate_link_fields", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "1" + }, true); + + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "ToDo", + "property": "show_title_field_in_link", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "1" + }, true); + + cy.window().its('frappe').then(frappe => { + cy.insert_doc("Translation", { + doctype: "Translation", + language: frappe.boot.lang, + source_text: "this is a test todo for link", + translated_text: "this is a translated test todo for link", + }); + }); + + cy.clear_cache(); + cy.wait(500); + + cy.window().its('frappe').then(frappe => { + if (!frappe.boot) { + frappe.boot = { + link_title_doctypes: ['ToDo'], + translatable_doctypes: ['ToDo'] + }; + } else { + frappe.boot.link_title_doctypes = ['ToDo']; + frappe.boot.translatable_doctypes = ['ToDo']; + } + }); + + get_dialog_with_link().as('dialog'); + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); + + cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); + cy.wait('@search_link'); + cy.get('@input').type('todo for link', { delay: 100 }); + cy.wait('@search_link'); + cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); + cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); + cy.get('.frappe-control[data-fieldname=link] input').blur(); + cy.get('@dialog').then(dialog => { + cy.get('@todos').then(todos => { + let field = dialog.get_field('link'); + let value = field.get_value(); + let label = field.get_label_value(); + + expect(value).to.eq(todos[0]); + expect(label).to.eq('this is a translated test todo for link'); + }); + }); + }); + + it('show translated text for link with show_title_field_in_link disabled', () => { + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "User", + "property": "translate_link_fields", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "1" + }, true); + + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "ToDo", + "property": "show_title_field_in_link", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "0" + }, true); + + cy.window().its('frappe').then(frappe => { + cy.insert_doc("Translation", { + doctype: "Translation", + language: frappe.boot.lang, + source_text: "test@erpnext.com", + translated_text: "translatedtest@erpnext.com", + }); + }); + + cy.clear_cache(); + cy.wait(500); + + cy.window().its('frappe').then(frappe => { + if (!frappe.boot) { + frappe.boot = { + translatable_doctypes: ['User'] + }; + } else { + frappe.boot.translatable_doctypes = ['User']; + } + }); + + get_dialog_with_user_link().as('dialog'); + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); + + cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); + cy.wait('@search_link'); + cy.get('@input').type('test@erpnext.com', { delay: 100 }); + cy.wait('@search_link'); + cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); + cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); + cy.get('.frappe-control[data-fieldname=link] input').blur(); + cy.get('@dialog').then(dialog => { + let field = dialog.get_field('link'); + let value = field.get_value(); + let label = field.get_label_value(); + + expect(value).to.eq('test@erpnext.com'); + expect(label).to.eq('translatedtest@erpnext.com'); + }); + }); }); diff --git a/cypress/integration/custom_buttons.js b/cypress/integration/custom_buttons.js new file mode 100644 index 0000000000..e2f02668e9 --- /dev/null +++ b/cypress/integration/custom_buttons.js @@ -0,0 +1,59 @@ +const test_button_names = [ + "Metallica", + "Pink Floyd", + "Porcupine Tree (the GOAT)", + "AC / DC", + `Electronic Dance "music"`, +]; + +const add_button = (label, group = "TestGroup") => { + cy.window() + .its("cur_frm") + .then((frm) => { + frm.add_custom_button(label, () => {}, group); + }); +}; + +const check_button_count = (label, group = "TestGroup") => { + // Verify main buttons + cy.findByRole("button", { name: group }).click(); + cy.get(`[data-label="${encodeURIComponent(label)}"]`) + .should("have.length", 1) + .should("be.visible"); + + // Verify dropdown buttons in mobile view + cy.viewport(420, 900); + const dropdown_btn_label = `${group} > ${label}`; + cy.get(".menu-btn-group > .btn").click(); + cy.get(`[data-label="${encodeURIComponent(dropdown_btn_label)}"]`) + .should("have.length", 1) + .should("be.visible"); + + //reset viewport + cy.viewport( + Cypress.config("viewportWidth"), + Cypress.config("viewportHeight") + ); +}; + +describe( + "Custom group button behaviour on desk", + { scrollBehavior: false }, // speeds up the test + () => { + before(() => { + cy.login(); + cy.visit(`/app/note/new`); + }); + + test_button_names.forEach((button_name) => { + it(`Custom button works with name '${button_name}'`, () => { + add_button(button_name); + check_button_count(button_name); + + // duplicate button shouldn't be added + add_button(button_name); + check_button_count(button_name); + }); + }); + } +); diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index cec7edb59f..484419b4aa 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -15,10 +15,9 @@ context('Folder Navigation', () => { cy.get('.filter-action-buttons > div > .btn-primary').findByText('Apply Filters').click(); //Adding folder (Test Folder) - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group [data-label="New Folder"]').click(); - cy.get('form > [data-fieldname="value"]').type('Test Folder'); - cy.findByRole('button', {name: 'Create'}).click(); + cy.click_menu_button("New Folder"); + cy.fill_field('value', 'Test Folder'); + cy.click_modal_primary_button('Create'); }); it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { @@ -30,10 +29,9 @@ context('Folder Navigation', () => { cy.visit('/app/file/view/home/Attachments'); //Adding folder inside the attachments folder - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group [data-label="New Folder"]').click(); - cy.get('form > [data-fieldname="value"]').type('Test Folder'); - cy.findByRole('button', {name: 'Create'}).click(); + cy.click_menu_button("New Folder"); + cy.fill_field('value', 'Test Folder'); + cy.click_modal_primary_button('Create'); //Navigating inside the added folder in the Attachments folder cy.get('[title="Test Folder"] > span').click(); @@ -46,34 +44,36 @@ context('Folder Navigation', () => { cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true}); cy.get('.file-uploader').findByText('Link').click(); cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg'); - cy.findByRole('button', {name: 'Upload'}).click(); + cy.click_modal_primary_button('Upload'); //To check if the added file is present in the Test Folder cy.get('span.level-item > span').should('contain', 'Test Folder'); cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); cy.get('.list-row-checkbox').eq(0).click(); + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.reportview.delete_items' + }).as('file_deleted'); + //Deleting the added file from the Test folder - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); - cy.wait(700); - cy.findByRole('button', {name: 'Yes'}).click(); - cy.wait(700); + cy.click_action_button("Delete"); + cy.click_modal_primary_button('Yes'); + cy.wait('@file_deleted'); //Deleting the Test Folder cy.visit('/app/file/view/home/Attachments'); cy.get('.list-row-checkbox').eq(0).click(); - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); - cy.findByRole('button', {name: 'Yes'}).click(); + cy.click_action_button("Delete"); + cy.click_modal_primary_button('Yes'); + cy.wait('@file_deleted'); }); it('Deleting Test Folder from the home', () => { //Deleting the Test Folder added in the home directory cy.visit('/app/file/view/home'); cy.get('.level-left > .list-subject > .file-select >.list-row-checkbox').eq(0).click({force: true, delay: 500}); - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); - cy.findByRole('button', {name: 'Yes'}).click(); + cy.click_action_button("Delete"); + cy.click_modal_primary_button('Yes'); }); -}); +}); diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js index ab7ada9034..507a07ab1a 100644 --- a/cypress/integration/form_tour.js +++ b/cypress/integration/form_tour.js @@ -1,7 +1,7 @@ -context('Form Tour', () => { +context.skip('Form Tour', () => { before(() => { cy.login(); - cy.visit('/app/form-tour'); + cy.visit('/app'); return cy.window().its('frappe').then(frappe => { return frappe.call("frappe.tests.ui_test_helpers.create_form_tour"); }); diff --git a/cypress/integration/kanban.js b/cypress/integration/kanban.js index 50fc41afe3..6ebab5d008 100644 --- a/cypress/integration/kanban.js +++ b/cypress/integration/kanban.js @@ -26,7 +26,7 @@ context('Kanban Board', () => { cy.click_listview_primary_button('Add ToDo'); - cy.fill_field('description', 'Test Kanban ToDo', 'Text Editor'); + cy.fill_field('description', 'Test Kanban ToDo', 'Text Editor').wait(300); cy.get('.modal-footer .btn-primary').last().click(); cy.wait('@save-todo'); diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js index f873461efb..ae93354964 100644 --- a/cypress/integration/table_multiselect.js +++ b/cypress/integration/table_multiselect.js @@ -48,4 +48,4 @@ context('Table MultiSelect', () => { cy.get('@existing_value').find('.btn-link-to-form').click(); cy.location('pathname').should('contain', '/user/test@erpnext.com'); }); -}); +}); \ No newline at end of file diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index 6c4733400d..cb4d43a96a 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -78,12 +78,5 @@ context('Timeline', () => { cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click(); cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click(); cy.click_modal_primary_button('Yes'); - - //Deleting the custom doctype - cy.visit('/app/doctype'); - cy.select_listview_row_checkbox(0); - cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click(); - cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click(); - cy.click_modal_primary_button('Yes'); }); }); \ No newline at end of file diff --git a/cypress/integration/workspace_blocks.js b/cypress/integration/workspace_blocks.js new file mode 100644 index 0000000000..3c0a429973 --- /dev/null +++ b/cypress/integration/workspace_blocks.js @@ -0,0 +1,140 @@ +context('Workspace Blocks', () => { + before(() => { + cy.login(); + cy.visit('/app'); + return cy.window().its('frappe').then(frappe => { + return frappe.xcall("frappe.tests.ui_test_helpers.setup_workflow"); + }); + }); + + it('Create Test Page', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.new_page' + }).as('new_page'); + + cy.visit('/app/website'); + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); + cy.fill_field('title', 'Test Block Page', 'Data'); + 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 private section + cy.get('.sidebar-item-container[item-name="Test Block 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 Block Page"]').should('have.attr', 'item-public', '0'); + + cy.wait('@new_page'); + }); + + it('Quick List Block', () => { + cy.create_records([ + { + doctype: 'ToDo', + description: 'Quick List ToDo 1', + status: 'Open' + }, + { + doctype: 'ToDo', + description: 'Quick List ToDo 2', + status: 'Open' + }, + { + doctype: 'ToDo', + description: 'Quick List ToDo 3', + status: 'Open' + }, + { + doctype: 'ToDo', + description: 'Quick List ToDo 4', + status: 'Open' + } + ]); + + cy.intercept({ + method: 'GET', + url: 'api/method/frappe.desk.form.load.getdoctype' + }).as('get_doctype'); + + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); + + // test quick list creation + cy.get('.ce-block').first().click({force: true}).type('{enter}'); + cy.get('.block-list-container .block-list-item').contains('Quick List').click(); + + cy.get_open_dialog().find('.modal-header').click(); + + cy.fill_field('document_type', 'ToDo', 'Link').blur(); + cy.fill_field('label', 'ToDo', 'Data').blur(); + cy.wait('@get_doctype'); + + cy.get_open_dialog().find('.filter-edit-area').should('contain', 'No filters selected'); + cy.get_open_dialog().find('.filter-area .add-filter').click(); + + cy.get_open_dialog().find('.fieldname-select-area input').type('Workflow State{enter}').blur(); + cy.get_open_dialog().find('.filter-field .input-with-feedback').type('Pending'); + + cy.get_open_dialog().find('.modal-header').click(); + cy.get_open_dialog().find('.btn-primary').click(); + + cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); + + + cy.get('.codex-editor__redactor .ce-block'); + + cy.get('.ce-block .quick-list-widget-box').first().as('todo-quick-list'); + + cy.get('@todo-quick-list').find('.quick-list-item .status').should('contain', 'Pending'); + + // test quick-list-item + cy.get('@todo-quick-list').find('.quick-list-item .title') + .first() + .invoke('attr', 'title') + .then(title => { + cy.get('@todo-quick-list').find('.quick-list-item').contains(title).click(); + cy.get_field('description', 'Text Editor').should('contain', title); + cy.click_action_button('Approve'); + }); + cy.go('back'); + + // test filter-list + cy.get('@todo-quick-list').realHover().find('.widget-control .filter-list').click(); + + cy.get_open_dialog().find('.filter-field .input-with-feedback').clear().type('Approved'); + cy.get_open_dialog().find('.modal-header').click(); + cy.get_open_dialog().find('.btn-primary').click(); + + cy.get('@todo-quick-list').find('.quick-list-item .status').should('contain', 'Approved'); + + + // test refresh-list + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.reportview.get' + }).as('refresh-list'); + + cy.get('@todo-quick-list').realHover().find('.widget-control .refresh-list').click(); + cy.wait('@refresh-list'); + + + // test add-new + cy.get('@todo-quick-list').realHover().find('.widget-control .add-new').click(); + cy.url().should('include', `/todo/new-todo-1`); + cy.go('back'); + + + // test see-all + cy.get('@todo-quick-list').find('.widget-footer .see-all').click(); + cy.open_list_filter(); + cy.get('.filter-field input[data-fieldname="workflow_state"]') + .invoke('val') + .should('eq', 'Pending'); + cy.go('back'); + }); + +}); \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 95b5cbb670..6398018e10 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,6 +1,7 @@ import 'cypress-file-upload'; import '@testing-library/cypress/add-commands'; import '@4tw/cypress-drag-drop'; +import "cypress-real-events/support"; // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite @@ -312,12 +313,22 @@ Cypress.Commands.add('insert_doc', (doctype, args, ignore_duplicate) => { }); }); -Cypress.Commands.add('add_filter', () => { +Cypress.Commands.add('open_list_filter', () => { cy.get('.filter-section .filter-button').click(); cy.wait(300); cy.get('.filter-popover').should('exist'); }); +Cypress.Commands.add('click_action_button', (name) => { + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get(`.actions-btn-group [data-label="${encodeURIComponent(name)}"]`).click(); +}); + +Cypress.Commands.add('click_menu_button', (name) => { + cy.get('.standard-actions .menu-btn-group > .btn').click(); + cy.get(`.menu-btn-group [data-label="${encodeURIComponent(name)}"]`).click(); +}); + Cypress.Commands.add('clear_filters', () => { let has_filter = false; cy.intercept({ @@ -341,7 +352,8 @@ Cypress.Commands.add('clear_filters', () => { }); Cypress.Commands.add('click_modal_primary_button', (btn_name) => { - cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).trigger('click', {force: true}); + cy.wait(400); + cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).click({force: true}); }); Cypress.Commands.add('click_sidebar_button', (btn_name) => { diff --git a/frappe/__init__.py b/frappe/__init__.py index 8bd7783283..17a945c875 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -10,28 +10,19 @@ be used to build database driven apps. Read the documentation: https://frappeframework.com/docs """ -import os -import warnings - -STANDARD_USERS = ("Guest", "Administrator") - -_dev_server = os.environ.get("DEV_SERVER", False) - -if _dev_server: - warnings.simplefilter("always", DeprecationWarning) - warnings.simplefilter("always", PendingDeprecationWarning) - import importlib import inspect import json +import os import sys -from typing import TYPE_CHECKING, Dict, List, Union +import warnings +from typing import TYPE_CHECKING, Dict, List, Optional, Union import click from werkzeug.local import Local, release_local from frappe.query_builder import get_query_builder, patch_query_aggregation, patch_query_execute -from frappe.utils.data import cstr +from frappe.utils.data import cstr, sbool # Local application imports from .exceptions import * @@ -45,11 +36,17 @@ from .utils.jinja import ( from .utils.lazy_loader import lazy_import __version__ = "14.0.0-dev" - __title__ = "Frappe Framework" -local = Local() controllers = {} +local = Local() +STANDARD_USERS = ("Guest", "Administrator") + +_dev_server = int(sbool(os.environ.get("DEV_SERVER", False))) + +if _dev_server: + warnings.simplefilter("always", DeprecationWarning) + warnings.simplefilter("always", PendingDeprecationWarning) class _dict(dict): @@ -435,7 +432,7 @@ def msgprint( if as_table and type(msg) in (list, tuple): out.as_table = 1 - if as_list and type(msg) in (list, tuple) and len(msg) > 1: + if as_list and type(msg) in (list, tuple): out.as_list = 1 if flags.print_messages and out.message: @@ -973,7 +970,7 @@ def get_precision(doctype, fieldname, currency=None, doc=None): return get_field_precision(get_meta(doctype).get_field(fieldname), doc, currency) -def generate_hash(txt=None, length=None): +def generate_hash(txt: Optional[str] = None, length: Optional[int] = None) -> str: """Generates random hash for given text + current timestamp + random string.""" import hashlib import time diff --git a/frappe/boot.py b/frappe/boot.py index 62122ed4e5..a23a7e6ac3 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -100,6 +100,7 @@ def get_bootinfo(): bootinfo.desk_settings = get_desk_settings() bootinfo.app_logo_url = get_app_logo() bootinfo.link_title_doctypes = get_link_title_doctypes() + bootinfo.translatable_doctypes = get_translatable_doctypes() return bootinfo @@ -408,3 +409,11 @@ def set_time_zone(bootinfo): "user": bootinfo.get("user_info", {}).get(frappe.session.user, {}).get("time_zone", None) or get_time_zone(), } + + +def get_translatable_doctypes(): + dts = frappe.get_all("DocType", {"translate_link_fields": 1}, pluck="name") + custom_dts = frappe.get_all( + "Property Setter", {"property": "translate_link_fields", "value": "1"}, pluck="doc_type" + ) + return dts + custom_dts diff --git a/frappe/client.py b/frappe/client.py index 1bad2bed2f..f753da6f57 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import json import os +from typing import TYPE_CHECKING import frappe import frappe.model @@ -11,6 +12,9 @@ from frappe.desk.reportview import validate_args from frappe.model.db_query import check_parent_permission from frappe.utils import get_safe_filters +if TYPE_CHECKING: + from frappe.model.document import Document + """ Handle RESTful requests that are mapped to the `/api/resource` route. @@ -189,18 +193,7 @@ def insert(doc=None): if isinstance(doc, str): doc = json.loads(doc) - 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) - parent.save() - return parent.as_dict() - else: - doc = frappe.get_doc(doc).insert() - return doc.as_dict() + return insert_doc(doc).as_dict() @frappe.whitelist(methods=["POST", "PUT"]) @@ -211,21 +204,12 @@ def insert_many(docs=None): if isinstance(docs, str): docs = json.loads(docs) - out = [] - if len(docs) > 200: frappe.throw(_("Only 200 inserts allowed in one request")) + out = set() for doc in docs: - if doc.get("parenttype"): - # inserting a child record - parent = frappe.get_doc(doc.parenttype, doc.parent) - parent.append(doc.parentfield, doc) - parent.save() - out.append(parent.name) - else: - doc = frappe.get_doc(doc).insert() - out.append(doc.name) + out.add(insert_doc(doc).name) return out @@ -496,3 +480,23 @@ def validate_link(doctype: str, docname: str, fields=None): ) return values + + +def insert_doc(doc) -> "Document": + """Inserts document and returns parent document object with appended child document + if `doc` is child document else returns the inserted document object + + :param doc: doc to insert (dict)""" + + 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) + parent.save() + return parent + + return frappe.get_doc(doc).insert() diff --git a/frappe/commands/site.py b/frappe/commands/site.py index fa9ab4be59..628a10d67e 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -54,7 +54,6 @@ def new_site( db_root_password=None, admin_password=None, verbose=False, - install_apps=None, source_sql=None, force=None, no_mariadb_socket=False, @@ -398,8 +397,9 @@ def _reinstall( @click.command("install-app") @click.argument("apps", nargs=-1) +@click.option("--force", is_flag=True, default=False) @pass_context -def install_app(context, apps): +def install_app(context, apps, force=False): "Install a new app to site, supports multiple apps" from frappe.installer import install_app as _install_app @@ -414,7 +414,7 @@ def install_app(context, apps): for app in apps: try: - _install_app(app, verbose=context.verbose) + _install_app(app, verbose=context.verbose, force=force) except frappe.IncompatibleApp as err: err_msg = ":\n{}".format(err) if str(err) else "" print("App {} is Incompatible with Site {}{}".format(app, site, err_msg)) @@ -825,7 +825,7 @@ def _drop_site( try: if not no_backup: click.secho(f"Taking backup of {site}", fg="green") - odb = scheduled_backup(ignore_files=False, force=True, verbose=True) + odb = scheduled_backup(ignore_files=False, ignore_conf=True, force=True, verbose=True) odb.print_summary() except Exception as err: if force: @@ -923,7 +923,6 @@ def set_user_password(site, user, password, logout_all_sessions=False): update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions) frappe.db.commit() - password = None finally: frappe.destroy() diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 499dd61421..2d3916914d 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -856,6 +856,8 @@ def run_ui_tests( node_bin = subprocess.getoutput("npm bin") cypress_path = f"{node_bin}/cypress" plugin_path = f"{node_bin}/../cypress-file-upload" + drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop" + real_events_plugin_path = f"{node_bin}/../cypress-real-events" testing_library_path = f"{node_bin}/../@testing-library" coverage_plugin_path = f"{node_bin}/../@cypress/code-coverage" @@ -863,6 +865,8 @@ def run_ui_tests( if not ( os.path.exists(cypress_path) and os.path.exists(plugin_path) + and os.path.exists(drag_drop_plugin_path) + and os.path.exists(real_events_plugin_path) and os.path.exists(testing_library_path) and os.path.exists(coverage_plugin_path) and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6 @@ -870,7 +874,7 @@ def run_ui_tests( # install cypress click.secho("Installing Cypress...", fg="yellow") frappe.commands.popen( - "yarn add cypress@^6 cypress-file-upload@^5 @4tw/cypress-drag-drop@^2 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile" + "yarn add cypress@^6 cypress-file-upload@^5 @4tw/cypress-drag-drop@^2 cypress-real-events @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile" ) # run for headless mode diff --git a/frappe/contacts/address_and_contact.py b/frappe/contacts/address_and_contact.py index 1eb9f1cf33..036594926e 100644 --- a/frappe/contacts/address_and_contact.py +++ b/frappe/contacts/address_and_contact.py @@ -17,7 +17,7 @@ def load_address_and_contact(doc, key=None): ["Dynamic Link", "link_name", "=", doc.name], ["Dynamic Link", "parenttype", "=", "Address"], ] - address_list = frappe.get_list("Address", filters=filters, fields=["*"]) + address_list = frappe.get_list("Address", filters=filters, fields=["*"], order_by="creation asc") address_list = [a.update({"display": get_address_display(a)}) for a in address_list] diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index d9ba31d474..c7564e8866 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -268,7 +268,6 @@ def address_query(doctype, txt, searchfield, start, page_len, filters): `tabAddress`.idx desc, `tabAddress`.name limit %(start)s, %(page_len)s """.format( mcond=get_match_cond(doctype), - key=searchfield, search_condition=search_condition, condition=condition or "", ), diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py index 84c908d0ae..61dedd7bc0 100644 --- a/frappe/core/doctype/activity_log/activity_log.py +++ b/frappe/core/doctype/activity_log/activity_log.py @@ -2,7 +2,6 @@ # License: MIT. See LICENSE import frappe -from frappe import _ from frappe.core.utils import set_timeline_doc from frappe.model.document import Document from frappe.query_builder import DocType, Interval diff --git a/frappe/core/doctype/block_module/block_module.py b/frappe/core/doctype/block_module/block_module.py index 3158e3e6a5..456df5037c 100644 --- a/frappe/core/doctype/block_module/block_module.py +++ b/frappe/core/doctype/block_module/block_module.py @@ -2,7 +2,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index f0e80c2207..9b86f18726 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -20,7 +20,6 @@ from frappe.core.doctype.communication.mixins import CommunicationEmailMixin from frappe.core.utils import get_parent_doc from frappe.model.document import Document from frappe.utils import ( - cstr, parse_addr, split_emails, strip_html, @@ -152,8 +151,6 @@ class Communication(Document, CommunicationEmailMixin): if not email_body: return - email_body = email_body[0] - user_email_signature = ( frappe.db.get_value( "User", diff --git a/frappe/core/doctype/custom_docperm/test_custom_docperm.py b/frappe/core/doctype/custom_docperm/test_custom_docperm.py index e3a831bb8d..51923718b6 100644 --- a/frappe/core/doctype/custom_docperm/test_custom_docperm.py +++ b/frappe/core/doctype/custom_docperm/test_custom_docperm.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Custom DocPerm') diff --git a/frappe/core/doctype/custom_role/test_custom_role.py b/frappe/core/doctype/custom_role/test_custom_role.py index 01956ceda3..9c12bddd26 100644 --- a/frappe/core/doctype/custom_role/test_custom_role.py +++ b/frappe/core/doctype/custom_role/test_custom_role.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Custom Role') diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 9a801cfc19..01be69ea16 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -11,7 +11,6 @@ import frappe from frappe import _ from frappe.core.doctype.version.version import get_diff from frappe.model import no_value_fields -from frappe.model import table_fields as table_fieldtypes from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar from frappe.utils.csvutils import get_csv_content_from_google_sheets, read_csv_content from frappe.utils.xlsxutils import ( @@ -574,7 +573,7 @@ class ImportFile: ###### def read_file(self, file_path): - extn = file_path.split(".")[1] + extn = os.path.splitext(file_path)[1][1:] file_content = None with io.open(file_path, mode="rb") as f: diff --git a/frappe/core/doctype/deleted_document/test_deleted_document.py b/frappe/core/doctype/deleted_document/test_deleted_document.py index 0c8e88a32f..8985298498 100644 --- a/frappe/core/doctype/deleted_document/test_deleted_document.py +++ b/frappe/core/doctype/deleted_document/test_deleted_document.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Deleted Document') diff --git a/frappe/core/doctype/docperm/docperm.py b/frappe/core/doctype/docperm/docperm.py index 68ef21e770..87d6457c3c 100644 --- a/frappe/core/doctype/docperm/docperm.py +++ b/frappe/core/doctype/docperm/docperm.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index b1579f35cd..4e110202d2 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -47,6 +47,7 @@ "view_settings", "title_field", "show_title_field_in_link", + "translate_link_fields", "search_fields", "default_print_format", "sort_field", @@ -591,6 +592,12 @@ "fieldname": "show_title_field_in_link", "fieldtype": "Check", "label": "Show Title in Link Fields" + }, + { + "default": "0", + "fieldname": "translate_link_fields", + "fieldtype": "Check", + "label": "Translate Link Fields" } ], "icon": "fa fa-bolt", @@ -673,7 +680,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2022-02-15 21:47:16.467217", + "modified": "2022-02-28 21:56:52.116915", "modified_by": "Administrator", "module": "Core", "name": "DocType", @@ -708,5 +715,6 @@ "sort_field": "modified", "sort_order": "DESC", "states": [], - "track_changes": 1 + "track_changes": 1, + "translate_link_fields": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 6bd7f2306f..047c48e9d5 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -32,7 +32,7 @@ from frappe.model.meta import Meta from frappe.modules import get_doc_path, make_boilerplate from frappe.modules.import_file import get_file_path from frappe.query_builder.functions import Concat -from frappe.utils import cint, now +from frappe.utils import cint from frappe.website.utils import clear_cache @@ -100,6 +100,7 @@ class DocType(Document): self.set_default_in_list_view() self.set_default_translatable() validate_series(self) + self.set("can_change_name_type", validate_autoincrement_autoname(self)) self.validate_document_type() validate_fields(self) @@ -124,12 +125,6 @@ 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 = [ @@ -374,6 +369,10 @@ class DocType(Document): def on_update(self): """Update database schema, make controller templates if `custom` is not set and clear cache.""" + + if self.get("can_change_name_type"): + self.setup_autoincrement_and_sequence() + try: frappe.db.updatedb(self.name, Meta(self)) except Exception as e: @@ -413,6 +412,17 @@ class DocType(Document): clear_linked_doctype_cache() + def setup_autoincrement_and_sequence(self): + """Changes name type and makes sequence on change (if required)""" + + name_type = f"varchar({frappe.db.VARCHAR_LEN})" + + if self.autoname == "autoincrement": + name_type = "bigint" + frappe.db.create_sequence(self.name, check_not_exists=True, cache=frappe.db.SEQUENCE_CACHE) + + change_name_column_type(self.name, name_type) + def sync_global_search(self): """If global search settings are changed, rebuild search properties for this table""" global_search_fields_before_update = [ @@ -903,26 +913,25 @@ 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" - ) +def validate_autoincrement_autoname(dt: DocType) -> bool: + """Checks if can doctype can change to/from autoincrement autoname""" + def get_autoname_before_save(dt: DocType) -> str: + if dt.name == "Customize Form": + property_value = frappe.db.get_value( + "Property Setter", {"doc_type": dt.doc_type, "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 frappe.db.get_value("DocType", dt.doc_type, "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) + autoname_before_save = get_autoname_before_save(dt) is_autoname_autoincrement = dt.autoname == "autoincrement" if ( @@ -930,23 +939,35 @@ def check_if_can_change_name_type(dt: DocType, raise_err: bool = True) -> bool: and autoname_before_save != "autoincrement" or (not is_autoname_autoincrement and autoname_before_save == "autoincrement") ): - if not frappe.get_all(doctype_name, limit=1): + + if frappe.get_meta(dt.name).issingle: + if dt.name == "Customize Form": + frappe.throw(_("Cannot change to/from autoincrement autoname in Customize Form")) + + return False + + if not frappe.get_all(dt.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") - ) + 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 + """Changes name column type""" + + args = ( + (doctype_name, "name", type, False, True) + if (frappe.db.db_type == "postgres") + else (doctype_name, "name", type, True) ) + frappe.db.change_column_type(*args) + def validate_links_table_fieldnames(meta): """Validate fieldnames in Links table""" diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 59475a95a7..11f5ef8a69 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -38,6 +38,52 @@ class TestDocType(unittest.TestCase): doc = new_doctype(name).insert() doc.delete() + def test_making_sequence_on_change(self): + frappe.delete_doc_if_exists("DocType", self._testMethodName) + dt = new_doctype(self._testMethodName).insert(ignore_permissions=True) + autoname = dt.autoname + + # change autoname + dt.autoname = "autoincrement" + dt.save() + + # check if name type has been changed + self.assertEqual( + frappe.db.sql( + f"""select data_type FROM information_schema.columns + where column_name = 'name' and table_name = 'tab{self._testMethodName}'""" + )[0][0], + "bigint", + ) + + if frappe.db.db_type == "mariadb": + table_name = "information_schema.tables" + conditions = f"table_type = 'sequence' and table_name = '{self._testMethodName}_id_seq'" + else: + table_name = "information_schema.sequences" + conditions = f"sequence_name = '{self._testMethodName}_id_seq'" + + # check if sequence table is created + self.assertTrue( + frappe.db.sql( + f"""select * from {table_name} + where {conditions}""" + ) + ) + + # change the autoname/naming rule back to original + dt.autoname = autoname + dt.save() + + # check if name type has changed + self.assertEqual( + frappe.db.sql( + f"""select data_type FROM information_schema.columns + where column_name = 'name' and table_name = 'tab{self._testMethodName}'""" + )[0][0], + "varchar" if frappe.db.db_type == "mariadb" else "character varying", + ) + def test_doctype_unique_constraint_dropped(self): if frappe.db.exists("DocType", "With_Unique"): frappe.delete_doc("DocType", "With_Unique") diff --git a/frappe/core/doctype/domain/test_domain.py b/frappe/core/doctype/domain/test_domain.py index 85f613a6bd..623d80a280 100644 --- a/frappe/core/doctype/domain/test_domain.py +++ b/frappe/core/doctype/domain/test_domain.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - class TestDomain(unittest.TestCase): pass diff --git a/frappe/core/doctype/error_log/error_log.json b/frappe/core/doctype/error_log/error_log.json index b2ab516bba..e0ce109595 100644 --- a/frappe/core/doctype/error_log/error_log.json +++ b/frappe/core/doctype/error_log/error_log.json @@ -39,18 +39,20 @@ "in_standard_filter": 1, "label": "Reference DocType", "options": "DocType", + "read_only": 1, "search_index": 1 }, { "fieldname": "reference_name", "fieldtype": "Data", - "label": "Reference Name" + "label": "Reference Name", + "read_only": 1 } ], "icon": "fa fa-warning-sign", "idx": 1, "links": [], - "modified": "2022-04-18 17:25:47.406873", + "modified": "2022-05-19 05:32:16.026684", "modified_by": "Administrator", "module": "Core", "name": "Error Log", diff --git a/frappe/core/doctype/error_snapshot/test_error_snapshot.py b/frappe/core/doctype/error_snapshot/test_error_snapshot.py index 40596b3d22..121fe8a6f9 100644 --- a/frappe/core/doctype/error_snapshot/test_error_snapshot.py +++ b/frappe/core/doctype/error_snapshot/test_error_snapshot.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Error Snapshot') diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 3547a03832..3352123146 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -41,7 +41,6 @@ from frappe.utils.file_manager import safe_b64decode from frappe.utils.image import optimize_image, strip_exif_data if TYPE_CHECKING: - from PIL.ImageFile import ImageFile from requests.models import Response @@ -608,7 +607,7 @@ def on_doctype_update(): def make_home_folder(): home = frappe.get_doc( {"doctype": "File", "is_folder": 1, "is_home_folder": 1, "file_name": _("Home")} - ).insert() + ).insert(ignore_if_duplicate=True) frappe.get_doc( { @@ -618,7 +617,7 @@ def make_home_folder(): "is_attachments_folder": 1, "file_name": _("Attachments"), } - ).insert() + ).insert(ignore_if_duplicate=True) @frappe.whitelist() diff --git a/frappe/core/doctype/has_domain/has_domain.py b/frappe/core/doctype/has_domain/has_domain.py index 15dc596365..8ef69ef0c4 100644 --- a/frappe/core/doctype/has_domain/has_domain.py +++ b/frappe/core/doctype/has_domain/has_domain.py @@ -2,7 +2,6 @@ # Copyright (c) 2017, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/language/test_language.py b/frappe/core/doctype/language/test_language.py index 7262cf444e..305515c191 100644 --- a/frappe/core/doctype/language/test_language.py +++ b/frappe/core/doctype/language/test_language.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Language') diff --git a/frappe/core/doctype/log_settings/log_settings.py b/frappe/core/doctype/log_settings/log_settings.py index 58d5fddcd3..5632f05a36 100644 --- a/frappe/core/doctype/log_settings/log_settings.py +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -10,10 +10,15 @@ from frappe.query_builder.functions import Now class LogSettings(Document): - def clear_logs(self): + def clear_logs(self, commit=False): + self.clear_email_queue() + if commit: + # Since since deleting many logs can take significant amount of time, commit is required to relase locks. + # Error log table doesn't require commit - myisam + # activity logs are deleted last so background job finishes and commits. + frappe.db.commit() self.clear_error_logs() self.clear_activity_logs() - self.clear_email_queue() def clear_error_logs(self): table = DocType("Error Log") @@ -34,7 +39,7 @@ class LogSettings(Document): def run_log_clean_up(): doc = frappe.get_doc("Log Settings") - doc.clear_logs() + doc.clear_logs(commit=True) @frappe.whitelist() diff --git a/frappe/core/doctype/module_def/test_module_def.py b/frappe/core/doctype/module_def/test_module_def.py index 3d269e4734..3a6da6d854 100644 --- a/frappe/core/doctype/module_def/test_module_def.py +++ b/frappe/core/doctype/module_def/test_module_def.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Module Def') diff --git a/frappe/core/doctype/patch_log/patch_log.py b/frappe/core/doctype/patch_log/patch_log.py index dbedbebdeb..eee57af4c2 100644 --- a/frappe/core/doctype/patch_log/patch_log.py +++ b/frappe/core/doctype/patch_log/patch_log.py @@ -3,7 +3,6 @@ # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/patch_log/test_patch_log.py b/frappe/core/doctype/patch_log/test_patch_log.py index 521eaf5e41..54cdc6416a 100644 --- a/frappe/core/doctype/patch_log/test_patch_log.py +++ b/frappe/core/doctype/patch_log/test_patch_log.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Patch Log') diff --git a/frappe/core/doctype/payment_gateway/payment_gateway.py b/frappe/core/doctype/payment_gateway/payment_gateway.py index c48fd340cd..7eb4c5481d 100644 --- a/frappe/core/doctype/payment_gateway/payment_gateway.py +++ b/frappe/core/doctype/payment_gateway/payment_gateway.py @@ -2,7 +2,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/payment_gateway/test_payment_gateway.py b/frappe/core/doctype/payment_gateway/test_payment_gateway.py index d40c7bbece..71766561b3 100644 --- a/frappe/core/doctype/payment_gateway/test_payment_gateway.py +++ b/frappe/core/doctype/payment_gateway/test_payment_gateway.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Payment Gateway') diff --git a/frappe/core/doctype/role/role.js b/frappe/core/doctype/role/role.js index f436c8c166..595e857d02 100644 --- a/frappe/core/doctype/role/role.js +++ b/frappe/core/doctype/role/role.js @@ -1,8 +1,15 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// MIT License. See license.txt +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +// MIT License. See LICENSE frappe.ui.form.on('Role', { refresh: function(frm) { + if (frm.doc.name === "All") { + frm.dashboard.add_comment( + __("Role 'All' will be given to all System Users."), + "yellow" + ); + } + frm.set_df_property('is_custom', 'read_only', frappe.session.user !== 'Administrator'); frm.add_custom_button("Role Permissions Manager", function() { diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py index 0e2eac16ba..5fd59e1014 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -2,7 +2,6 @@ # Copyright (c) 2019, Frappe Technologies and contributors # License: MIT. See LICENSE -import ast from types import FunctionType, MethodType, ModuleType from typing import Dict, List @@ -17,6 +16,7 @@ class ServerScript(Document): frappe.only_for("Script Manager", True) self.sync_scheduled_jobs() self.clear_scheduled_events() + self.check_if_compilable_in_restricted_context() def on_update(self): frappe.cache().delete_value("server_script_map") @@ -60,6 +60,15 @@ class ServerScript(Document): for scheduled_job in self.scheduled_jobs: frappe.delete_doc("Scheduled Job Type", scheduled_job.name) + def check_if_compilable_in_restricted_context(self): + """Check compilation errors and send them back as warnings.""" + from RestrictedPython import compile_restricted + + try: + compile_restricted(self.script) + except Exception as e: + frappe.msgprint(str(e), title=_("Compilation warning")) + def execute_method(self) -> Dict: """Specific to API endpoint Server Scripts diff --git a/frappe/core/doctype/sms_parameter/sms_parameter.py b/frappe/core/doctype/sms_parameter/sms_parameter.py index d67e905234..d2c5be6c50 100644 --- a/frappe/core/doctype/sms_parameter/sms_parameter.py +++ b/frappe/core/doctype/sms_parameter/sms_parameter.py @@ -1,7 +1,6 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/sms_settings/test_sms_settings.py b/frappe/core/doctype/sms_settings/test_sms_settings.py index a7ec761b82..da1b807594 100644 --- a/frappe/core/doctype/sms_settings/test_sms_settings.py +++ b/frappe/core/doctype/sms_settings/test_sms_settings.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - class TestSMSSettings(unittest.TestCase): pass diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 181cc6ba87..940704eb31 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -11,7 +11,6 @@ "language", "column_break_3", "time_zone", - "is_first_startup", "enable_onboarding", "setup_complete", "date_and_number_format", @@ -46,6 +45,7 @@ "password_settings", "logout_on_password_reset", "force_user_to_reset_password", + "reset_password_link_expiry_duration", "password_reset_limit", "column_break_31", "enable_password_policy", @@ -73,7 +73,8 @@ "column_break_64", "max_auto_email_report_per_user", "system_updates_section", - "disable_system_update_notification" + "disable_system_update_notification", + "disable_change_log_notification" ], "fields": [ { @@ -105,14 +106,6 @@ "read_only": 1, "reqd": 1 }, - { - "default": "0", - "fieldname": "is_first_startup", - "fieldtype": "Check", - "hidden": 1, - "label": "Is First Startup", - "read_only": 1 - }, { "default": "0", "fieldname": "setup_complete", @@ -512,12 +505,25 @@ "fieldname": "max_auto_email_report_per_user", "fieldtype": "Int", "label": "Max auto email report per user" + }, + { + "default": "0", + "fieldname": "disable_change_log_notification", + "fieldtype": "Check", + "label": "Disable Change Log Notification" + }, + { + "default": "1200", + "fieldname": "reset_password_link_expiry_duration", + "fieldtype": "Duration", + "label": "Reset Password Link Expiry Duration", + "non_negative": 1 } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2022-04-22 09:11:35.218721", + "modified": "2022-05-19 00:00:18.095269", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/system_settings/test_system_settings.py b/frappe/core/doctype/system_settings/test_system_settings.py index 955f4193f0..410762b4e7 100644 --- a/frappe/core/doctype/system_settings/test_system_settings.py +++ b/frappe/core/doctype/system_settings/test_system_settings.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - class TestSystemSettings(unittest.TestCase): pass diff --git a/frappe/core/doctype/transaction_log/transaction_log.py b/frappe/core/doctype/transaction_log/transaction_log.py index d0c0342f6f..efe9bbddc2 100644 --- a/frappe/core/doctype/transaction_log/transaction_log.py +++ b/frappe/core/doctype/transaction_log/transaction_log.py @@ -4,7 +4,6 @@ import hashlib import frappe -from frappe import _ from frappe.model.document import Document from frappe.query_builder import DocType from frappe.utils import cint, now_datetime diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 151dd40308..d5e0a108b5 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import json +import time import unittest from unittest.mock import patch @@ -256,7 +257,8 @@ class TestUser(unittest.TestCase): @Team and - + @Unknown Team please check @@ -365,7 +367,7 @@ class TestUser(unittest.TestCase): self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/app") self.assertEqual( update_password(new_password, key="wrong_key"), - "The Link specified has either been used before or Invalid", + "The reset password link has either been used before or is invalid", ) # password verification should fail with old password @@ -374,7 +376,6 @@ class TestUser(unittest.TestCase): # reset password update_password(old_password, old_password=new_password) - self.assertRaisesRegex( frappe.exceptions.ValidationError, "Invalid key type", update_password, "test", 1, ["like", "%"] ) @@ -434,6 +435,21 @@ class TestUser(unittest.TestCase): [m.get("module_name") for m in get_modules_from_all_apps()], ) + def test_reset_password_link_expiry(self): + new_password = "new_password" + # set the reset password expiry to 1 second + frappe.db.set_value( + "System Settings", "System Settings", "reset_password_link_expiry_duration", 1 + ) + frappe.set_user("testpassword@example.com") + test_user = frappe.get_doc("User", "testpassword@example.com") + test_user.reset_password() + time.sleep(1) # sleep for 1 sec to expire the reset link + self.assertEqual( + update_password(new_password, key=test_user.reset_password_key), + "The reset password link has been expired", + ) + def delete_contact(user): frappe.db.delete("Contact", {"email_id": user}) diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 642a392a58..42122ebfda 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -43,6 +43,7 @@ "new_password", "logout_all_sessions", "reset_password_key", + "last_reset_password_key_generated_on", "last_password_reset_date", "redirect_url", "document_follow_notifications_section", @@ -613,6 +614,14 @@ "label": "Module Profile", "options": "Module Profile" }, + { + "description": "Stores the datetime when the last reset password key was generated.", + "fieldname": "last_reset_password_key_generated_on", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Last Reset Password Key Generated On", + "read_only": 1 + }, { "fieldname": "column_break_75", "fieldtype": "Column Break" diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index e81f5ecd99..1ff5c98a91 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -1,5 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from datetime import timedelta + from bs4 import BeautifulSoup import frappe @@ -276,6 +278,7 @@ class User(Document): key = random_string(32) self.db_set("reset_password_key", key) + self.db_set("last_reset_password_key_generated_on", now_datetime()) url = "/update-password?key=" + key if password_expired: @@ -780,16 +783,27 @@ def _get_user_for_update_password(key, old_password): # verify old password result = frappe._dict() if key: - result.user = frappe.db.get_value("User", {"reset_password_key": key}) - if not result.user: - result.message = _("The Link specified has either been used before or Invalid") - + user = frappe.db.get_value( + "User", {"reset_password_key": key}, ["name", "last_reset_password_key_generated_on"] + ) + result.user, last_reset_password_key_generated_on = user or (None, None) + if result.user: + reset_password_link_expiry = cint( + frappe.db.get_single_value("System Settings", "reset_password_link_expiry_duration") + ) + if ( + reset_password_link_expiry + and now_datetime() + > last_reset_password_key_generated_on + timedelta(seconds=reset_password_link_expiry) + ): + result.message = _("The reset password link has been expired") + else: + result.message = _("The reset password link has either been used before or is invalid") elif old_password: # verify old password frappe.local.login_manager.check_password(frappe.session.user, old_password) user = frappe.session.user result.user = user - return result diff --git a/frappe/core/doctype/user_email/user_email.py b/frappe/core/doctype/user_email/user_email.py index 4e125f5308..21167ad25d 100644 --- a/frappe/core/doctype/user_email/user_email.py +++ b/frappe/core/doctype/user_email/user_email.py @@ -2,7 +2,6 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index e43a288744..2dfd7863b1 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -8,7 +8,6 @@ from frappe import _ from frappe.core.utils import find from frappe.desk.form.linked_with import get_linked_doctypes from frappe.model.document import Document -from frappe.permissions import get_valid_perms, update_permission_property from frappe.utils import cstr diff --git a/frappe/core/doctype/view_log/view_log.py b/frappe/core/doctype/view_log/view_log.py index 4a3ba0e83c..6156fb74df 100644 --- a/frappe/core/doctype/view_log/view_log.py +++ b/frappe/core/doctype/view_log/view_log.py @@ -2,7 +2,6 @@ # Copyright (c) 2018, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/core/notifications.py b/frappe/core/notifications.py index a10f8ec5ae..9a127e567e 100644 --- a/frappe/core/notifications.py +++ b/frappe/core/notifications.py @@ -2,8 +2,6 @@ # License: MIT. See LICENSE import frappe -from frappe.query_builder import DocType, Interval -from frappe.query_builder.functions import Now def get_notification_config(): diff --git a/frappe/core/page/background_jobs/background_jobs.py b/frappe/core/page/background_jobs/background_jobs.py index 09213d64c3..8c3c8ff41e 100644 --- a/frappe/core/page/background_jobs/background_jobs.py +++ b/frappe/core/page/background_jobs/background_jobs.py @@ -1,13 +1,9 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import json from typing import TYPE_CHECKING, Dict, List -from rq import Worker - import frappe -from frappe import _ from frappe.utils import convert_utc_to_user_timezone from frappe.utils.background_jobs import get_queues, get_workers from frappe.utils.scheduler import is_scheduler_inactive diff --git a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py index 32d8bbe18f..6d7f394beb 100644 --- a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py +++ b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py @@ -3,7 +3,6 @@ import frappe import frappe.utils.user -from frappe import _, throw from frappe.model import data_fieldtypes from frappe.permissions import check_admin_or_system_manager, rights diff --git a/frappe/core/web_form/edit_profile/edit_profile.py b/frappe/core/web_form/edit_profile/edit_profile.py index 80b7b87352..02e3e93333 100644 --- a/frappe/core/web_form/edit_profile/edit_profile.py +++ b/frappe/core/web_form/edit_profile/edit_profile.py @@ -1,6 +1,3 @@ -import frappe - - def get_context(context): # do your magic here pass diff --git a/frappe/custom/doctype/client_script/client_script.py b/frappe/custom/doctype/client_script/client_script.py index b60f5708d1..e18ad4be5d 100644 --- a/frappe/custom/doctype/client_script/client_script.py +++ b/frappe/custom/doctype/client_script/client_script.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import frappe -from frappe import _ from frappe.model.document import Document diff --git a/frappe/custom/doctype/client_script/test_client_script.py b/frappe/custom/doctype/client_script/test_client_script.py index 2538fdf515..7497ab7780 100644 --- a/frappe/custom/doctype/client_script/test_client_script.py +++ b/frappe/custom/doctype/client_script/test_client_script.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Client Script') diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 4ce2c73fa3..3ec6795f0e 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -16,7 +16,6 @@ frappe.ui.form.on("Customize Form", { onload: function(frm) { frm.set_query("doc_type", function() { return { - translate_values: false, filters: [ ["DocType", "issingle", "=", 0], ["DocType", "custom", "=", 0], diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index a0bc994c45..0011f51af4 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -29,6 +29,7 @@ "view_settings_section", "title_field", "show_title_field_in_link", + "translate_link_fields", "image_field", "default_print_format", "column_break_29", @@ -287,7 +288,7 @@ "label": "Naming" }, { - "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. \n
  3. autoincrement - Uses Databases' Auto Increment feature
  4. naming_series: - By Naming Series (field called naming_series must be present
  5. Prompt - Prompt user for a name
  6. [series] - Series by prefix (separated by a dot); for example PRE.#####
  7. \n
  8. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present)
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", "fieldname": "autoname", "fieldtype": "Data", "label": "Auto Name" @@ -311,6 +312,12 @@ "fieldname": "show_title_field_in_link", "fieldtype": "Check", "label": "Show Title in Link Fields" + }, + { + "default": "0", + "fieldname": "translate_link_fields", + "fieldtype": "Check", + "label": "Translate Link Fields" } ], "hide_toolbar": 1, @@ -319,7 +326,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-04-21 15:36:16.772277", + "modified": "2022-05-13 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 262542fd4b..20c3a7c025 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -11,9 +11,8 @@ 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_autoincrement_autoname, validate_fields_for_doctype, validate_series, ) @@ -163,7 +162,7 @@ class CustomizeForm(Document): return validate_series(self, self.autoname, self.doc_type) - can_change_name_type = check_if_can_change_name_type(self) + validate_autoincrement_autoname(self) self.flags.update_db = False self.flags.rebuild_doctype_for_global_search = False self.set_property_setters() @@ -172,12 +171,6 @@ 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) @@ -531,7 +524,10 @@ class CustomizeForm(Document): """allow type change, if both old_type and new_type are in same field group. field groups are defined in ALLOWED_FIELDTYPE_CHANGE variables. """ - in_field_group = lambda group: (old_type in group) and (new_type in group) + + def in_field_group(group): + return (old_type in group) and (new_type in group) + return any(map(in_field_group, ALLOWED_FIELDTYPE_CHANGE)) @@ -584,6 +580,7 @@ doctype_properties = { "naming_rule": "Data", "autoname": "Data", "show_title_field_in_link": "Check", + "translate_link_fields": "Check", } docfield_properties = { diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index 5a1f629beb..b00f45f5d2 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -396,3 +396,10 @@ class TestCustomizeForm(unittest.TestCase): d.label = "" d.run_method("save_customization") self.assertEqual(d.label, "") + + def test_change_to_autoincrement_autoname(self): + d = self.get_customize_form("Event") + d.autoname = "autoincrement" + + with self.assertRaises(frappe.ValidationError): + d.run_method("save_customization") diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.py b/frappe/custom/doctype/customize_form_field/customize_form_field.py index 0e030ce812..468496ca7a 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.py +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/custom/doctype/property_setter/test_property_setter.py b/frappe/custom/doctype/property_setter/test_property_setter.py index 5b877ab18c..a1bbc69235 100644 --- a/frappe/custom/doctype/property_setter/test_property_setter.py +++ b/frappe/custom/doctype/property_setter/test_property_setter.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Property Setter') diff --git a/frappe/database/database.py b/frappe/database/database.py index ca4b5a5310..42135f3cd5 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -1019,21 +1019,17 @@ class Database(object): return self.get_value(dt, dn, ignore=True, cache=cache) - def count(self, dt, filters=None, debug=False, cache=False): + def count(self, dt, filters=None, debug=False, cache=False, distinct: bool = True): """Returns `COUNT(*)` for given DocType and filters.""" if cache and not filters: cache_count = frappe.cache().get_value("doctype:count:{}".format(dt)) if cache_count is not None: return cache_count - query = self.query.get_sql(table=dt, filters=filters, fields=Count("*")) - if filters: - count = self.sql(query, debug=debug)[0][0] - return count - else: - count = self.sql(query, debug=debug)[0][0] - if cache: - frappe.cache().set_value("doctype:count:{}".format(dt), count, expires_in_sec=86400) - return count + query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"), distinct=distinct) + count = self.sql(query, debug=debug)[0][0] + if not filters and cache: + frappe.cache().set_value("doctype:count:{}".format(dt), count, expires_in_sec=86400) + return count @staticmethod def format_date(date): diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 28d78471d2..7505ef3a7f 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -19,6 +19,15 @@ class MariaDBDatabase(Database): DataError = pymysql.err.DataError REGEX_CHARACTER = "regexp" + # NOTE: using a very small cache - as during backup, if the sequence was used in anyform, + # it drops the cache and uses the next non cached value in setval query and + # puts that in the backup file, which will start the counter + # from that value when inserting any new record in the doctype. + # 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 + SEQUENCE_CACHE = 50 + def setup_type_map(self): self.db_type = "mariadb" self.type_map = { diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index f2a1206c7c..dc91873a82 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -226,6 +226,7 @@ CREATE TABLE `tabDocType` ( `sender_field` varchar(255) DEFAULT NULL, `show_title_field_in_link` int(1) NOT NULL DEFAULT 0, `migration_hash` varchar(255) DEFAULT NULL, + `translate_link_fields` int(1) NOT NULL DEFAULT 0, PRIMARY KEY (`name`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 784fa23c13..f402b4ec74 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -40,14 +40,7 @@ class MariaDBTable(DBTable): not self.meta.issingle and self.meta.autoname == "autoincrement" ) or self.doctype in log_types: - # NOTE: using a very small cache - as during backup, if the sequence was used in anyform, - # it drops the cache and uses the next non cached value in setval func and - # puts that in the backup file, which will start the counter - # from that value when inserting any new record in the doctype. - # 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 - frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=50) + frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=frappe.db.SEQUENCE_CACHE) # 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 d69e0bea94..8bd4113823 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -31,6 +31,12 @@ class PostgresDatabase(Database): InterfaceError = psycopg2.InterfaceError REGEX_CHARACTER = "~" + # NOTE; The sequence cache for postgres is per connection. + # 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 + SEQUENCE_CACHE = 0 + def setup_type_map(self): self.db_type = "postgres" self.type_map = { @@ -209,18 +215,19 @@ class PostgresDatabase(Database): ) def change_column_type( - self, doctype: str, column: str, type: str, nullable: bool = False + self, doctype: str, column: str, type: str, nullable: bool = False, use_cast: bool = False ) -> Union[List, Tuple]: table_name = get_table_name(doctype) null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL" + using_cast = f'using "{column}"::{type}' if use_cast else "" # 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}""" + ALTER COLUMN "{column}" TYPE {type} {using_cast}, + ALTER COLUMN "{column}" {null_constraint}""" ) def create_auth_table(self): diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 2cae3ab82f..99e94a226f 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -231,6 +231,7 @@ CREATE TABLE "tabDocType" ( "sender_field" varchar(255) DEFAULT NULL, "show_title_field_in_link" smallint NOT NULL DEFAULT 0, "migration_hash" varchar(255) DEFAULT NULL, + "translate_link_fields" smallint NOT NULL DEFAULT 0, PRIMARY KEY ("name") ) ; diff --git a/frappe/database/postgres/schema.py b/frappe/database/postgres/schema.py index 2abd5f37c7..ef7ba33e12 100644 --- a/frappe/database/postgres/schema.py +++ b/frappe/database/postgres/schema.py @@ -34,11 +34,7 @@ class PostgresTable(DBTable): not self.meta.issingle and self.meta.autoname == "autoincrement" ) or self.doctype in log_types: - # The sequence cache is per connection. - # 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 - frappe.db.create_sequence(self.doctype, check_not_exists=True) + frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=frappe.db.SEQUENCE_CACHE) name_column = "name bigint primary key" # TODO: set docstatus length diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index 90d5f72c16..5584c098ce 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -114,7 +114,7 @@ def drop_user_and_database(db_name, root_login, root_password): ) root_conn.commit() root_conn.sql( - f"SELECT pg_terminate_backend (pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = %s", + "SELECT pg_terminate_backend (pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = %s", (db_name,), ) root_conn.sql(f"DROP DATABASE IF EXISTS {db_name}") diff --git a/frappe/database/query.py b/frappe/database/query.py index 136f5c86b6..f608539854 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -1,13 +1,16 @@ import operator import re -from typing import Any, Dict, List, Tuple, Union +from functools import cached_property +from typing import Any, Callable, Dict, List, Tuple, Union import frappe from frappe import _ -from frappe.query_builder import Criterion, Field, Order +from frappe.boot import get_additional_filters_from_hooks +from frappe.model.db_query import get_timespan_date_range +from frappe.query_builder import Criterion, Field, Order, Table -def like(key: str, value: str) -> frappe.qb: +def like(key: Field, value: str) -> frappe.qb: """Wrapper method for `LIKE` Args: @@ -17,10 +20,10 @@ def like(key: str, value: str) -> frappe.qb: Returns: frappe.qb: `frappe.qb object with `LIKE` """ - return Field(key).like(value) + return key.like(value) -def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb: +def func_in(key: Field, value: Union[List, Tuple]) -> frappe.qb: """Wrapper method for `IN` Args: @@ -30,10 +33,10 @@ def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb: Returns: frappe.qb: `frappe.qb object with `IN` """ - return Field(key).isin(value) + return key.isin(value) -def not_like(key: str, value: str) -> frappe.qb: +def not_like(key: Field, value: str) -> frappe.qb: """Wrapper method for `NOT LIKE` Args: @@ -43,10 +46,10 @@ def not_like(key: str, value: str) -> frappe.qb: Returns: frappe.qb: `frappe.qb object with `NOT LIKE` """ - return Field(key).not_like(value) + return key.not_like(value) -def func_not_in(key: str, value: Union[List, Tuple]): +def func_not_in(key: Field, value: Union[List, Tuple]): """Wrapper method for `NOT IN` Args: @@ -56,10 +59,10 @@ def func_not_in(key: str, value: Union[List, Tuple]): Returns: frappe.qb: `frappe.qb object with `NOT IN` """ - return Field(key).notin(value) + return key.notin(value) -def func_regex(key: str, value: str) -> frappe.qb: +def func_regex(key: Field, value: str) -> frappe.qb: """Wrapper method for `REGEX` Args: @@ -69,10 +72,10 @@ def func_regex(key: str, value: str) -> frappe.qb: Returns: frappe.qb: `frappe.qb object with `REGEX` """ - return Field(key).regex(value) + return key.regex(value) -def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb: +def func_between(key: Field, value: Union[List, Tuple]) -> frappe.qb: """Wrapper method for `BETWEEN` Args: @@ -82,7 +85,26 @@ def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb: Returns: frappe.qb: `frappe.qb object with `BETWEEN` """ - return Field(key)[slice(*value)] + return key[slice(*value)] + + +def func_is(key, value): + "Wrapper for IS" + return Field(key).isnotnull() if value.lower() == "set" else Field(key).isnull() + + +def func_timespan(key: Field, value: str) -> frappe.qb: + """Wrapper method for `TIMESPAN` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `TIMESPAN` + """ + + return func_between(key, get_timespan_date_range(value)) def make_function(key: Any, value: Union[int, str]): @@ -95,7 +117,7 @@ def make_function(key: Any, value: Union[int, str]): Returns: frappe.qb: frappe.qb object """ - return OPERATOR_MAP[value[0]](key, value[1]) + return OPERATOR_MAP[value[0].casefold()](key, value[1]) def change_orderby(order: str): @@ -118,7 +140,8 @@ def change_orderby(order: str): return order[0], Order.desc -OPERATOR_MAP = { +# default operators +OPERATOR_MAP: Dict[str, Callable] = { "+": operator.add, "=": operator.eq, "-": operator.sub, @@ -135,11 +158,38 @@ OPERATOR_MAP = { "not like": not_like, "regex": func_regex, "between": func_between, + "is": func_is, + "timespan": func_timespan, + # TODO: Add support for nested set + # TODO: Add support for custom operators (WIP) - via filters_config hooks } class Query: - def get_condition(self, table: str, **kwargs) -> frappe.qb: + tables: dict = {} + + @cached_property + def OPERATOR_MAP(self): + # default operators + all_operators = OPERATOR_MAP.copy() + + # update with site-specific custom operators + additional_filters_config = get_additional_filters_from_hooks() + + if additional_filters_config: + from frappe.utils.commands import warn + + warn("'filters_config' hook is not completely implemented yet in frappe.db.query engine") + + for _operator, function in additional_filters_config.items(): + if callable(function): + all_operators.update({_operator.casefold(): function}) + elif isinstance(function, dict): + all_operators[_operator.casefold()] = frappe.get_attr(function.get("get_field"))()["operator"] + + return all_operators + + def get_condition(self, table: Union[str, Table], **kwargs) -> frappe.qb: """Get initial table object Args: @@ -148,11 +198,20 @@ class Query: Returns: frappe.qb: DocType with initial condition """ + table_object = self.get_table(table) if kwargs.get("update"): - return frappe.qb.update(table) + return frappe.qb.update(table_object) if kwargs.get("into"): - return frappe.qb.into(table) - return frappe.qb.from_(table) + return frappe.qb.into(table_object) + return frappe.qb.from_(table_object) + + def get_table(self, table_name: Union[str, Table]) -> Table: + if isinstance(table_name, Table): + return table_name + table_name = table_name.strip('"').strip("'") + if table_name not in self.tables: + self.tables[table_name] = frappe.qb.DocType(table_name) + return self.tables[table_name] def criterion_query(self, table: str, criterion: Criterion, **kwargs) -> frappe.qb: """Generate filters from Criterion objects @@ -210,15 +269,20 @@ class Query: if isinstance(filters, list): for f in filters: if not isinstance(f, (list, tuple)): - _operator = OPERATOR_MAP[filters[1]] + _operator = self.OPERATOR_MAP[filters[1].casefold()] if not isinstance(filters[0], str): conditions = make_function(filters[0], filters[2]) break conditions = conditions.where(_operator(Field(filters[0]), filters[2])) break else: - _operator = OPERATOR_MAP[f[1]] - conditions = conditions.where(_operator(Field(f[0]), f[2])) + _operator = self.OPERATOR_MAP[f[-2].casefold()] + if len(f) == 4: + table_object = self.get_table(f[0]) + _field = table_object[f[1]] + else: + _field = Field(f[0]) + conditions = conditions.where(_operator(_field, f[-1])) return self.add_conditions(conditions, **kwargs) @@ -241,18 +305,14 @@ class Query: for key in filters: value = filters.get(key) - _operator = OPERATOR_MAP["="] + _operator = self.OPERATOR_MAP["="] if not isinstance(key, str): conditions = conditions.where(make_function(key, value)) continue if isinstance(value, (list, tuple)): - if isinstance(value[1], (list, tuple)) or value[0] in list(OPERATOR_MAP.keys())[-4:]: - _operator = OPERATOR_MAP[value[0]] - conditions = conditions.where(_operator(key, value[1])) - else: - _operator = OPERATOR_MAP[value[0]] - conditions = conditions.where(_operator(Field(key), value[1])) + _operator = self.OPERATOR_MAP[value[0].casefold()] + conditions = conditions.where(_operator(Field(key), value[1])) else: if value is not None: conditions = conditions.where(_operator(Field(key), value)) @@ -293,10 +353,19 @@ class Query: self, table: str, fields: Union[List, Tuple], - filters: Union[Dict[str, Union[str, int]], str, int] = None, + filters: Union[Dict[str, Union[str, int]], str, int, List[Union[List, str, int]]] = None, **kwargs, ): + # Clean up state before each query + self.tables = {} criterion = self.build_conditions(table, filters, **kwargs) + + if len(self.tables) > 1: + primary_table = self.tables[table] + del self.tables[table] + for table_object in self.tables.values(): + criterion = criterion.left_join(table_object).on(table_object.parent == primary_table.name) + if isinstance(fields, (list, tuple)): query = criterion.select(*kwargs.get("field_objects", fields)) diff --git a/frappe/database/sequence.py b/frappe/database/sequence.py index ede4689485..6a352d20d1 100644 --- a/frappe/database/sequence.py +++ b/frappe/database/sequence.py @@ -5,7 +5,7 @@ def create_sequence( doctype_name: str, *, slug: str = "_id_seq", - temporary=False, + temporary: bool = False, check_not_exists: bool = False, cycle: bool = False, cache: int = 0, @@ -51,7 +51,7 @@ def create_sequence( else: query += " cycle" - db.sql(query) + db.sql_ddl(query) return sequence_name diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 4c82fe8c73..ca0d1e2353 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -166,6 +166,8 @@ class Workspace: self.onboardings = {"items": self.get_onboardings()} + self.quick_lists = {"items": self.get_quick_lists()} + def _doctype_contains_a_record(self, name): exists = self.table_counts.get(name, False) @@ -284,6 +286,21 @@ class Workspace: return items + @handle_not_exist + def get_quick_lists(self): + items = [] + quick_lists = self.doc.quick_lists + + for item in quick_lists: + new_item = item.as_dict().copy() + + # Translate label + new_item["label"] = _(item.label) if item.label else _(item.document_type) + + items.append(new_item) + + return items + @handle_not_exist def get_onboardings(self): if self.onboarding_list: @@ -336,6 +353,7 @@ def get_desktop_page(page): "shortcuts": workspace.shortcuts, "cards": workspace.cards, "onboardings": workspace.onboardings, + "quick_lists": workspace.quick_lists, } except DoesNotExistError: frappe.log_error("Workspace Missing") @@ -452,6 +470,8 @@ def save_new_widget(doc, page, blocks, new_widgets): doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts")) if widgets.shortcut: doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts")) + if widgets.quick_list: + doc.quick_lists.extend(new_widget(widgets.quick_list, "Workspace Quick List", "quick_lists")) if widgets.card: doc.build_links_table_from_card(widgets.card) @@ -481,12 +501,12 @@ def save_new_widget(doc, page, blocks, new_widgets): def clean_up(original_page, blocks): page_widgets = {} - for wid in ["shortcut", "card", "chart"]: + for wid in ["shortcut", "card", "chart", "quick_list"]: # get list of widget's name from blocks page_widgets[wid] = [x["data"][wid + "_name"] for x in loads(blocks) if x["type"] == wid] - # shortcut & chart cleanup - for wid in ["shortcut", "chart"]: + # shortcut, chart & quick_list cleanup + for wid in ["shortcut", "chart", "quick_list"]: updated_widgets = [] original_page.get(wid + "s").reverse() diff --git a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py index 29c1b6ee7d..155a71a9b4 100644 --- a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py +++ b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py @@ -5,7 +5,6 @@ import os import frappe -from frappe import _ from frappe.model.document import Document from frappe.modules import get_module_path, scrub from frappe.modules.export_file import export_to_files diff --git a/frappe/desk/doctype/event/event.json b/frappe/desk/doctype/event/event.json index 2f67c36fc0..bce3b1e65a 100644 --- a/frappe/desk/doctype/event/event.json +++ b/frappe/desk/doctype/event/event.json @@ -277,7 +277,7 @@ "icon": "fa fa-calendar", "idx": 1, "links": [], - "modified": "2021-11-18 05:06:24.881742", + "modified": "2022-05-12 05:43:27.935510", "modified_by": "Administrator", "module": "Desk", "name": "Event", @@ -312,6 +312,7 @@ "sender_field": "sender", "sort_field": "modified", "sort_order": "DESC", + "states": [], "subject_field": "subject", "title_field": "subject", "track_changes": 1, diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index 531cc69c57..6fdc95d3d0 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -18,7 +18,6 @@ from frappe.utils import ( cstr, date_diff, format_datetime, - get_datetime, get_datetime_str, getdate, now_datetime, diff --git a/frappe/desk/doctype/kanban_board/test_kanban_board.py b/frappe/desk/doctype/kanban_board/test_kanban_board.py index 179e6c71e5..d4504bf9d8 100644 --- a/frappe/desk/doctype/kanban_board/test_kanban_board.py +++ b/frappe/desk/doctype/kanban_board/test_kanban_board.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Kanban Board') diff --git a/frappe/desk/doctype/kanban_board_column/kanban_board_column.py b/frappe/desk/doctype/kanban_board_column/kanban_board_column.py index 8a1f839c98..d905369a0b 100644 --- a/frappe/desk/doctype/kanban_board_column/kanban_board_column.py +++ b/frappe/desk/doctype/kanban_board_column/kanban_board_column.py @@ -2,7 +2,6 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/list_filter/list_filter.py b/frappe/desk/doctype/list_filter/list_filter.py index ac434a760a..a5ba12df6a 100644 --- a/frappe/desk/doctype/list_filter/list_filter.py +++ b/frappe/desk/doctype/list_filter/list_filter.py @@ -2,9 +2,6 @@ # Copyright (c) 2018, Frappe Technologies and contributors # License: MIT. See LICENSE -import json - -import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/note_seen_by/note_seen_by.py b/frappe/desk/doctype/note_seen_by/note_seen_by.py index 7b87cf13b2..7dde133e6d 100644 --- a/frappe/desk/doctype/note_seen_by/note_seen_by.py +++ b/frappe/desk/doctype/note_seen_by/note_seen_by.py @@ -2,7 +2,6 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index 9e7ed7d9fe..c4a082ff11 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -7,7 +7,6 @@ from frappe import _ from frappe.desk.doctype.notification_settings.notification_settings import ( is_email_notifications_enabled_for_type, is_notifications_enabled, - set_seen_value, ) from frappe.model.document import Document diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index fa8b81f5fd..032de9de4e 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -26,6 +26,8 @@ "shortcuts", "tab_break_18", "links", + "quick_lists_tab", + "quick_lists", "roles_tab", "roles" ], @@ -155,11 +157,22 @@ "fieldname": "roles_tab", "fieldtype": "Tab Break", "label": "Roles" + }, + { + "fieldname": "quick_lists_tab", + "fieldtype": "Tab Break", + "label": "Quick Lists" + }, + { + "fieldname": "quick_lists", + "fieldtype": "Table", + "label": "Quick Lists", + "options": "Workspace Quick List" } ], "in_create": 1, "links": [], - "modified": "2022-01-27 12:06:13.111743", + "modified": "2022-05-12 13:00:03.925605", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", @@ -189,5 +202,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/frappe/desk/doctype/workspace_quick_list/__init__.py b/frappe/desk/doctype/workspace_quick_list/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/workspace_quick_list/workspace_quick_list.json b/frappe/desk/doctype/workspace_quick_list/workspace_quick_list.json new file mode 100644 index 0000000000..1542ebe03c --- /dev/null +++ b/frappe/desk/doctype/workspace_quick_list/workspace_quick_list.json @@ -0,0 +1,60 @@ +{ + "actions": [], + "creation": "2022-05-12 12:58:41.824496", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "column_break_1", + "label", + "section_break_4", + "quick_list_filter" + ], + "fields": [ + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "DocType", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break" + }, + { + "fieldname": "quick_list_filter", + "fieldtype": "Code", + "in_list_view": 1, + "label": "Quick List Filter", + "options": "JSON" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-05-12 13:48:40.617623", + "modified_by": "Administrator", + "module": "Desk", + "name": "Workspace Quick List", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/workspace_quick_list/workspace_quick_list.py b/frappe/desk/doctype/workspace_quick_list/workspace_quick_list.py new file mode 100644 index 0000000000..9f26424115 --- /dev/null +++ b/frappe/desk/doctype/workspace_quick_list/workspace_quick_list.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class WorkspaceQuickList(Document): + pass diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 75cd403aac..5a6004652e 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -2,7 +2,7 @@ # License: MIT. See LICENSE import json -from typing import Dict, List, Union +from typing import List, Union from urllib.parse import quote import frappe diff --git a/frappe/desk/link_preview.py b/frappe/desk/link_preview.py index 374a151505..7778d9e373 100644 --- a/frappe/desk/link_preview.py +++ b/frappe/desk/link_preview.py @@ -1,5 +1,3 @@ -import json - import frappe from frappe.model import no_value_fields, table_fields diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index e92a7492ce..2a987f5539 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -249,9 +249,9 @@ def get_open_count(doctype, name, items=None): if frappe.flags.in_migrate or frappe.flags.in_install: return {"count": []} - frappe.has_permission(doc=frappe.get_doc(doctype, name), throw=True) - - meta = frappe.get_meta(doctype) + doc = frappe.get_doc(doctype, name) + doc.check_permission() + meta = doc.meta links = meta.get_dashboard_data() # compile all items in a list @@ -266,7 +266,6 @@ def get_open_count(doctype, name, items=None): out = [] for d in items: if d in links.get("internal_links", {}): - # internal link continue filters = get_filters_for(d) diff --git a/frappe/desk/page/setup_wizard/install_fixtures.py b/frappe/desk/page/setup_wizard/install_fixtures.py index 18a519f87f..91ea386948 100644 --- a/frappe/desk/page/setup_wizard/install_fixtures.py +++ b/frappe/desk/page/setup_wizard/install_fixtures.py @@ -2,7 +2,6 @@ # License: MIT. See LICENSE import frappe -from frappe import _ from frappe.desk.doctype.global_search_settings.global_search_settings import ( update_global_search_doctypes, ) diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index f85d24704f..3f849bbcaa 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -269,7 +269,6 @@ def add_all_roles_to(name): def disable_future_access(): frappe.db.set_default("desktop:home_page", "workspace") frappe.db.set_value("System Settings", "System Settings", "setup_complete", 1) - frappe.db.set_value("System Settings", "System Settings", "is_first_startup", 1) # Enable onboarding after install frappe.db.set_value("System Settings", "System Settings", "enable_onboarding", 1) @@ -334,11 +333,6 @@ def load_user_details(): } -@frappe.whitelist() -def reset_is_first_startup(): - frappe.db.set_value("System Settings", "System Settings", "is_first_startup", 0) - - def prettify_args(args): # remove attachments for key, val in args.items(): diff --git a/frappe/desk/search.py b/frappe/desk/search.py index eb1a2e82ba..639077da5e 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -348,7 +348,7 @@ def get_names_for_mentions(search_term): def get_users_for_mentions(): - return frappe.get_all( + return frappe.get_list( "User", fields=["name as id", "full_name as value"], filters={ @@ -361,7 +361,7 @@ def get_users_for_mentions(): def get_user_groups(): - return frappe.get_all( + return frappe.get_list( "User Group", fields=["name as id", "name as value"], update={"is_group": True} ) diff --git a/frappe/email/doctype/email_flag_queue/email_flag_queue.py b/frappe/email/doctype/email_flag_queue/email_flag_queue.py index 1b29d2f9d8..b7ca48faf0 100644 --- a/frappe/email/doctype/email_flag_queue/email_flag_queue.py +++ b/frappe/email/doctype/email_flag_queue/email_flag_queue.py @@ -2,7 +2,6 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py b/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py index f52aeb61fa..11571da7d9 100644 --- a/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py +++ b/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Email Flag Queue') diff --git a/frappe/email/doctype/email_group/test_email_group.py b/frappe/email/doctype/email_group/test_email_group.py index 4013c17d93..96032d35cc 100644 --- a/frappe/email/doctype/email_group/test_email_group.py +++ b/frappe/email/doctype/email_group/test_email_group.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Email Group') diff --git a/frappe/email/doctype/email_group_member/test_email_group_member.py b/frappe/email/doctype/email_group_member/test_email_group_member.py index 562b0327f2..ae608a4e15 100644 --- a/frappe/email/doctype/email_group_member/test_email_group_member.py +++ b/frappe/email/doctype/email_group_member/test_email_group_member.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Email Group Member') diff --git a/frappe/email/doctype/email_queue/test_email_queue.py b/frappe/email/doctype/email_queue/test_email_queue.py index b90390916a..96c566a041 100644 --- a/frappe/email/doctype/email_queue/test_email_queue.py +++ b/frappe/email/doctype/email_queue/test_email_queue.py @@ -1,12 +1,41 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE -import unittest import frappe - -# test_records = frappe.get_test_records('Email Queue') +from frappe.email.queue import clear_outbox +from frappe.tests.utils import FrappeTestCase -class TestEmailQueue(unittest.TestCase): - pass +class TestEmailQueue(FrappeTestCase): + def test_email_queue_deletion_based_on_modified_date(self): + old_record = frappe.get_doc( + { + "doctype": "Email Queue", + "sender": "Test ", + "show_as_cc": "", + "message": "Test message", + "status": "Sent", + "priority": 1, + "recipients": [ + { + "recipient": "test_auth@test.com", + } + ], + } + ).insert() + + old_record.modified = "2010-01-01 00:00:01" + old_record.recipients[0].modified = old_record.modified + old_record.db_update_all() + + new_record = frappe.copy_doc(old_record) + new_record.insert() + + clear_outbox() + + self.assertFalse(frappe.db.exists("Email Queue", old_record.name)) + self.assertFalse(frappe.db.exists("Email Queue Recipient", {"parent": old_record.name})) + + self.assertTrue(frappe.db.exists("Email Queue", new_record.name)) + self.assertTrue(frappe.db.exists("Email Queue Recipient", {"parent": new_record.name})) diff --git a/frappe/email/doctype/email_rule/email_rule.py b/frappe/email/doctype/email_rule/email_rule.py index f603aed77e..5075024b73 100644 --- a/frappe/email/doctype/email_rule/email_rule.py +++ b/frappe/email/doctype/email_rule/email_rule.py @@ -2,7 +2,6 @@ # Copyright (c) 2017, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/email_rule/test_email_rule.py b/frappe/email/doctype/email_rule/test_email_rule.py index 2cea421bd6..7b7a9a5c23 100644 --- a/frappe/email/doctype/email_rule/test_email_rule.py +++ b/frappe/email/doctype/email_rule/test_email_rule.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - class TestEmailRule(unittest.TestCase): pass diff --git a/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py b/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py index 7f9173d0b0..0133c6c4b5 100644 --- a/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py +++ b/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Email Unsubscribe') diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index b04ad4db40..18ca440738 100644 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -237,7 +237,7 @@ def confirmed_unsubscribe(email, group): @frappe.whitelist(allow_guest=True) -def subscribe(email, email_group=_("Website")): +def subscribe(email, email_group=_("Website")): # noqa """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email.""" # build subscription confirmation URL @@ -282,7 +282,7 @@ def subscribe(email, email_group=_("Website")): @frappe.whitelist(allow_guest=True) -def confirm_subscription(email, email_group=_("Website")): +def confirm_subscription(email, email_group=_("Website")): # noqa """API endpoint to confirm email subscription. This endpoint is called when user clicks on the link sent to their mail. """ diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 81702f3a09..9ec61194ef 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -7,7 +7,6 @@ from typing import Union from unittest.mock import MagicMock, PropertyMock, patch import frappe -from frappe.desk.form.load import run_onload from frappe.email.doctype.newsletter.exceptions import ( NewsletterAlreadySentError, NoRecipientFoundError, diff --git a/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py b/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py index b7a00ac7d2..9fe1364d11 100644 --- a/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py +++ b/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py @@ -2,7 +2,6 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/notification_recipient/notification_recipient.py b/frappe/email/doctype/notification_recipient/notification_recipient.py index 9de15f46c0..1785590e93 100644 --- a/frappe/email/doctype/notification_recipient/notification_recipient.py +++ b/frappe/email/doctype/notification_recipient/notification_recipient.py @@ -2,7 +2,6 @@ # Copyright (c) 2018, Frappe Technologies and contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/unhandled_email/test_unhandled_email.py b/frappe/email/doctype/unhandled_email/test_unhandled_email.py index 694b3e03a6..1485f3bbaa 100644 --- a/frappe/email/doctype/unhandled_email/test_unhandled_email.py +++ b/frappe/email/doctype/unhandled_email/test_unhandled_email.py @@ -3,8 +3,6 @@ # License: MIT. See LICENSE import unittest -import frappe - # test_records = frappe.get_test_records('Unhandled Emails') diff --git a/frappe/email/queue.py b/frappe/email/queue.py index b92dea3e65..07731417d8 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -195,18 +195,24 @@ def clear_outbox(days: int = None) -> None: Note: Used separate query to avoid deadlock """ days = days or 31 - email_queue = DocType("Email Queue") + email_queue = frappe.qb.DocType("Email Queue") + email_recipient = frappe.qb.DocType("Email Queue Recipient") - email_queues = ( + # Delete queue table + ( frappe.qb.from_(email_queue) - .select(email_queue.name) - .where(email_queue.modified < (Now() - Interval(days=days))) - .run(pluck=True) - ) + .delete() + .where((email_queue.modified < (Now() - Interval(days=days)))) + ).run() - if email_queues: - frappe.db.delete("Email Queue", {"name": ("in", email_queues)}) - frappe.db.delete("Email Queue Recipient", {"parent": ("in", email_queues)}) + # delete child tables, note that this has potential to leave some orphan + # child table behind if modified time was later than parent doc (rare). + # But it's safe since child table doesn't contain links. + ( + frappe.qb.from_(email_recipient) + .delete() + .where((email_recipient.modified < (Now() - Interval(days=days)))) + ).run() def set_expiry_for_email_queue(): diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 1c91356506..d621fd2bba 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -1,15 +1,13 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import email.utils import smtplib -import sys import _socket import frappe from frappe import _ -from frappe.utils import cint, cstr, parse_addr +from frappe.utils import cint, cstr CONNECTION_FAILED = _("Could not connect to outgoing email server") AUTH_ERROR_TITLE = _("Invalid Credentials") diff --git a/frappe/geo/doctype/country/country.py b/frappe/geo/doctype/country/country.py index b3ba1b7127..c6edb38e94 100644 --- a/frappe/geo/doctype/country/country.py +++ b/frappe/geo/doctype/country/country.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe from frappe.model.document import Document diff --git a/frappe/geo/doctype/currency/currency.py b/frappe/geo/doctype/currency/currency.py index dd5df57bab..93bcc063f8 100644 --- a/frappe/geo/doctype/currency/currency.py +++ b/frappe/geo/doctype/currency/currency.py @@ -2,7 +2,6 @@ # License: MIT. See LICENSE import frappe -from frappe import _, throw from frappe.model.document import Document diff --git a/frappe/handler.py b/frappe/handler.py index 7b010eb716..44249323ef 100644 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -25,6 +25,7 @@ ALLOWED_MIMETYPES = ( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet", + "text/plain", ) @@ -202,7 +203,7 @@ def upload_file(): if not file_url and (frappe.session.user == "Guest" or (user and not user.has_desk_access())): filetype = guess_type(filename)[0] if filetype not in ALLOWED_MIMETYPES: - frappe.throw(_("You can only upload JPG, PNG, PDF, or Microsoft documents.")) + frappe.throw(_("You can only upload JPG, PNG, PDF, TXT or Microsoft documents.")) if method: method = frappe.get_attr(method) diff --git a/frappe/hooks.py b/frappe/hooks.py index f7a67dc7ec..ee8417a3ec 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -4,8 +4,6 @@ app_name = "frappe" app_title = "Frappe Framework" app_publisher = "Frappe Technologies" app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node" -app_icon = "octicon octicon-circuit-board" -app_color = "orange" source_link = "https://github.com/frappe/frappe" app_license = "MIT" app_logo_url = "/assets/frappe/images/frappe-framework-logo.svg" diff --git a/frappe/installer.py b/frappe/installer.py index 634d6287f8..5cd46e618d 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -7,6 +7,8 @@ import sys from collections import OrderedDict from typing import Dict, List, Tuple +import click + import frappe from frappe.defaults import _clear_cache from frappe.utils import is_git_url @@ -80,7 +82,13 @@ def _new_site( ) for app in apps_to_install: - install_app(app, verbose=verbose, set_as_patched=not source_sql) + # NOTE: not using force here for 2 reasons: + # 1. It's not really needed here as we've freshly installed a new db + # 2. If someone uses a sql file to do restore and that file already had + # installed_apps then it might cause problems as that sql file can be of any previous version(s) + # which might be incompatible with the current version and using force might cause problems. + # Example: the DocType DocType might not have `migration_hash` column which will cause failure in the restore. + install_app(app, verbose=verbose, set_as_patched=not source_sql, force=False) os.remove(installing) @@ -226,7 +234,7 @@ def parse_app_name(name: str) -> str: return repo -def install_app(name, verbose=False, set_as_patched=True): +def install_app(name, verbose=False, set_as_patched=True, force=False): from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.model.sync import sync_for from frappe.modules.utils import sync_customizations @@ -243,7 +251,7 @@ def install_app(name, verbose=False, set_as_patched=True): if app_hooks.required_apps: for app in app_hooks.required_apps: required_app = parse_app_name(app) - install_app(required_app, verbose=verbose) + install_app(required_app, verbose=verbose, force=force) frappe.flags.in_install = name frappe.clear_cache() @@ -251,8 +259,8 @@ def install_app(name, verbose=False, set_as_patched=True): if name not in frappe.get_all_apps(): raise Exception("App not in apps.txt") - if name in installed_apps: - frappe.msgprint(frappe._("App {0} already installed").format(name)) + if not force and name in installed_apps: + click.secho(f"App {name} already installed", fg="yellow") return print("\nInstalling {0}...".format(name)) @@ -266,9 +274,9 @@ def install_app(name, verbose=False, set_as_patched=True): return if name != "frappe": - add_module_defs(name) + add_module_defs(name, ignore_if_duplicate=force) - sync_for(name, force=True, reset_permissions=True) + sync_for(name, force=force, reset_permissions=True) add_to_installed_apps(name) @@ -315,7 +323,6 @@ def remove_from_installed_apps(app_name): def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False): """Remove app and all linked to the app's module with the app from a site.""" - import click site = frappe.local.site app_hooks = frappe.get_hooks(app_name=app_name) @@ -573,13 +580,13 @@ def make_site_dirs(): os.makedirs(path, exist_ok=True) -def add_module_defs(app): +def add_module_defs(app, ignore_if_duplicate=False): modules = frappe.get_module_list(app) for module in modules: d = frappe.new_doc("Module Def") d.app_name = app d.module_name = module - d.insert(ignore_permissions=True, ignore_if_duplicate=True) + d.insert(ignore_permissions=True, ignore_if_duplicate=ignore_if_duplicate) def remove_missing_apps(): @@ -752,11 +759,9 @@ def partial_restore(sql_file_path, verbose=False): elif frappe.conf.db_type == "postgres": import warnings - from click import style - from frappe.database.postgres.setup_db import import_db_from_sql - warn = style( + warn = click.style( "Delete the tables you want to restore manually before attempting" " partial restore operation for PostreSQL databases", fg="yellow", @@ -798,8 +803,6 @@ def validate_database_sql(path, _raise=True): error_message = "Table `tabDefaultValue` not found in file." if error_message: - import click - click.secho(error_message, fg="red") if _raise and (missing_table or empty_file): diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index acb63b5bfa..6a7f7fbd54 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -920,12 +920,12 @@ def cast_name(column: str) -> str: 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): + if re.search(r"locate\([^,]+,\s*[`\"]?name[`\"]?\s*\)", **kwargs): return re.sub( - r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\)", r"locate(\1, cast(\2 as varchar))", **kwargs + r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\s*\)", r"locate(\1, cast(\2 as varchar))", **kwargs ) - elif match := re.search(r"(strpos|ifnull|coalesce)\(\s*([`\"]?name[`\"]?)\s*,", **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) @@ -982,11 +982,11 @@ def is_parent_only_filter(doctype, filters): only_parent_doctype = True if isinstance(filters, list): - for flt in filters: - if doctype not in flt: + for filter in filters: + if doctype not in filter: only_parent_doctype = False - if "Between" in flt: - flt[3] = get_between_date_filter(flt[3]) + if "Between" in filter: + filter[3] = get_between_date_filter(flt[3]) return only_parent_doctype @@ -1041,7 +1041,7 @@ def get_additional_filter_field(additional_filters_config, f, value): return f -def get_date_range(operator, value): +def get_date_range(operator: str, value: str): timespan_map = { "1 week": "week", "1 month": "month", @@ -1054,7 +1054,10 @@ def get_date_range(operator, value): "next": "next", } - timespan = period_map[operator] + " " + timespan_map[value] if operator != "timespan" else value + if operator != "timespan": + timespan = f"{period_map[operator]} {timespan_map[value]}" + else: + timespan = value return get_timespan_date_range(timespan) diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 733e8ca367..2eccc1e717 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -106,7 +106,7 @@ def delete_doc( ): try: delete_controllers(name, doc.module) - except (FileNotFoundError, OSError, KeyError): + except (OSError, KeyError): # in case a doctype doesnt have any controller code nor any app and module pass diff --git a/frappe/model/meta.py b/frappe/model/meta.py index aeb12136ef..4f7dc01ea4 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -774,7 +774,10 @@ def trim_table(doctype, dry_run=True): ignore_fields = default_fields + optional_fields + child_table_fields columns = frappe.db.get_table_columns(doctype) fields = frappe.get_meta(doctype, cached=False).get_fieldnames_with_value() - is_internal = lambda f: f not in ignore_fields and not f.startswith("_") + + def is_internal(field): + return field not in ignore_fields and not field.startswith("_") + columns_to_remove = [f for f in list(set(columns) - set(fields)) if is_internal(f)] DROPPED_COLUMNS = columns_to_remove[:] diff --git a/frappe/model/sync.py b/frappe/model/sync.py index a56d1f267f..4c535b2811 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -80,6 +80,7 @@ def sync_for(app_name, force=0, reset_permissions=False): "workspace_link", "workspace_chart", "workspace_shortcut", + "workspace_quick_list", "workspace", ]: files.append(os.path.join(FRAPPE_PATH, "desk", "doctype", desk_module, f"{desk_module}.json")) diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 00483bf6a5..d39f98f966 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -144,7 +144,6 @@ def import_file_by_path( import_doc( docdict=doc, - force=force, data_import=data_import, pre_process=pre_process, ignore_version=ignore_version, @@ -203,7 +202,6 @@ def update_modified(original_modified, doc): def import_doc( docdict, - force=False, data_import=False, pre_process=None, ignore_version=None, diff --git a/frappe/patches.txt b/frappe/patches.txt index 881282b872..6c46d5dcd9 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -111,7 +111,7 @@ frappe.patches.v11_0.make_all_prepared_report_attachments_private #2019-11-26 frappe.patches.v12_0.setup_email_linking frappe.patches.v12_0.change_existing_dashboard_chart_filters frappe.patches.v12_0.set_correct_assign_value_in_docs #2020-07-13 -execute:frappe.delete_doc("Test Runner") +execute:frappe.delete_doc('DocType', 'Test Runner') # 2022-05-19 execute:frappe.delete_doc_if_exists('DocType', 'Google Maps Settings') execute:frappe.db.set_default('desktop:home_page', 'workspace') execute:frappe.delete_doc_if_exists('DocType', 'GSuite Settings') @@ -189,6 +189,8 @@ frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.transform_todo_schema frappe.patches.v14_0.remove_post_and_post_comment frappe.patches.v14_0.reset_creation_datetime +frappe.patches.v14_0.remove_is_first_startup +frappe.patches.v14_0.reload_workspace_child_tables [post_model_sync] frappe.patches.v14_0.drop_data_import_legacy diff --git a/frappe/patches/v14_0/reload_workspace_child_tables.py b/frappe/patches/v14_0/reload_workspace_child_tables.py new file mode 100644 index 0000000000..c22774d94c --- /dev/null +++ b/frappe/patches/v14_0/reload_workspace_child_tables.py @@ -0,0 +1,13 @@ +import frappe + + +def execute(): + child_tables = frappe.get_all( + "DocField", + pluck="options", + filters={"fieldtype": ["in", frappe.model.table_fields], "parent": "Workspace"}, + ) + + for child_table in child_tables: + if child_table != "Has Role": + frappe.reload_doc("desk", "doctype", child_table, force=True) diff --git a/frappe/patches/v14_0/remove_db_aggregation.py b/frappe/patches/v14_0/remove_db_aggregation.py index 6dc34a784b..4b0a58c2d6 100644 --- a/frappe/patches/v14_0/remove_db_aggregation.py +++ b/frappe/patches/v14_0/remove_db_aggregation.py @@ -30,6 +30,6 @@ def execute(): name, script = server_script["name"], server_script["script"] for agg in ["avg", "max", "min", "sum"]: - script = re.sub(f"frappe.db.{agg}\(", f"frappe.qb.{agg}(", script) + script = re.sub(f"frappe.db.{agg}\\(", f"frappe.qb.{agg}(", script) frappe.db.update("Server Script", name, "script", script) diff --git a/frappe/patches/v14_0/remove_is_first_startup.py b/frappe/patches/v14_0/remove_is_first_startup.py new file mode 100644 index 0000000000..cae38ce2ab --- /dev/null +++ b/frappe/patches/v14_0/remove_is_first_startup.py @@ -0,0 +1,8 @@ +import frappe + + +def execute(): + singles = frappe.qb.Table("tabSingles") + frappe.qb.from_(singles).delete().where( + (singles.doctype == "System Settings") & (singles.field == "is_first_startup") + ).run() diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 72e8010605..a8cbe020f3 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -511,7 +511,8 @@ frappe.Application = class Application { // "version": "12.2.0" // }]; - if (!Array.isArray(change_log) || !change_log.length || window.Cypress) { + if (!Array.isArray(change_log) || !change_log.length || + window.Cypress || cint(frappe.boot.sysdefaults.disable_change_log_notification)) { return; } diff --git a/frappe/public/js/frappe/form/controls/base_input.js b/frappe/public/js/frappe/form/controls/base_input.js index 5a5af389ee..5545a453e9 100644 --- a/frappe/public/js/frappe/form/controls/base_input.js +++ b/frappe/public/js/frappe/form/controls/base_input.js @@ -65,7 +65,11 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control }; var update_input = function() { - me.set_input(me.value); + if (me.doctype && me.docname) { + me.set_input(me.value); + } else { + me.set_input(me.value || null); + } }; if (me.disp_status != "None") { @@ -155,6 +159,13 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control this.$wrapper.find(".help-box").html(""); } set_mandatory(value) { + // do not set has-error class on form load + if (this.frm && this.frm.cscript && this.frm.cscript.is_onload) return; + + // do not set has-error class while dialog is rendered + // set has-error if dialog primary button is clicked + if (this.layout && this.layout.is_dialog && !this.layout.primary_action_fulfilled) return; + this.$wrapper.toggleClass("has-error", Boolean(this.df.reqd && is_null(value))); } set_invalid () { diff --git a/frappe/public/js/frappe/form/controls/date.js b/frappe/public/js/frappe/form/controls/date.js index cf7eb1f786..a8b82604c9 100644 --- a/frappe/public/js/frappe/form/controls/date.js +++ b/frappe/public/js/frappe/form/controls/date.js @@ -139,13 +139,13 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat }); } parse(value) { - if(value) { - return frappe.datetime.user_to_str(value); + if (value) { + return frappe.datetime.user_to_str(value, false, true); } } format_for_input(value) { - if(value) { - return frappe.datetime.str_to_user(value); + if (value) { + return frappe.datetime.str_to_user(value, false, true); } return ""; } diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index d1a06a6ac6..9b10465d7b 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -45,8 +45,6 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co } format_for_input(value) { if (!value) return ""; - - return frappe.datetime.str_to_user(value, false); } set_description() { diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 2081a301c3..ebaf36fe4e 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -36,6 +36,9 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat if(!me.$input.val()) { me.$input.val("").trigger("input"); + + // hide link arrow to doctype if none is set + me.$link.toggle(false); } }, 500); }); @@ -78,6 +81,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } this.set_link_title(value); } + get_translated(value) { + return this.is_translatable() ? __(value) : value; + } + is_translatable() { + return in_list(frappe.boot?.translatable_doctypes || [], this.get_options()); + } set_link_title(value) { let doctype = this.get_options(); @@ -89,25 +98,32 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat link_title = frappe.utils .fetch_link_title(doctype, value) .then(link_title => { - this.set_input_value(link_title); - this.title_value_map[link_title] = value; + this.translate_and_set_input_value(link_title, value); }); } else { - this.set_input_value(link_title); - this.title_value_map[link_title] = value; + this.translate_and_set_input_value(link_title, value); } } else { - this.set_input_value(value); + this.translate_and_set_input_value(value, value) } } + translate_and_set_input_value(link_title, value) { + let translated_link_text = this.get_translated(link_title) + this.title_value_map[translated_link_text] = value; + + this.set_input_value(translated_link_text); + } parse_validate_and_set_in_model(value, e, label) { if (this.parse) value = this.parse(value, label); if (label) { - this.label = label; + this.label = this.get_translated(label); frappe.utils.add_link_title(this.df.options, value, label); } - return this.validate_and_set_in_model(value, e); + return this.validate_and_set_in_model(value, e, true); + } + parse(value) { + return strip_html(value); } get_input_value() { if (this.$input) { @@ -164,7 +180,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat return false; } setup_awesomeplete() { - var me = this; + let me = this; this.$input.cache = {}; @@ -173,14 +189,14 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat maxItems: 99, autoFirst: true, list: [], - replace: function (suggestion) { + replace: function (item) { // Override Awesomeplete replace function as it is used to set the input value // https://github.com/LeaVerou/awesomplete/issues/17104#issuecomment-359185403 - this.input.value = suggestion.label || suggestion.value; + this.input.value = me.get_translated(item.label || item.value); }, data: function (item) { return { - label: item.label || item.value, + label: me.get_translated(item.label || item.value), value: item.value }; }, @@ -188,11 +204,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat return true; }, item: function (item) { - var d = this.get_item(item.value); + let d = this.get_item(item.value); if(!d.label) { d.label = d.value; } - var _label = (me.translate_values) ? __(d.label) : d.label; - var html = d.html || "" + _label + ""; + let _label = me.get_translated(d.label); + let html = d.html || "" + _label + ""; if(d.description && d.value!==d.description) { html += '
' + __(d.description) + ''; } @@ -296,18 +312,30 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } let value = me.get_input_value(); let label = me.get_label_value(); + let last_value = me.last_value || ""; + let last_label = me.label || ""; - if (value !== me.last_value || me.label !== label) { + if (value !== last_value || label !== last_label) { me.parse_validate_and_set_in_model(value, null, label); } }); this.$input.on("awesomplete-open", () => { this.autocomplete_open = true; + + if (!me.get_label_value()) { + // hide link arrow to doctype if none is set + me.$link.toggle(false); + } }); - this.$input.on("awesomplete-close", () => { + this.$input.on("awesomplete-close", (e) => { this.autocomplete_open = false; + + if (!me.get_label_value()) { + // hide link arrow to doctype if none is set + me.$link.toggle(false); + } }); this.$input.on("awesomplete-select", function(e) { @@ -317,7 +345,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat me.autocomplete_open = false; // prevent selection on tab - var TABKEY = 9; + let TABKEY = 9; if (e.keyCode === TABKEY) { e.preventDefault(); me.awesomplete.close(); @@ -347,6 +375,24 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat me.$input.val(""); } }); + + this.$input.on("focus", function () { + if (!frappe.boot.translated_search_doctypes.includes(me.df.options)) { + me.show_untranslated(); + } + }); + + this.$input.keydown((e) => { + let BACKSPACE = 8; + if (e.keyCode === BACKSPACE && !frappe.boot.translated_search_doctypes.includes(me.df.options)) { + me.show_untranslated(); + } + }); + } + + show_untranslated() { + let value = this.get_input_value(); + this.is_translatable() && this.set_input_value(value); } merge_duplicates(results) { @@ -590,5 +636,4 @@ if (Awesomplete) { return item.value === value; }); }; -} - +} \ No newline at end of file diff --git a/frappe/public/js/frappe/form/controls/multiselect.js b/frappe/public/js/frappe/form/controls/multiselect.js index fe2b9e200f..8b577fa601 100644 --- a/frappe/public/js/frappe/form/controls/multiselect.js +++ b/frappe/public/js/frappe/form/controls/multiselect.js @@ -78,4 +78,26 @@ frappe.ui.form.ControlMultiSelect = class ControlMultiSelect extends frappe.ui.f if(data) data.filter(d => !values.includes(d)); return data; } + + validate(value) { + if (this.df.ignore_validation) { + return value || ''; + } + + let valid_values = this.awesomplete._list.map(d => d.value); + + if (!valid_values.length) { + return value; + } + + // remove last comma and convert into array + let value_arr = value.replace(/,\s*$/, "").split(','); + let include_all_values = value_arr.every(val => valid_values.includes(val)); + + if (include_all_values) { + return value; + } else { + return ''; + } + } }; diff --git a/frappe/public/js/frappe/form/controls/select.js b/frappe/public/js/frappe/form/controls/select.js index 7df2bbfbaa..363c0d957c 100644 --- a/frappe/public/js/frappe/form/controls/select.js +++ b/frappe/public/js/frappe/form/controls/select.js @@ -73,7 +73,8 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro if(this.$input) { var selected = this.$input.find(":selected").val(); - this.$input.empty().add_options(options || []); + this.$input.empty(); + frappe.ui.form.add_options(this.$input, options || []); if(value===undefined && selected) { this.$input.val(selected); @@ -101,39 +102,47 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro } }; +frappe.ui.form.add_options = function(input, options_list) { + let $select = $(input); + if (!Array.isArray(options_list)) { + return $select; + } + // create options + for(var i=0, j=options_list.length; i').html(cstr(label)) + .attr('value', value) + .prop('disabled', is_disabled) + .prop('selected', is_selected) + .appendTo($select.get(0)); + } + // select the first option + $select.get(0).selectedIndex = 0; + $select.trigger('select-change'); + return $select; +}; + // add