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/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/folder_navigation.js b/cypress/integration/folder_navigation.js index cec7edb59f..bab14f5441 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -17,8 +17,8 @@ context('Folder Navigation', () => { //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.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', () => { @@ -32,8 +32,8 @@ context('Folder Navigation', () => { //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.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,26 +46,31 @@ 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_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_modal_primary_button('Yes'); + cy.wait('@file_deleted'); }); it('Deleting Test Folder from the home', () => { @@ -74,6 +79,6 @@ context('Folder Navigation', () => { 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_modal_primary_button('Yes'); }); }); diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js index e87a0e5528..507a07ab1a 100644 --- a/cypress/integration/form_tour.js +++ b/cypress/integration/form_tour.js @@ -1,7 +1,7 @@ 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..ba707499c9 --- /dev/null +++ b/cypress/integration/workspace_blocks.js @@ -0,0 +1,150 @@ +context('Workspace Blocks', () => { + before(() => { + cy.login(); + cy.visit('/app'); + }); + + it('Create Test Page', () => { + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.doctype.workspace.workspace.new_page' + }).as('new_page'); + + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); + cy.fill_field('title', 'Test 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' + }, + { + doctype: 'ToDo', + description: 'Quick List ToDo 5', + status: 'Closed' + }, + { + doctype: 'ToDo', + description: 'Quick List ToDo 6', + status: 'Closed' + }, + { + doctype: 'ToDo', + description: 'Quick List ToDo 7', + status: 'Closed' + }, + { + doctype: 'ToDo', + description: 'Quick List ToDo 8', + status: 'Closed' + } + ]); + + 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.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('Status{enter}').blur(); + cy.get_open_dialog().find('select.input-with-feedback').select('Open'); + + 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', 'Open'); + + // test filter-list + cy.get('@todo-quick-list').find('.widget-control .filter-list').click(); + + cy.get_open_dialog().find('select.input-with-feedback').select('Closed'); + 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', 'Closed'); + + + // test refresh-list + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.desk.reportview.get' + }).as('refresh-list'); + + cy.get('@todo-quick-list').find('.widget-control .refresh-list').click(); + cy.wait('@refresh-list'); + + + // test add-new + cy.get('@todo-quick-list').find('.widget-control .add-new').click(); + cy.url().should('include', `/todo/new-todo-1`); + cy.go('back'); + + + // 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.go('back'); + + + // test see-all + cy.get('@todo-quick-list').find('.widget-footer .see-all').click(); + + cy.get('.standard-filter-section select[data-fieldname="status"]') + .invoke('val') + .should('eq', 'Open'); + cy.go('back'); + }); + +}); \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 95b5cbb670..7686626fea 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -341,7 +341,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/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/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index f0e80c2207..667d3ee135 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -152,8 +152,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/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..4e21f3bcb4 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -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/file/file.py b/frappe/core/doctype/file/file.py index 3547a03832..8ca0b9ea10 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -608,7 +608,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 +618,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/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/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 8436c24065..1dc5e55e04 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", @@ -72,7 +71,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": [ { @@ -104,14 +104,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", @@ -505,12 +497,18 @@ "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" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2022-04-21 09:11:35.218721", + "modified": "2022-05-09 18:53:35.218721", "modified_by": "Administrator", "module": "Core", "name": "System Settings", 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..e92fd50ea8 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) @@ -584,6 +577,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/database/database.py b/frappe/database/database.py index ca4b5a5310..1a03ac3889 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -1019,13 +1019,13 @@ 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("*")) + query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"), distinct=distinct) if filters: count = self.sql(query, debug=debug)[0][0] return count 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/query.py b/frappe/database/query.py index 136f5c86b6..b107759af0 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -4,10 +4,10 @@ from typing import Any, Dict, List, Tuple, Union import frappe from frappe import _ -from frappe.query_builder import Criterion, Field, Order +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 +17,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 +30,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 +43,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 +56,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 +69,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 +82,12 @@ 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 make_function(key: Any, value: Union[int, str]): @@ -135,11 +140,14 @@ OPERATOR_MAP = { "not like": not_like, "regex": func_regex, "between": func_between, + "is": func_is, } class Query: - def get_condition(self, table: str, **kwargs) -> frappe.qb: + tables: dict = {} + + def get_condition(self, table: Union[str, Table], **kwargs) -> frappe.qb: """Get initial table object Args: @@ -148,11 +156,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 @@ -217,8 +234,13 @@ class Query: 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 = OPERATOR_MAP[f[-2]] + 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) @@ -249,7 +271,7 @@ class Query: 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])) + conditions = conditions.where(_operator(Field(key), value[1])) else: _operator = OPERATOR_MAP[value[0]] conditions = conditions.where(_operator(Field(key), value[1])) @@ -293,10 +315,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/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/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/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/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/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..005b7e3741 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) 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 845ccee09a..4dc8c1b698 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -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_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 0f80371706..bea7e77bd1 100644 --- a/frappe/public/js/frappe/form/controls/date.js +++ b/frappe/public/js/frappe/form/controls/date.js @@ -137,13 +137,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/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