diff --git a/.eslintrc b/.eslintrc index cc7f555669..937f11586c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -148,6 +148,7 @@ "context": true, "before": true, "beforeEach": true, + "after": true, "qz": true, "localforage": true, "extend_cscript": true diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index f8ee3fa10b..aece5f543b 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -24,6 +24,8 @@ def docs_link_exists(body): parts = parsed_url.path.split('/') if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos: return True + if parsed_url.netloc in ["docs.erpnext.com", "frappeframework.com"]: + return True if __name__ == "__main__": diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 454cc89694..19a7c68e19 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -17,7 +17,7 @@ if [ "$TYPE" == "server" ]; then fi if [ "$DB" == "mariadb" ];then - sudo apt install mariadb-client-10.3 + sudo apt update && sudo apt install mariadb-client-10.3 mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'"; mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"; diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 510e7c7678..dba13f9358 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -12,4 +12,4 @@ jobs: - name: curl run: | apk add curl bash - curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Travis-API-Version: 3" -H "Authorization: token ${{ secrets.TRAVIS_CI_TOKEN }}" -d '{"request":{"branch":"master"}}' https://api.travis-ci.com/repo/frappe%2Ffrappe_docker/requests + curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}' diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 8758c4e273..c8294886a0 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -10,6 +10,7 @@ concurrency: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 60 name: Patch Test @@ -31,6 +32,12 @@ jobs: with: python-version: '3.9' + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: 14 + check-latest: true + - name: Check if build should be run id: check-build run: | @@ -106,16 +113,14 @@ jobs: source env/bin/activate cd apps/frappe/ git remote set-url upstream https://github.com/frappe/frappe.git - git fetch --all --tags - taglist=$(git tag --sort version:refname | grep -v "beta") - last_release=$(echo "$taglist" | tail -1 | cut -d . -f 1 | cut -c 2-) - - for version in $(seq 12 "$last_release") + for version in $(seq 12 13) do - last_tag=$(echo "$taglist" | grep "v$version" | tail -1) - echo "Updating to $last_tag" - git checkout -q -f "$last_tag" + echo "Updating to v$version" + branch_name="version-$version-hotfix" + git fetch --depth 1 upstream $branch_name:$branch_name + + git checkout -q -f $branch_name pip install -q -r requirements.txt bench --site test_site migrate done diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 588f357f26..4edf74ba71 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -14,6 +14,7 @@ concurrency: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 60 strategy: fail-fast: false @@ -128,4 +129,4 @@ jobs: fail_ci_if_error: true files: /home/runner/frappe-bench/sites/coverage.xml verbose: true - flags: server \ No newline at end of file + flags: server diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 78f379837b..895af5184e 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -13,6 +13,7 @@ concurrency: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 60 strategy: fail-fast: false diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index fcc53ba59c..cb502f68a7 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -13,6 +13,7 @@ concurrency: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 60 strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index c9dd8f38f3..7e3d178630 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dist/ frappe/docs/current frappe/public/dist .vscode +.vs node_modules .kdev4/ *.kdev4 diff --git a/CODEOWNERS b/CODEOWNERS index 69ca578b6c..f7d759c123 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,18 +3,18 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, -* @frappe/frappe-review-team -templates/ @surajshetty3416 -www/ @surajshetty3416 -integrations/ @leela -patches/ @surajshetty3416 @gavindsouza -email/ @leela -event_streaming/ @ruchamahabal -data_import* @netchampfaris -core/ @surajshetty3416 +* @frappe/frappe-review-team +templates/ @surajshetty3416 +www/ @surajshetty3416 +integrations/ @leela +patches/ @surajshetty3416 @gavindsouza +email/ @leela +event_streaming/ @ruchamahabal +data_import* @netchampfaris +core/ @surajshetty3416 database @gavindsouza model @gavindsouza -requirements.txt @gavindsouza -query_builder/ @gavindsouza -commands/ @gavindsouza +requirements.txt @gavindsouza +query_builder/ @gavindsouza +commands/ @gavindsouza workspace @shariquerik diff --git a/codecov.yml b/codecov.yml index a9f6df0296..bc59416d2f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -11,6 +11,15 @@ coverage: threshold: 0.5% flags: - server + patch: + default: false + server: + target: 85% + threshold: 0% + only_pulls: true + if_ci_failed: ignore + flags: + - server comment: layout: "diff, flags" diff --git a/cypress.json b/cypress.json index f2508ca66e..15f8f230fa 100644 --- a/cypress.json +++ b/cypress.json @@ -4,8 +4,12 @@ "adminPassword": "admin", "defaultCommandTimeout": 20000, "pageLoadTimeout": 15000, + "video": true, + "videoUploadOnPasses": false, "retries": { "runMode": 2, "openMode": 2 - } + }, + "integrationFolder": ".", + "testFiles": ["cypress/integration/*.js", "**/ui_test_*.js"] } diff --git a/cypress/fixtures/doctype_with_tab_break.js b/cypress/fixtures/doctype_with_tab_break.js index bc346e8fb8..74e5e6abba 100644 --- a/cypress/fixtures/doctype_with_tab_break.js +++ b/cypress/fixtures/doctype_with_tab_break.js @@ -30,11 +30,6 @@ export default { "link_doctype": "Contact", "link_fieldname": "user" }, - { - "group": "Profile", - "link_doctype": "Chat Profile", - "link_fieldname": "user" - }, ], modified_by: 'Administrator', module: 'Custom', diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js index 266d421e70..09629a344f 100644 --- a/cypress/integration/control_duration.js +++ b/cypress/integration/control_duration.js @@ -33,12 +33,13 @@ context('Control Duration', () => { cy.get('@dialog').then(dialog => { let value = dialog.get_value('duration'); expect(value).to.equal(3889800); + cy.hide_dialog(); }); }); it('should hide days or seconds according to duration options', () => { get_dialog_with_duration(1, 1).as('dialog'); - cy.get('.frappe-control[data-fieldname=duration] input').first().click(); + cy.get('.frappe-control[data-fieldname=duration] input').first(); cy.get('.duration-input[data-duration=days]').should('not.be.visible'); cy.get('.duration-input[data-duration=seconds]').should('not.be.visible'); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 2a81338c59..6d16769b37 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -49,19 +49,19 @@ context('Control Link', () => { it('should unset invalid value', () => { get_dialog_with_link().as('dialog'); - cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.get('.frappe-control[data-fieldname=link] input') .type('invalid value', { delay: 100 }) .blur(); - cy.wait('@get_value'); + cy.wait('@validate_link'); cy.get('.frappe-control[data-fieldname=link] input').should('have.value', ''); }); it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); - cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.get('@todos').then(todos => { @@ -69,7 +69,7 @@ context('Control Link', () => { cy.get('@input').focus(); cy.wait('@search_link'); cy.get('@input').type(todos[0]).blur(); - cy.wait('@get_value'); + cy.wait('@validate_link'); cy.get('@input').focus(); cy.findByTitle('Open Link') .should('be.visible') @@ -81,11 +81,11 @@ context('Control Link', () => { it('should fetch valid value', () => { cy.get('@todos').then(todos => { cy.visit(`/app/todo/${todos[0]}`); - cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input'); cy.get('@input').type('Administrator', {delay: 100}).blur(); - cy.wait('@get_value'); + cy.wait('@validate_link'); cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( 'contain', 'Administrator' ); diff --git a/cypress/integration/control_rating.js b/cypress/integration/control_rating.js index 592ed87004..15c11b352b 100644 --- a/cypress/integration/control_rating.js +++ b/cypress/integration/control_rating.js @@ -10,6 +10,7 @@ context('Control Rating', () => { fields: [{ 'fieldname': 'rate', 'fieldtype': 'Rating', + 'options': 7 }] }); } @@ -19,12 +20,13 @@ context('Control Rating', () => { cy.get('div.rating') .children('svg') + .find('.right-half') .first() .click() .should('have.class', 'star-click'); cy.get('@dialog').then(dialog => { var value = dialog.get_value('rate'); - expect(value).to.equal(1); + expect(value).to.equal(1/7); dialog.hide(); }); }); @@ -34,10 +36,21 @@ context('Control Rating', () => { cy.get('div.rating') .children('svg') + .find('.right-half') .first() .invoke('trigger', 'mouseenter') .should('have.class', 'star-hover') .invoke('trigger', 'mouseleave') .should('not.have.class', 'star-hover'); }); + + it('check number of stars in rating', () => { + get_dialog_with_rating(); + + cy.get('div.rating') + .first() + .children('svg') + .should('have.length', 7); + }); + }); diff --git a/cypress/integration/dashboard_chart.js b/cypress/integration/dashboard_chart.js new file mode 100644 index 0000000000..ae71fcda3a --- /dev/null +++ b/cypress/integration/dashboard_chart.js @@ -0,0 +1,22 @@ +context('Dashboard Chart', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + it('Check filter populate for child table doctype', () => { + cy.visit('/app/dashboard-chart/new-dashboard-chart-1'); + cy.get('[data-fieldname="parent_document_type"]').should('have.css', 'display', 'none'); + + cy.get_field('document_type', 'Link'); + cy.fill_field('document_type', 'Workspace Link', 'Link').focus().blur(); + cy.get_field('document_type', 'Link').should('have.value', 'Workspace Link'); + + cy.fill_field('chart_name', 'Test Chart', 'Data'); + + cy.get('[data-fieldname="filters_json"]').click().wait(200); + cy.get('.modal-body .filter-action-buttons .add-filter').click(); + cy.get('.modal-body .fieldname-select-area').click(); + cy.get('.modal-actions .btn-modal-close').click(); + }); +}); \ No newline at end of file diff --git a/cypress/integration/datetime.js b/cypress/integration/datetime.js index b310526c7c..ef1952dc94 100644 --- a/cypress/integration/datetime.js +++ b/cypress/integration/datetime.js @@ -92,15 +92,15 @@ context('Control Date, Time and DateTime', () => { date_format: 'dd.mm.yyyy', time_format: 'HH:mm:ss', value: ' 02.12.2019 11:00:12', - doc_value: '2019-12-02 11:00:12', - input_value: '02.12.2019 11:00:12' + doc_value: '2019-12-02 00:30:12', // system timezone (America/New_York) + input_value: '02.12.2019 11:00:12' // admin timezone (Asia/Kolkata) }, { date_format: 'mm-dd-yyyy', time_format: 'HH:mm', value: ' 12-02-2019 11:00:00', - doc_value: '2019-12-02 11:00:00', - input_value: '12-02-2019 11:00' + doc_value: '2019-12-02 00:30:00', // system timezone (America/New_York) + input_value: '12-02-2019 11:00' // admin timezone (Asia/Kolkata) } ]; datetime_formats.forEach(d => { diff --git a/cypress/integration/first_day_of_the_week.js b/cypress/integration/first_day_of_the_week.js new file mode 100644 index 0000000000..1e65b78990 --- /dev/null +++ b/cypress/integration/first_day_of_the_week.js @@ -0,0 +1,45 @@ +context("First Day of the Week", () => { + before(() => { + cy.login(); + }); + + beforeEach(() => { + cy.visit('/app/system-settings'); + cy.findByText('Date and Number Format').click(); + }); + + it("Date control starts with same day as selected in System Settings", () => { + cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings"); + cy.fill_field('first_day_of_the_week', 'Tuesday', 'Select'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.wait("@load_settings"); + cy.dialog({ + title: 'Date', + fields: [ + { + label: 'Date', + fieldname: 'date', + fieldtype: 'Date' + } + ] + }); + cy.get_field('date').click(); + cy.get('.datepicker--day-name').eq(0).should('have.text', 'Tu'); + }); + + it("Calendar view starts with same day as selected in System Settings", () => { + cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings"); + cy.fill_field('first_day_of_the_week', 'Monday', 'Select'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.wait("@load_settings"); + cy.visit("app/todo/view/calendar/default"); + cy.get('.fc-day-header > span').eq(0).should('have.text', 'Mon'); + }); + + after(() => { + cy.visit('/app/system-settings'); + cy.findByText('Date and Number Format').click(); + cy.fill_field('first_day_of_the_week', 'Sunday', 'Select'); + cy.findByRole('button', {name: 'Save'}).click(); + }); +}); \ No newline at end of file diff --git a/cypress/integration/form.js b/cypress/integration/form.js index f860a742ef..71cc6f4f0d 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -8,11 +8,7 @@ context('Form', () => { }); it('create a new form', () => { cy.visit('/app/todo/new'); - cy.get('[data-fieldname="description"] .ql-editor') - .first() - .click() - .type('this is a test todo'); - cy.wait(300); + cy.get_field('description', 'Text Editor').type('this is a test todo', {force: true}).wait(200); cy.get('.page-title').should('contain', 'Not Saved'); cy.intercept({ method: 'POST', @@ -20,29 +16,34 @@ context('Form', () => { }).as('form_save'); cy.get('.primary-action').click(); cy.wait('@form_save').its('response.statusCode').should('eq', 200); + cy.visit('/app/todo'); - cy.wait(300); - cy.get('.title-text').should('be.visible').and('contain', 'To Do'); + cy.get('.page-head').findByTitle('To Do').should('exist'); cy.get('.list-row').should('contain', 'this is a test todo'); }); + it('navigates between documents with child table list filters applied', () => { cy.visit('/app/contact'); - cy.add_filter(); - cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); - cy.findByRole('button', {name: 'Apply Filters'}).click({ force: true }); - cy.visit('/app/contact/Test Form Contact 3'); + + cy.clear_filters(); + cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur(); + cy.click_listview_row_item(0); + cy.get('.prev-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); - cy.get('.next-doc').click(); - cy.wait(200); + + cy.get('.next-doc').should('be.visible').click(); + cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); - cy.contains('Test Form Contact 2').should('not.exist'); - cy.get('.title-text').should('contain', 'Test Form Contact 3'); + + cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist'); + // clear filters cy.visit('/app/contact'); cy.clear_filters(); }); + it('validates behaviour of Data options validations in child table', () => { // test email validations for set_invalid controller let website_input = 'website.in'; diff --git a/cypress/integration/grid_keyboard_shortcut.js b/cypress/integration/grid_keyboard_shortcut.js new file mode 100644 index 0000000000..9cf39165ad --- /dev/null +++ b/cypress/integration/grid_keyboard_shortcut.js @@ -0,0 +1,40 @@ +context('Grid Keyboard Shortcut', () => { + let total_count = 0; + before(() => { + cy.login(); + }); + beforeEach(() => { + cy.reload(); + cy.visit('/app/contact/new-contact-1'); + cy.get('.frappe-control[data-fieldname="email_ids"]').find(".grid-add-row").click(); + }); + it('Insert new row at the end', () => { + cy.add_new_row_in_grid('{ctrl}{shift}{downarrow}', (cy, total_count) => { + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', `${total_count+1}`); + }, total_count); + }); + it('Insert new row at the top', () => { + cy.add_new_row_in_grid('{ctrl}{shift}{uparrow}', (cy) => { + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2'); + }); + }); + it('Insert new row below', () => { + cy.add_new_row_in_grid('{ctrl}{downarrow}', (cy) => { + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '1'); + }); + }); + it('Insert new row above', () => { + cy.add_new_row_in_grid('{ctrl}{uparrow}', (cy) => { + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2'); + }); + }); +}); + +Cypress.Commands.add('add_new_row_in_grid', (shortcut_keys, callbackFn, total_count) => { + cy.get('.frappe-control[data-fieldname="email_ids"]').as('table'); + cy.get('@table').find('.grid-body [data-fieldname="email_id"]').first().click(); + cy.get('@table').find('.grid-body [data-fieldname="email_id"]') + .first().type(shortcut_keys); + + callbackFn(cy, total_count); +}); \ No newline at end of file diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index c07230d2b8..84b3320282 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -13,7 +13,7 @@ context('Grid Pagination', () => { it('creates pages for child table', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); - cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.current-page-number').should('have.value', '1'); cy.get('@table').find('.total-page-number').should('contain', '20'); cy.get('@table').find('.grid-body .grid-row').should('have.length', 50); }); @@ -21,10 +21,10 @@ context('Grid Pagination', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.next-page').click(); - cy.get('@table').find('.current-page-number').should('contain', '2'); + cy.get('@table').find('.current-page-number').should('have.value', '2'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '51'); cy.get('@table').find('.prev-page').click(); - cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.current-page-number').should('have.value', '1'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1'); }); it('adds and deletes rows and changes page', () => { @@ -32,14 +32,35 @@ context('Grid Pagination', () => { cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').findByRole('button', {name: 'Add Row'}).click(); cy.get('@table').find('.grid-body .row-index').should('contain', 1001); - cy.get('@table').find('.current-page-number').should('contain', '21'); + cy.get('@table').find('.current-page-number').should('have.value', '21'); cy.get('@table').find('.total-page-number').should('contain', '21'); cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true }); cy.get('@table').findByRole('button', {name: 'Delete'}).click(); cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000); - cy.get('@table').find('.current-page-number').should('contain', '20'); + cy.get('@table').find('.current-page-number').should('have.value', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); }); + it('go to specific page, use up and down arrow, type characters, 0 page and more than existing page', () => { + cy.visit('/app/contact/Test Contact'); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + cy.get('@table').find('.current-page-number').focus().clear().type('17').blur(); + cy.get('@table').find('.grid-body .row-index').should('contain', 801); + + cy.get('@table').find('.current-page-number').focus().type('{uparrow}{uparrow}'); + cy.get('@table').find('.current-page-number').should('have.value', '19'); + + cy.get('@table').find('.current-page-number').focus().type('{downarrow}{downarrow}'); + cy.get('@table').find('.current-page-number').should('have.value', '17'); + + cy.get('@table').find('.current-page-number').focus().clear().type('700').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '20'); + + cy.get('@table').find('.current-page-number').focus().clear().type('0').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '1'); + + cy.get('@table').find('.current-page-number').focus().clear().type('abc').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '1'); + }); // it('deletes all rows', ()=> { // cy.visit('/app/contact/Test Contact'); // cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index ce9e87274b..b161af2df7 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -7,19 +7,13 @@ context('List View', () => { }); }); - it('Keep checkbox checked after Bulk Update', () => { + it('Keep checkbox checked after Refresh', () => { cy.go_to_list('ToDo'); cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true }); - cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click(); - cy.get('.dropdown-menu li:visible .dropdown-item .menu-item-label[data-label="Edit"]').click(); - - cy.get('.modal-body .form-control[data-fieldname="field"]').first().select('Due Date').wait(200); - cy.fill_field('value', '09-28-21', 'Date'); - - cy.get('.modal-footer .standard-actions .btn-primary').click(); - cy.wait(500); - - cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click(); + cy.get('.actions-btn-group button').contains('Actions').should('be.visible'); + cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh'); + cy.get('button[data-original-title="Refresh"]').click(); + cy.wait('@list-refresh'); cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible'); }); diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js index a45fba8d32..7752ad0f0b 100644 --- a/cypress/integration/multi_select_dialog.js +++ b/cypress/integration/multi_select_dialog.js @@ -2,32 +2,47 @@ context('MultiSelectDialog', () => { before(() => { cy.login(); cy.visit('/app'); + const contact_template = { + "doctype": "Contact", + "first_name": "Test", + "status": "Passive", + "email_ids": [ + { + "doctype": "Contact Email", + "email_id": "test@example.com", + "is_primary": 0 + } + ] + }; + const promises = Array.from({length: 25}) + .map(() => cy.insert_doc('Contact', contact_template, true)); + Promise.all(promises); }); function open_multi_select_dialog() { cy.window().its('frappe').then(frappe => { new frappe.ui.form.MultiSelectDialog({ - doctype: "Assignment Rule", + doctype: "Contact", target: {}, setters: { - document_type: null, - priority: null + status: null, + gender: null }, add_filters_group: 1, allow_child_item_selection: 1, - child_fieldname: "assignment_days", - child_columns: ["day"] + child_fieldname: "email_ids", + child_columns: ["email_id", "is_primary"] }); }); } - it('multi select dialog api works', () => { + it('checks multi select dialog api works', () => { open_multi_select_dialog(); - cy.get_open_dialog().should('contain', 'Select Assignment Rules'); + cy.get_open_dialog().should('contain', 'Select Contacts'); }); it('checks for filters', () => { - ['search_term', 'document_type', 'priority'].forEach(fieldname => { + ['search_term', 'status', 'gender'].forEach(fieldname => { cy.get_open_dialog().get(`.frappe-control[data-fieldname="${fieldname}"]`).should('exist'); }); @@ -42,17 +57,43 @@ context('MultiSelectDialog', () => { cy.get_open_dialog() .get(`.frappe-control[data-fieldname="allow_child_item_selection"]`) + .find('input[data-fieldname="allow_child_item_selection"]') .should('exist') - .click(); + .click({force: true}); cy.get_open_dialog() .get(`.frappe-control[data-fieldname="child_selection_area"]`) .should('exist'); cy.get_open_dialog() - .get(`.dt-row-header`).should('contain', 'Assignment Rule'); + .get(`.dt-row-header`).should('contain', 'Contact'); cy.get_open_dialog() - .get(`.dt-row-header`).should('contain', 'Day'); + .get(`.dt-row-header`).should('contain', 'Email Id'); + + cy.get_open_dialog() + .get(`.dt-row-header`).should('contain', 'Is Primary'); + }); + + it('tests more button', () => { + cy.get_open_dialog() + .get(`.frappe-control[data-fieldname="more_btn"]`) + .should('exist') + .as('more-btn'); + + cy.get_open_dialog().get('.list-item-container').should(($rows) => { + expect($rows).to.have.length(20); + }); + + cy.intercept('POST', 'api/method/frappe.client.get_list').as('get-more-records'); + cy.get('@more-btn').find('button').click({force: true}); + cy.wait('@get-more-records'); + + cy.get_open_dialog().get('.list-item-container').should(($rows) => { + if ($rows.length <= 20) { + throw new Error("More button doesn't work"); + } + }); + }); }); \ No newline at end of file diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js index c4d0638f26..b4e023c53e 100644 --- a/cypress/integration/navigation.js +++ b/cypress/integration/navigation.js @@ -13,10 +13,10 @@ context('Navigation', () => { it.only('Navigate to previous page after login', () => { cy.visit('/app/todo'); - cy.findByTitle('To Do').should('be.visible'); + cy.get('.page-head').findByTitle('To Do').should('be.visible'); cy.request('/api/method/logout'); - cy.reload(); - cy.get('.btn-primary').contains('Login').click(); + cy.reload().as('reload'); + cy.get('@reload').get('.page-card .btn-primary').contains('Login').click(); cy.location('pathname').should('eq', '/login'); cy.login(); cy.visit('/app'); diff --git a/cypress/integration/query_report.js b/cypress/integration/query_report.js index e2a1c3fc79..43f26f8b50 100644 --- a/cypress/integration/query_report.js +++ b/cypress/integration/query_report.js @@ -2,32 +2,62 @@ context('Query Report', () => { before(() => { cy.login(); cy.visit('/app/website'); + cy.insert_doc('Report', { + 'report_name': 'Test ToDo Report', + 'ref_doctype': 'ToDo', + 'report_type': 'Query Report', + 'query': 'select * from tabToDo' + }, true).as('doc'); + cy.create_records({ + doctype: 'ToDo', + description: 'this is a test todo for query report' + }).as('todos'); }); it('add custom column in report', () => { cy.visit('/app/query-report/Permitted Documents For User'); cy.get('.page-form.flex', { timeout: 60000 }).should('have.length', 1).then(() => { - cy.get('#page-query-report input[data-fieldname="user"]').as('input'); - cy.get('@input').focus().type('test@erpnext.com', { delay: 100 }).blur(); + cy.get('#page-query-report input[data-fieldname="user"]').as('input-user'); + cy.get('@input-user').focus().type('test@erpnext.com', { delay: 100 }).blur(); cy.wait(300); - cy.get('#page-query-report input[data-fieldname="doctype"]').as('input-test'); - cy.get('@input-test').focus().type('Role', { delay: 100 }).blur(); + cy.get('#page-query-report input[data-fieldname="doctype"]').as('input-role'); + cy.get('@input-role').focus().type('Role', { delay: 100 }).blur(); cy.get('.datatable').should('exist'); - cy.get('.menu-btn-group button').click({ force: true }); - cy.get('.dropdown-menu li').contains('Add Column').click({ force: true }); - cy.get('.modal-dialog').should('contain', 'Add Column'); + cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true }); + cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Add Column').click({ force: true }); + cy.get_open_dialog().get('.modal-title').should('contain', 'Add Column'); cy.get('select[data-fieldname="doctype"]').select("Role", { force: true }); cy.get('select[data-fieldname="field"]').select("Role Name", { force: true }); cy.get('select[data-fieldname="insert_after"]').select("Name", { force: true }); - cy.get('button').contains('Submit').click({ force: true }); - cy.get('.menu-btn-group button').click({ force: true }); - cy.get('.dropdown-menu li').contains('Save').click({ force: true }); - cy.get('.modal-dialog').should('contain', 'Save Report'); + cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ force: true }); + cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true }); + cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true }); + cy.get_open_dialog().get('.modal-title').should('contain', 'Save Report'); cy.get('input[data-fieldname="report_name"]').type("Test Report", { delay: 100, force: true }); - cy.get('button').contains('Submit').click({ timeout: 1000, force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true }); }); }); + + let save_report_and_open = (report, update_name) => { + cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true }); + cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true }); + cy.get_open_dialog().get('.modal-title').should('contain', 'Save Report'); + + cy.get('input[data-fieldname="report_name"]').type(update_name, { delay: 100, force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true }); + + cy.visit('/app/query-report/'+report); + cy.get('.datatable').should('exist'); + }; + + it('test multi level query report', () => { + cy.visit('/app/query-report/Test ToDo Report'); + cy.get('.datatable').should('exist'); + + save_report_and_open('Test ToDo Report 1', ' 1'); + save_report_and_open('Test ToDo Report 11', '1'); + }); }); \ No newline at end of file diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js index caf1349e6e..7d4c83abf5 100644 --- a/cypress/integration/recorder.js +++ b/cypress/integration/recorder.js @@ -14,48 +14,51 @@ context('Recorder', () => { }); it('Recorder Empty State', () => { - cy.findByTitle('Recorder').should('exist'); + cy.get('.page-head').findByTitle('Recorder').should('exist'); cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red'); - cy.findByRole('button', {name: 'Start'}).should('exist'); - cy.findByRole('button', {name: 'Clear'}).should('exist'); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).should('exist'); + cy.get('.page-actions').findByRole('button', {name: 'Clear'}).should('exist'); - cy.get('.msg-box').should('contain', 'Inactive'); - cy.findByRole('button', {name: 'Start Recording'}).should('exist'); + cy.get('.msg-box').should('contain', 'Recorder is Inactive'); + cy.get('.msg-box').findByRole('button', {name: 'Start Recording'}).should('exist'); }); it('Recorder Start', () => { - cy.findByRole('button', {name: 'Start'}).click(); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).click(); cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green'); - cy.get('.msg-box').should('contain', 'No Requests'); + cy.get('.msg-box').should('contain', 'No Requests found'); cy.visit('/app/List/DocType/List'); cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.wait('@list_refresh'); - cy.get('.title-text').should('contain', 'DocType'); + cy.get('.page-head').findByTitle('DocType').should('exist'); cy.get('.list-count').should('contain', '20 of '); cy.visit('/app/recorder'); - cy.findByTitle('Recorder').should('exist'); - cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get'); + cy.get('.page-head').findByTitle('Recorder').should('exist'); + cy.get('.frappe-list .result-list').should('contain', '/api/method/frappe.desk.reportview.get'); }); it('Recorder View Request', () => { - cy.findByRole('button', {name: 'Start'}).click(); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).click(); cy.visit('/app/List/DocType/List'); cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.wait('@list_refresh'); - cy.get('.title-text').should('contain', 'DocType'); + cy.get('.page-head').findByTitle('DocType').should('exist'); cy.get('.list-count').should('contain', '20 of '); cy.visit('/app/recorder'); - cy.get('.list-row-container span').contains('/api/method/frappe').click(); + cy.get('.frappe-list .list-row-container span') + .contains('/api/method/frappe') + .should('be.visible') + .click({force: true}); cy.url().should('include', '/recorder/request'); cy.get('form').should('contain', '/api/method/frappe'); diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index e762eebea1..629ae72eb8 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -7,6 +7,8 @@ context('Report View', () => { cy.visit('/app/website'); cy.insert_doc('DocType', custom_submittable_doctype, true); cy.clear_cache(); + }); + it('Field with enabled allow_on_submit should be editable.', () => { cy.insert_doc(doctype_name, { 'title': 'Doc 1', 'description': 'Random Text', @@ -14,8 +16,6 @@ context('Report View', () => { // submit document 'docstatus': 1 }, true).as('doc'); - }); - it('Field with enabled allow_on_submit should be editable.', () => { cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update'); cy.visit(`/app/List/${doctype_name}/Report`); // check status column added from docstatus @@ -23,8 +23,7 @@ context('Report View', () => { let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); // select the cell cell.dblclick(); - cell.findByRole('checkbox').check({ force: true }); - cy.get('.dt-row-0 > .dt-cell--col-5').click(); + cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true }); cy.wait('@value-update'); cy.get('@doc').then(doc => { cy.call('frappe.client.get_value', { diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index 191b5a2b2c..6c4733400d 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -8,22 +8,18 @@ context('Timeline', () => { it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => { //Adding new ToDo + cy.visit('/app/todo/new-todo-1'); + cy.get('[data-fieldname="description"] .ql-editor.ql-blank').type('Test ToDo', {force: true}).wait(200); + cy.get('.page-head .page-actions').findByRole('button', {name: 'Save'}).click(); + cy.visit('/app/todo'); - cy.click_listview_primary_button('Add ToDo'); - cy.findByRole('button', {name: 'Edit in full page'}).click(); - cy.findByTitle('New ToDo').should('be.visible'); - cy.get('[data-fieldname="description"] .ql-editor').eq(0).type('Test ToDo', {force: true}); - cy.wait(200); - cy.findByRole('button', {name: 'Save'}).click(); - cy.wait(700); - cy.visit('/app/todo'); - cy.get('.level-item.ellipsis').eq(0).click(); + cy.click_listview_row_item(0); //To check if the comment box is initially empty and tying some text into it cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline'); //Adding new comment - cy.findByRole('button', {name: 'Comment'}).click(); + cy.get('.comment-box').findByRole('button', {name: 'Comment'}).click(); //To check if the commented text is visible in the timeline content cy.get('.timeline-content').should('contain', 'Testing Timeline'); @@ -38,21 +34,17 @@ context('Timeline', () => { //Discarding comment cy.click_timeline_action_btn("Edit"); - cy.findByRole('button', {name: 'Dismiss'}).click(); + cy.click_timeline_action_btn("Dismiss"); //To check if after discarding the timeline content is same as previous cy.get('.timeline-content').should('contain', 'Testing Timeline 123'); //Deleting the added comment - cy.get('.more-actions > .action-btn').click(); - cy.get('.more-actions .dropdown-item').contains('Delete').click(); - cy.findByRole('button', {name: 'Yes'}).click(); - cy.click_modal_primary_button('Yes'); + cy.get('.timeline-message-box .more-actions > .action-btn').click(); //Menu button in timeline item + cy.get('.timeline-message-box .more-actions .dropdown-item').contains('Delete').click({ force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click({ force: true }); - //Deleting the added ToDo - cy.get('[id="page-ToDo"] .menu-btn-group [data-original-title="Menu"]').click(); - cy.get('[id="page-ToDo"] .menu-btn-group .dropdown-item').contains('Delete').click(); - cy.findByRole('button', {name: 'Yes'}).click(); + cy.get('.timeline-content').should('not.contain', 'Testing Timeline 123'); }); it('Timeline should have submit and cancel activity information', () => { @@ -66,31 +58,32 @@ context('Timeline', () => { //Adding a new entry for the created custom doctype cy.fill_field('title', 'Test'); - cy.findByRole('button', {name: 'Save'}).click(); - cy.findByRole('button', {name: 'Submit'}).click(); + cy.click_modal_primary_button('Save'); + cy.click_modal_primary_button('Submit'); + cy.visit('/app/custom-submittable-doctype'); - cy.get('.list-subject > .bold > .ellipsis').eq(0).click(); + cy.click_listview_row_item(0); //To check if the submission of the documemt is visible in the timeline content cy.get('.timeline-content').should('contain', 'Administrator submitted this document'); - cy.findByRole('button', {name: 'Cancel'}).click({delay: 900}); - cy.findByRole('button', {name: 'Yes'}).click(); + cy.get('[id="page-Custom Submittable DocType"] .page-actions').findByRole('button', {name: 'Cancel'}).click(); + cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click(); //To check if the cancellation of the documemt is visible in the timeline content cy.get('.timeline-content').should('contain', 'Administrator cancelled this document'); //Deleting the document cy.visit('/app/custom-submittable-doctype'); - cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .dropdown-item').contains("Delete").click(); - cy.click_modal_primary_button('Yes', {force: true, delay: 700}); + 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'); //Deleting the custom doctype cy.visit('/app/doctype'); - cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); + 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/web_form.js b/cypress/integration/web_form.js new file mode 100644 index 0000000000..8346c96313 --- /dev/null +++ b/cypress/integration/web_form.js @@ -0,0 +1,29 @@ +context('Web Form', () => { + before(() => { + cy.login(); + }); + + it('Navigate and Submit a WebForm', () => { + cy.visit('/update-profile'); + cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200); + cy.get('.web-form-actions .btn-primary').click(); + cy.wait(500); + cy.get('.modal.show > .modal-dialog').should('be.visible'); + }); + + it('Navigate and Submit a MultiStep WebForm', () => { + cy.call('frappe.tests.ui_test_helpers.update_webform_to_multistep').then(() => { + cy.visit('/update-profile-duplicate'); + cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200); + cy.get('.btn-next').should('be.visible'); + cy.get('.web-form-footer .btn-primary').should('not.be.visible'); + cy.get('.btn-next').click(); + cy.get('.btn-previous').should('be.visible'); + cy.get('.btn-next').should('not.be.visible'); + cy.get('.web-form-footer .btn-primary').should('be.visible'); + cy.get('.web-form-actions .btn-primary').click(); + cy.wait(500); + cy.get('.modal.show > .modal-dialog').should('be.visible'); + }); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 64a3b18b2f..758b3cde2b 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -30,7 +30,7 @@ Cypress.Commands.add('login', (email, password) => { email = 'Administrator'; } if (!password) { - password = Cypress.config('adminPassword'); + password = Cypress.env('adminPassword'); } cy.request({ url: '/api/method/login', @@ -161,7 +161,7 @@ Cypress.Commands.add('remove_doc', (doctype, name) => { Cypress.Commands.add('create_records', doc => { return cy - .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc}) + .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc: JSON.stringify(doc)}) .then(r => r.message); }); @@ -193,7 +193,8 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => { }); Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => { - let selector = `[data-fieldname="${fieldname}"] input:visible`; + let field_element = fieldtype === 'Select' ? 'select': 'input'; + let selector = `[data-fieldname="${fieldname}"] ${field_element}:visible`; if (fieldtype === 'Text Editor') { selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]:visible`; @@ -341,7 +342,7 @@ Cypress.Commands.add('click_sidebar_button', (btn_name) => { }); Cypress.Commands.add('click_listview_row_item', (row_no) => { - cy.get('.list-row > .level-left > .list-subject > .bold > .ellipsis').eq(row_no).click({force: true}); + cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis').eq(row_no).click({force: true}); }); Cypress.Commands.add('click_filter_button', () => { @@ -353,5 +354,9 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => { }); Cypress.Commands.add('click_timeline_action_btn', (btn_name) => { - cy.get('.timeline-message-box .custom-actions > .btn').contains(btn_name).click(); + cy.get('.timeline-message-box .actions .action-btn').contains(btn_name).click(); +}); + +Cypress.Commands.add('select_listview_row_checkbox', (row_no) => { + cy.get('.frappe-list .select-like > .list-row-checkbox').eq(row_no).click(); }); diff --git a/dev-requirements.txt b/dev-requirements.txt index df3ae9484a..f4045c6bed 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,4 @@ +coverage==5.5 Faker~=8.1.0 pyngrok~=5.0.5 unittest-xml-reporting~=3.0.4 diff --git a/esbuild/build-cleanup.js b/esbuild/build-cleanup.js new file mode 100644 index 0000000000..cf03606a34 --- /dev/null +++ b/esbuild/build-cleanup.js @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +const path = require("path"); +const fs = require("fs"); +const glob = require("fast-glob"); + +module.exports = { + name: 'build_cleanup', + setup(build) { + build.onEnd(result => { + if (result.errors.length) return; + clean_dist_files(Object.keys(result.metafile.outputs)); + }); + }, +}; + +function clean_dist_files(new_files) { + new_files.forEach( + file => { + if (file.endsWith(".map")) return; + + const pattern = file.split(".").slice(0, -2).join(".") + "*"; + glob.sync(pattern).forEach( + file_to_delete => { + if (file_to_delete.startsWith(file)) return; + + fs.unlink(path.resolve(file_to_delete), err => { + if (!err) return; + + console.error( + `Error deleting ${file.split(path.sep).pop()}` + ); + }); + } + + ); + } + ); +} \ No newline at end of file diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 18de95b40d..792cb56198 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -1,18 +1,20 @@ /* eslint-disable no-console */ -let path = require("path"); -let fs = require("fs"); -let glob = require("fast-glob"); -let esbuild = require("esbuild"); -let vue = require("esbuild-vue"); -let yargs = require("yargs"); -let cliui = require("cliui")(); -let chalk = require("chalk"); -let html_plugin = require("./frappe-html"); -let rtlcss = require('rtlcss'); -let postCssPlugin = require("esbuild-plugin-postcss2").default; -let ignore_assets = require("./ignore-assets"); -let sass_options = require("./sass_options"); -let { +const path = require("path"); +const fs = require("fs"); +const glob = require("fast-glob"); +const esbuild = require("esbuild"); +const vue = require("esbuild-vue"); +const yargs = require("yargs"); +const cliui = require("cliui")(); +const chalk = require("chalk"); +const html_plugin = require("./frappe-html"); +const rtlcss = require('rtlcss'); +const postCssPlugin = require("esbuild-plugin-postcss2").default; +const ignore_assets = require("./ignore-assets"); +const sass_options = require("./sass_options"); +const build_cleanup_plugin = require("./build-cleanup"); + +const { app_list, assets_path, apps_path, @@ -26,7 +28,7 @@ let { get_redis_subscriber } = require("./utils"); -let argv = yargs +const argv = yargs .usage("Usage: node esbuild [options]") .option("apps", { type: "string", @@ -98,9 +100,6 @@ if (WATCH_MODE) { async function execute() { console.time(TOTAL_BUILD_TIME); - if (!FILES_TO_BUILD.length) { - await clean_dist_folders(APPS); - } let results; try { @@ -231,12 +230,13 @@ function get_files_to_build(files) { function build_files({ files, outdir }) { let build_plugins = [ html_plugin, + build_cleanup_plugin, vue(), ]; return esbuild.build(get_build_options(files, outdir, build_plugins)); } -function build_style_files({ files, outdir, rtl_style=false }) { +function build_style_files({ files, outdir, rtl_style = false }) { let plugins = []; if (rtl_style) { plugins.push(rtlcss); @@ -244,6 +244,7 @@ function build_style_files({ files, outdir, rtl_style=false }) { let build_plugins = [ ignore_assets, + build_cleanup_plugin, postCssPlugin({ plugins: plugins, sassOptions: sass_options @@ -313,24 +314,6 @@ function get_watch_config() { return null; } -async function clean_dist_folders(apps) { - for (let app of apps) { - let public_path = get_public_path(app); - let paths = [ - path.resolve(public_path, "dist", "js"), - path.resolve(public_path, "dist", "css"), - path.resolve(public_path, "dist", "css-rtl") - ]; - for (let target of paths) { - if (fs.existsSync(target)) { - // rmdir is deprecated in node 16, this will work in both node 14 and 16 - let rmdir = fs.promises.rm || fs.promises.rmdir; - await rmdir(target, { recursive: true }); - } - } - } -} - function log_built_assets(results) { let outputs = {}; for (const result of results) { diff --git a/frappe/__init__.py b/frappe/__init__.py index 43246a7fd6..08c0f794b3 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -28,7 +28,11 @@ from .exceptions import * from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader) from .utils.lazy_loader import lazy_import -from frappe.query_builder import get_query_builder, patch_query_execute +from frappe.query_builder import ( + get_query_builder, + patch_query_execute, + patch_query_aggregation, +) __version__ = '14.0.0-dev' @@ -41,7 +45,8 @@ class _dict(dict): """dict like object that exposes keys as attributes""" def __getattr__(self, key): ret = self.get(key) - if not ret and key.startswith("__"): + # "__deepcopy__" exception added to fix frappe#14833 via DFP + if not ret and key.startswith("__") and key != "__deepcopy__": raise AttributeError() return ret def __setattr__(self, key, value): @@ -210,6 +215,7 @@ def init(site, sites_path=None, new_site=False): setup_module_map() patch_query_execute() + patch_query_aggregation() local.initialised = True @@ -734,17 +740,26 @@ def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=Fals :param doc: [optional] Checks User permissions for given doc. :param user: [optional] Check for given user. Default: current user. :param parent_doctype: Required when checking permission for a child DocType (unless doc is specified).""" + import frappe.permissions + if not doctype and doc: doctype = doc.doctype - import frappe.permissions out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user, raise_exception=throw, parent_doctype=parent_doctype) + if throw and not out: - if doc: - frappe.throw(_("No permission for {0}").format(doc.doctype + " " + doc.name)) - else: - frappe.throw(_("No permission for {0}").format(doctype)) + # mimics frappe.throw + document_label = f"{doc.doctype} {doc.name}" if doc else doctype + msgprint( + _("No permission for {0}").format(document_label), + raise_exception=ValidationError, + title=None, + indicator='red', + is_minimizable=None, + wide=None, + as_list=False + ) return out @@ -789,7 +804,9 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc def is_table(doctype): """Returns True if `istable` property (indicating child Table) is set for given DocType.""" def get_tables(): - return db.sql_list("select name from tabDocType where istable=1") + return db.get_values( + "DocType", filters={"istable": 1}, order_by=None, pluck=True + ) tables = cache().get_value("is_table", get_tables) return doctype in tables @@ -1195,7 +1212,7 @@ def read_file(path, raise_not_found=False): def get_attr(method_string): """Get python method object from its name.""" app_name = method_string.split(".")[0] - if not local.flags.in_install and app_name not in get_installed_apps(): + if not local.flags.in_uninstall and not local.flags.in_install and app_name not in get_installed_apps(): throw(_("App {0} is not installed").format(app_name), AppNotInstalledError) modulename = '.'.join(method_string.split('.')[:-1]) @@ -1522,8 +1539,8 @@ def format(*args, **kwargs): import frappe.utils.formatters return frappe.utils.formatters.format_value(*args, **kwargs) -def get_print(doctype=None, name=None, print_format=None, style=None, - html=None, as_pdf=False, doc=None, output=None, no_letterhead=0, password=None): +def get_print(doctype=None, name=None, print_format=None, style=None, html=None, + as_pdf=False, doc=None, output=None, no_letterhead=0, password=None, pdf_options=None): """Get Print Format for given document. :param doctype: DocType of document. @@ -1542,15 +1559,15 @@ def get_print(doctype=None, name=None, print_format=None, style=None, local.form_dict.doc = doc local.form_dict.no_letterhead = no_letterhead - options = None + pdf_options = pdf_options or {} if password: - options = {'password': password} + pdf_options['password'] = password if not html: html = get_response_content("printview") if as_pdf: - return get_pdf(html, output = output, options = options) + return get_pdf(html, options=pdf_options, output=output) else: return html @@ -1797,7 +1814,7 @@ def get_version(doctype, name, limit=None, head=False, raise_err=True): 'limit': limit }, as_list=1) - from frappe.chat.util import squashify, dictify, safe_json_loads + from frappe.utils import squashify, dictify, safe_json_loads versions = [] @@ -1855,7 +1872,7 @@ def mock(type, size=1, locale='en'): data = getattr(fake, type)() results.append(data) - from frappe.chat.util import squashify + from frappe.utils import squashify return squashify(results) def validate_and_sanitize_search_inputs(fn): diff --git a/frappe/app.py b/frappe/app.py index 8e1534e7ef..d73dd67983 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -120,6 +120,8 @@ def init_request(request): else: frappe.connect(set_admin_as_user=False) + request.max_content_length = frappe.local.conf.get('max_file_size') or 10 * 1024 * 1024 + make_form_dict(request) if request.method != "OPTIONS": @@ -183,7 +185,9 @@ def make_form_dict(request): if 'application/json' in (request.content_type or '') and request_data: args = json.loads(request_data) else: - args = request.form or request.args + args = {} + args.update(request.args or {}) + args.update(request.form or {}) if not isinstance(args, dict): frappe.throw(_("Invalid request arguments")) diff --git a/frappe/auth.py b/frappe/auth.py index 2c875c4437..078a6bb165 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -128,7 +128,6 @@ class LoginManager: self.make_session() self.set_user_info() - @frappe.whitelist() def login(self): # clear cache frappe.clear_cache(user = frappe.form_dict.get('usr')) diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index a3e27d4da5..a8c75bffd9 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -1,32 +1,47 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and contributors +# Copyright (c) 2022, Frappe Technologies and contributors # License: MIT. See LICENSE +from typing import Dict, Iterable, List + import frappe -from frappe.model.document import Document -from frappe.desk.form import assign_to -import frappe.cache_manager from frappe import _ +from frappe.cache_manager import clear_doctype_map, get_doctype_map +from frappe.desk.form import assign_to from frappe.model import log_types +from frappe.model.document import Document + class AssignmentRule(Document): - def validate(self): + self.validate_document_types() + self.validate_assignment_days() + + def clear_cache(self): + super().clear_cache() + clear_doctype_map(self.doctype, self.document_type) + clear_doctype_map(self.doctype, f"due_date_rules_for_{self.document_type}") + + def validate_document_types(self): + if self.document_type == "ToDo": + frappe.throw( + _('Assignment Rule is not allowed on {0} document type').format( + frappe.bold("ToDo") + ) + ) + + def validate_assignment_days(self): assignment_days = self.get_assignment_days() - if not len(set(assignment_days)) == len(assignment_days): + + if len(set(assignment_days)) != len(assignment_days): repeated_days = get_repeated(assignment_days) - frappe.throw(_("Assignment Day {0} has been repeated.").format(frappe.bold(repeated_days))) - if self.document_type == 'ToDo': - frappe.throw(_('Assignment Rule is not allowed on {0} document type').format(frappe.bold("ToDo"))) + plural = "s" if len(repeated_days) > 1 else "" - def on_update(self): - clear_assignment_rule_cache(self) - - def after_rename(self, old, new, merge): - clear_assignment_rule_cache(self) - - def on_trash(self): - clear_assignment_rule_cache(self) + frappe.throw( + _("Assignment Day{0} {1} has been repeated.").format( + plural, + frappe.bold(", ".join(repeated_days)) + ) + ) def apply_unassign(self, doc, assignments): if (self.unassign_condition and @@ -35,7 +50,6 @@ class AssignmentRule(Document): return False - def apply_assign(self, doc): if self.safe_eval('assign_condition', doc): return self.do_assignment(doc) @@ -109,7 +123,7 @@ class AssignmentRule(Document): user = d.user, count = frappe.db.count('ToDo', dict( reference_type = self.document_type, - owner = d.user, + allocated_to = d.user, status = "Open")) )) @@ -141,65 +155,68 @@ class AssignmentRule(Document): def is_rule_not_applicable_today(self): today = frappe.flags.assignment_day or frappe.utils.get_weekday() assignment_days = self.get_assignment_days() - if assignment_days and not today in assignment_days: - return True + return assignment_days and today not in assignment_days - return False -def get_assignments(doc): +def get_assignments(doc) -> List[Dict]: return frappe.get_all('ToDo', fields = ['name', 'assignment_rule'], filters = dict( reference_type = doc.get('doctype'), reference_name = doc.get('name'), status = ('!=', 'Cancelled') - ), limit = 5) + ), limit=5) + @frappe.whitelist() def bulk_apply(doctype, docnames): - import json - docnames = json.loads(docnames) - + docnames = frappe.parse_json(docnames) background = len(docnames) > 5 + for name in docnames: if background: frappe.enqueue('frappe.automation.doctype.assignment_rule.assignment_rule.apply', doc=None, doctype=doctype, name=name) else: - apply(None, doctype=doctype, name=name) + apply(doctype=doctype, name=name) + def reopen_closed_assignment(doc): - todo_list = frappe.db.get_all('ToDo', filters = dict( - reference_type = doc.doctype, - reference_name = doc.name, - status = 'Closed' - )) - if not todo_list: - return False + todo_list = frappe.get_all("ToDo", filters={ + "reference_type": doc.doctype, + "reference_name": doc.name, + "status": "Closed", + }, pluck="name") + for todo in todo_list: - todo_doc = frappe.get_doc('ToDo', todo.name) + todo_doc = frappe.get_doc('ToDo', todo) todo_doc.status = 'Open' todo_doc.save(ignore_permissions=True) - return True -def apply(doc, method=None, doctype=None, name=None): - if not doctype: - doctype = doc.doctype + return bool(todo_list) - if (frappe.flags.in_patch + +def apply(doc=None, method=None, doctype=None, name=None): + doctype = doctype or doc.doctype + + skip_assignment_rules = ( + frappe.flags.in_patch or frappe.flags.in_install or frappe.flags.in_setup_wizard - or doctype in log_types): + or doctype in log_types + ) + + if skip_assignment_rules: return if not doc and doctype and name: doc = frappe.get_doc(doctype, name) - assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', doc.doctype, dict( - document_type = doc.doctype, disabled = 0), order_by = 'priority desc') - - assignment_rule_docs = [] + assignment_rules = get_doctype_map("Assignment Rule", doc.doctype, filters={ + "document_type": doc.doctype, "disabled": 0 + }, order_by="priority desc") # multiple auto assigns - for d in assignment_rules: - assignment_rule_docs.append(frappe.get_cached_doc('Assignment Rule', d.get('name'))) + assignment_rule_docs: List[AssignmentRule] = [ + frappe.get_cached_doc("Assignment Rule", d.get('name')) for d in assignment_rules + ] if not assignment_rule_docs: return @@ -235,6 +252,7 @@ def apply(doc, method=None, doctype=None, name=None): # apply close rule only if assignments exists assignments = get_assignments(doc) + if assignments: for assignment_rule in assignment_rule_docs: if assignment_rule.is_rule_not_applicable_today(): @@ -242,38 +260,74 @@ def apply(doc, method=None, doctype=None, name=None): if not new_apply: # only reopen if close condition is not satisfied - if not assignment_rule.safe_eval('close_condition', doc): - reopen = reopen_closed_assignment(doc) - if reopen: + to_close_todos = assignment_rule.safe_eval('close_condition', doc) + + if to_close_todos: + # close todo status + todos_to_close = frappe.get_all("ToDo", filters={ + "reference_type": doc.doctype, + "reference_name": doc.name, + }, pluck="name") + + for todo in todos_to_close: + _todo = frappe.get_doc("ToDo", todo) + _todo.status = "Closed" + _todo.save() + break + + else: + reopened = reopen_closed_assignment(doc) + if reopened: break + + # print(f"Rule:{assignment_rule}\nDoc: {doc}\nReOpened: {reopened}") + assignment_rule.close_assignments(doc) + def update_due_date(doc, state=None): - # called from hook - if (frappe.flags.in_patch - or frappe.flags.in_install - or frappe.flags.in_migrate + """Run on_update on every Document (via hooks.py) + """ + skip_document_update = ( + frappe.flags.in_migrate + or frappe.flags.in_patch or frappe.flags.in_import - or frappe.flags.in_setup_wizard): + or frappe.flags.in_setup_wizard + or frappe.flags.in_install + ) + + if skip_document_update: return - assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', 'due_date_rules_for_' + doc.doctype, dict( - document_type = doc.doctype, - disabled = 0, - due_date_based_on = ['is', 'set'] - )) + + assignment_rules = get_doctype_map( + doctype="Assignment Rule", + name=f"due_date_rules_for_{doc.doctype}", + filters={ + "due_date_based_on": ["is", "set"], + "document_type": doc.doctype, + "disabled": 0, + } + ) + for rule in assignment_rules: - rule_doc = frappe.get_cached_doc('Assignment Rule', rule.get('name')) + rule_doc = frappe.get_cached_doc("Assignment Rule", rule.get("name")) due_date_field = rule_doc.due_date_based_on - if doc.meta.has_field(due_date_field) and \ - doc.has_value_changed(due_date_field) and rule.get('name'): - assignment_todos = frappe.get_all('ToDo', { - 'assignment_rule': rule.get('name'), - 'status': 'Open', - 'reference_type': doc.doctype, - 'reference_name': doc.name - }) + field_updated = ( + doc.meta.has_field(due_date_field) + and doc.has_value_changed(due_date_field) + and rule.get("name") + ) + + if field_updated: + assignment_todos = frappe.get_all("ToDo", filters={ + "assignment_rule": rule.get("name"), + "reference_type": doc.doctype, + "reference_name": doc.name, + "status": "Open", + }, pluck="name") + for todo in assignment_todos: - todo_doc = frappe.get_doc('ToDo', todo.name) + todo_doc = frappe.get_doc('ToDo', todo) todo_doc.date = doc.get(due_date_field) todo_doc.flags.updater_reference = { 'doctype': 'Assignment Rule', @@ -282,20 +336,19 @@ def update_due_date(doc, state=None): } todo_doc.save(ignore_permissions=True) -def get_assignment_rules(): - return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))] -def get_repeated(values): - unique_list = [] - diff = [] +def get_assignment_rules() -> List[str]: + return frappe.get_all("Assignment Rule", filters={"disabled": 0}, pluck="document_type") + + +def get_repeated(values: Iterable) -> List: + unique = set() + repeated = set() + for value in values: - if value not in unique_list: - unique_list.append(str(value)) + if value in unique: + repeated.add(value) else: - if value not in diff: - diff.append(str(value)) - return " ".join(diff) + unique.add(value) -def clear_assignment_rule_cache(rule): - frappe.cache_manager.clear_doctype_map('Assignment Rule', rule.document_type) - frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type) + return [str(x) for x in repeated] diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py index 1c9e177f94..63dbf69d3b 100644 --- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -1,12 +1,22 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and Contributors +# Copyright (c) 2021, Frappe Technologies and Contributors # License: MIT. See LICENSE -import frappe + import unittest -from frappe.utils import random_string + +import frappe from frappe.test_runner import make_test_records +from frappe.utils import random_string + class TestAutoAssign(unittest.TestCase): + @classmethod + def setUpClass(cls): + frappe.db.delete("Assignment Rule") + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + def setUp(self): make_test_records("User") days = [ @@ -30,7 +40,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test@example.com') + ), 'allocated_to'), 'test@example.com') note = make_note(dict(public=1)) @@ -39,7 +49,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test1@example.com') + ), 'allocated_to'), 'test1@example.com') clear_assignments() @@ -51,7 +61,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test2@example.com') + ), 'allocated_to'), 'test2@example.com') # check loop back to first user note = make_note(dict(public=1)) @@ -60,7 +70,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test@example.com') + ), 'allocated_to'), 'test@example.com') def test_load_balancing(self): self.assignment_rule.rule = 'Load Balancing' @@ -71,11 +81,11 @@ class TestAutoAssign(unittest.TestCase): # check if each user has 10 assignments (?) for user in ('test@example.com', 'test1@example.com', 'test2@example.com'): - self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10) + self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10) # clear 5 assignments for first user # can't do a limit in "delete" since postgres does not support it - for d in frappe.get_all('ToDo', dict(reference_type = 'Note', owner = 'test@example.com'), limit=5): + for d in frappe.get_all('ToDo', dict(reference_type = 'Note', allocated_to = 'test@example.com'), limit=5): frappe.db.delete("ToDo", {"name": d.name}) # add 5 more assignments @@ -84,7 +94,7 @@ class TestAutoAssign(unittest.TestCase): # check if each user still has 10 assignments for user in ('test@example.com', 'test1@example.com', 'test2@example.com'): - self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10) + self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10) def test_based_on_field(self): self.assignment_rule.rule = 'Based on Field' @@ -119,7 +129,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), None) + ), 'allocated_to'), None) def test_clear_assignment(self): note = make_note(dict(public=1)) @@ -129,10 +139,10 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ))[0] + ), limit=1)[0] todo = frappe.get_doc('ToDo', todo['name']) - self.assertEqual(todo.owner, 'test@example.com') + self.assertEqual(todo.allocated_to, 'test@example.com') # test auto unassign note.public = 0 @@ -151,10 +161,10 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ))[0] + ), limit=1)[0] todo = frappe.get_doc('ToDo', todo['name']) - self.assertEqual(todo.owner, 'test@example.com') + self.assertEqual(todo.allocated_to, 'test@example.com') note.content="Closed" note.save() @@ -164,7 +174,7 @@ class TestAutoAssign(unittest.TestCase): # check if todo is closed self.assertEqual(todo.status, 'Closed') # check if closed todo retained assignment - self.assertEqual(todo.owner, 'test@example.com') + self.assertEqual(todo.allocated_to, 'test@example.com') def check_multiple_rules(self): note = make_note(dict(public=1, notify_on_login=1)) @@ -174,7 +184,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test@example.com') + ), 'allocated_to'), 'test@example.com') def check_assignment_rule_scheduling(self): frappe.db.delete("Assignment Rule") @@ -192,7 +202,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), ['test@example.com', 'test1@example.com', 'test2@example.com']) + ), 'allocated_to'), ['test@example.com', 'test1@example.com', 'test2@example.com']) frappe.flags.assignment_day = "Friday" note = make_note(dict(public=1)) @@ -201,7 +211,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), ['test3@example.com']) + ), 'allocated_to'), ['test3@example.com']) def test_assignment_rule_condition(self): frappe.db.delete("Assignment Rule") diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 5ab6c86c00..0277b8e402 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -96,7 +96,15 @@ class AutoRepeat(Document): auto_repeat_days = self.get_auto_repeat_days() if not len(set(auto_repeat_days)) == len(auto_repeat_days): repeated_days = get_repeated(auto_repeat_days) - frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days))) + plural = "s" if len(repeated_days) > 1 else "" + + frappe.throw( + _("Auto Repeat Day{0} {1} has been repeated.").format( + plural, + frappe.bold(", ".join(repeated_days)) + ) + ) + def update_auto_repeat_id(self): #check if document is already on auto repeat diff --git a/frappe/boot.py b/frappe/boot.py index 4b764dabfc..723e80313d 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -18,6 +18,7 @@ from frappe.model.base_document import get_controller from frappe.social.doctype.post.post import frequently_visited_links from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo from frappe.geo.country_info import get_all +from frappe.utils import get_time_zone def get_bootinfo(): """build and return boot info""" @@ -60,6 +61,7 @@ def get_bootinfo(): bootinfo.navbar_settings = get_navbar_settings() bootinfo.notification_settings = get_notification_settings() get_country_codes(bootinfo) + set_time_zone(bootinfo) # ipinfo if frappe.session.data.get('ipinfo'): @@ -222,8 +224,8 @@ def load_translations(bootinfo): bootinfo["__messages"] = messages def get_user_info(): - user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', - 'gender', 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type'], + user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', 'gender', + 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type', 'time_zone'], filters=dict(enabled=1)) user_info_map = {d.name: d for d in user_info} @@ -330,3 +332,9 @@ def get_notification_settings(): def get_country_codes(bootinfo): country_codes = get_all() bootinfo.country_codes = frappe._dict(country_codes) + +def set_time_zone(bootinfo): + bootinfo.time_zone = { + "system": get_time_zone(), + "user": bootinfo.get("user_info", {}).get(frappe.session.user, {}).get("time_zone", None) or get_time_zone() + } diff --git a/frappe/chat/__init__.py b/frappe/chat/__init__.py deleted file mode 100644 index 4c9b1c5db7..0000000000 --- a/frappe/chat/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ - -import frappe -from frappe import _ - -session = frappe.session - -def authenticate(user, raise_err = True): - if session.user == 'Guest': - if not frappe.db.exists('Chat Token', user): - if raise_err: - frappe.throw(_("Sorry, you're not authorized.")) - else: - return False - else: - return True - else: - if user != session.user: - if raise_err: - frappe.throw(_("Sorry, you're not authorized.")) - else: - return False - else: - return True \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message.js b/frappe/chat/doctype/chat_message/chat_message.js deleted file mode 100644 index edaad011db..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message.js +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2017, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Chat Message', { - onload: function(frm) { - if(frm.doc.type == 'File') { - frm.set_df_property('content', 'read_only', 1); - } - } -}); diff --git a/frappe/chat/doctype/chat_message/chat_message.json b/frappe/chat/doctype/chat_message/chat_message.json deleted file mode 100644 index 9d2d70c5e0..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "beta": 1, - "creation": "2017-11-10 11:10:40.011099", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "room_type", - "type", - "user", - "room", - "content", - "mentions", - "urls" - ], - "fields": [ - { - "fieldname": "room_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Room Type", - "options": "Direct\nGroup\nVisitor", - "reqd": 1 - }, - { - "fieldname": "type", - "fieldtype": "Data", - "label": "Type", - "options": "Content\nFile" - }, - { - "fieldname": "user", - "fieldtype": "Link", - "hidden": 1, - "label": "User", - "options": "User", - "read_only": 1 - }, - { - "fieldname": "room", - "fieldtype": "Link", - "label": "Room", - "options": "Chat Room", - "reqd": 1 - }, - { - "fieldname": "content", - "fieldtype": "Text", - "label": "Content", - "reqd": 1 - }, - { - "fieldname": "mentions", - "fieldtype": "Code", - "hidden": 1, - "label": "Mentions" - }, - { - "fieldname": "urls", - "fieldtype": "Data", - "hidden": 1, - "label": "URLs" - } - ], - "modified": "2020-09-18 17:26:09.703215", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Message", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "search_fields": "content, user", - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "content", - "track_changes": 1, - "track_seen": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message.py b/frappe/chat/doctype/chat_message/chat_message.py deleted file mode 100644 index bc470a5e9c..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message.py +++ /dev/null @@ -1,215 +0,0 @@ -# imports - standard imports -import json - -# imports - third-party imports -import requests -from bs4 import BeautifulSoup as Soup - -# imports - module imports -from frappe.model.document import Document -from frappe import _, _dict -import frappe - -# imports - frappe module imports -from frappe.chat import authenticate -from frappe.chat.util import ( - get_if_empty, - check_url, - dictify, - get_emojis, - safe_json_loads, - get_user_doc, - squashify -) - -session = frappe.session - -class ChatMessage(Document): - pass - -def get_message_urls(content): - soup = Soup(content, 'html.parser') - anchors = soup.find_all('a') - urls = [ ] - - for anchor in anchors: - text = anchor.text - - if check_url(text): - urls.append(text) - - return urls - -def get_message_mentions(content): - mentions = [ ] - tokens = content.split(' ') - - for token in tokens: - if token.startswith('@'): - what = token[1:] - if frappe.db.exists('User', what): - mentions.append(what) - else: - if frappe.db.exists('User', token): - mentions.append(token) - - return mentions - -def get_message_meta(content): - ''' - Assumes content to be HTML. Sanitizes the content - into a dict of metadata values. - ''' - meta = _dict( - links = [ ], - mentions = [ ] - ) - - meta.content = content - meta.urls = get_message_urls(content) - meta.mentions = get_message_mentions(content) - - return meta - -def sanitize_message_content(content): - emojis = get_emojis() - - tokens = content.split(' ') - for token in tokens: - if token.startswith(':') and token.endswith(':'): - what = token[1:-1] - - # Expensive, I know. - for emoji in emojis: - for alias in emoji.aliases: - if what == alias: - content = content.replace(token, emoji.emoji) - - return content - -def get_new_chat_message_doc(user, room, content, type = "Content", link = True): - user = get_user_doc(user) - room = frappe.get_doc('Chat Room', room) - - meta = get_message_meta(content) - mess = frappe.new_doc('Chat Message') - mess.room = room.name - mess.room_type = room.type - mess.content = sanitize_message_content(content) - mess.type = type - mess.user = user.name - - mess.mentions = json.dumps(meta.mentions) - mess.urls = ','.join(meta.urls) - mess.save(ignore_permissions = True) - - if link: - room.update(dict( - last_message = mess.name - )) - room.save(ignore_permissions = True) - - return mess - -def get_new_chat_message(user, room, content, type = "Content"): - mess = get_new_chat_message_doc(user, room, content, type) - - resp = dict( - name = mess.name, - user = mess.user, - room = mess.room, - room_type = mess.room_type, - content = json.loads(mess.content) if mess.type in ["File"] else mess.content, - urls = mess.urls, - mentions = json.loads(mess.mentions), - creation = mess.creation, - seen = json.loads(mess._seen) if mess._seen else [ ], - ) - - return resp - -@frappe.whitelist(allow_guest = True) -def send(user, room, content, type = "Content"): - mess = get_new_chat_message(user, room, content, type) - - frappe.publish_realtime('frappe.chat.message:create', mess, room = room, - after_commit = True) - -@frappe.whitelist(allow_guest = True) -def seen(message, user = None): - authenticate(user) - - has_message = frappe.db.exists('Chat Message', message) - - if has_message: - mess = frappe.get_doc('Chat Message', message) - mess.add_seen(user) - mess.load_from_db() - room = mess.room - resp = dict(message = message, data = dict(seen = json.loads(mess._seen) if mess._seen else [])) - - frappe.publish_realtime('frappe.chat.message:update', resp, room = room, after_commit = True) - -def history(room, fields = None, limit = 10, start = None, end = None): - room = frappe.get_doc('Chat Room', room) - mess = frappe.get_all('Chat Message', - filters = [ - ('Chat Message', 'room', '=', room.name), - ('Chat Message', 'room_type', '=', room.type) - ], - fields = fields if fields else [ - 'name', 'room_type', 'room', 'content', 'type', 'user', 'mentions', 'urls', 'creation', '_seen' - ], - order_by = 'creation' - ) - - if not fields or 'seen' in fields: - for m in mess: - m['seen'] = json.loads(m._seen) if m._seen else [ ] - del m['_seen'] - if not fields or 'content' in fields: - for m in mess: - m['content'] = json.loads(m.content) if m.type in ["File"] else m.content - - frappe.enqueue('frappe.chat.doctype.chat_message.chat_message.mark_messages_as_seen', - message_names=[m.name for m in mess], user=frappe.session.user) - - return mess - -def mark_messages_as_seen(message_names, user): - ''' - Marks chat messages as seen, updates the _seen for each message - (should be run in background process) - ''' - for name in message_names: - seen = frappe.db.get_value('Chat Message', name, '_seen') or '[]' - seen = json.loads(seen) - seen.append(user) - seen = json.dumps(seen) - frappe.db.set_value('Chat Message', name, '_seen', seen, update_modified=False) - - frappe.db.commit() - - -@frappe.whitelist() -def get(name, rooms = None, fields = None): - rooms, fields = safe_json_loads(rooms, fields) - - has_message = frappe.db.exists('Chat Message', name) - - if has_message: - dmess = frappe.get_doc('Chat Message', name) - data = dict( - name = dmess.name, - user = dmess.user, - room = dmess.room, - room_type = dmess.room_type, - content = json.loads(dmess.content) if dmess.type in ["File"] else dmess.content, - type = dmess.type, - urls = dmess.urls, - mentions = dmess.mentions, - creation = dmess.creation, - seen = get_if_empty(dmess._seen, [ ]) - ) - - return data \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message_list.js b/frappe/chat/doctype/chat_message/chat_message_list.js deleted file mode 100644 index c5b717048b..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message_list.js +++ /dev/null @@ -1,8 +0,0 @@ -frappe.listview_settings['Chat Message'] = { - filters: [ - ['Chat Message', 'user', '==', frappe.session.user, true] - // I need an or_filter here. - // ['Chat Room', 'owner', '==', frappe.session.user, true], - // ['Chat Room', frappe.session.user, 'in', 'users', true] - ] -}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/chat_profile.js b/frappe/chat/doctype/chat_profile/chat_profile.js deleted file mode 100644 index b27a98faf5..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile.js +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint semi: "never" */ -frappe.ui.form.on('Chat Profile', { - refresh: function (form) { - if ( form.doc.name !== frappe.session.user ) { - form.disable_save() - form.set_read_only(true) - // There's one more that faris@frappe.io told me to add here. form.refresh_fields()? - } - } -}); diff --git a/frappe/chat/doctype/chat_profile/chat_profile.json b/frappe/chat/doctype/chat_profile/chat_profile.json deleted file mode 100644 index eb36f803fe..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "autoname": "field:user", - "beta": 1, - "creation": "2017-11-13 18:26:57.943027", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "user", - "status", - "chat_background", - "notifications", - "message_preview", - "notification_tones", - "conversation_tones", - "settings", - "enable_chat" - ], - "fields": [ - { - "fieldname": "user", - "fieldtype": "Link", - "label": "User", - "options": "User", - "reqd": 1 - }, - { - "default": "Online", - "fieldname": "status", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Status", - "options": "Online\nAway\nBusy\nOffline" - }, - { - "fieldname": "chat_background", - "fieldtype": "Attach Image", - "label": "Chat Background" - }, - { - "fieldname": "notifications", - "fieldtype": "Section Break", - "label": "Notifications" - }, - { - "default": "1", - "fieldname": "message_preview", - "fieldtype": "Check", - "label": "Message Preview" - }, - { - "default": "1", - "fieldname": "notification_tones", - "fieldtype": "Check", - "label": "Notification Tones" - }, - { - "default": "1", - "fieldname": "conversation_tones", - "fieldtype": "Check", - "label": "Conversation Tones" - }, - { - "fieldname": "settings", - "fieldtype": "Section Break", - "label": "Settings" - }, - { - "default": "1", - "fieldname": "enable_chat", - "fieldtype": "Check", - "label": "Enable Chat" - } - ], - "in_create": 1, - "modified": "2019-11-07 13:21:36.414961", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Profile", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/chat_profile.py b/frappe/chat/doctype/chat_profile/chat_profile.py deleted file mode 100644 index da10a836c4..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile.py +++ /dev/null @@ -1,98 +0,0 @@ -# imports - module imports -from frappe.model.document import Document -from frappe import _ -import frappe - -# imports - frappe module imports -from frappe.core.doctype.version.version import get_diff -from frappe.chat.doctype.chat_room import chat_room -from frappe.chat.util import ( - safe_json_loads, - filter_dict, - dictify -) - -session = frappe.session - -class ChatProfile(Document): - def on_update(self): - if not self.is_new(): - b, a = self.get_doc_before_save(), self - diff = dictify(get_diff(a, b)) - if diff: - user = session.user - - fields = [changed[0] for changed in diff.changed] - - if 'status' in fields: - rooms = chat_room.get(user, filters = ['Chat Room', 'type', '=', 'Direct']) - update = dict(user = user, data = dict(status = self.status)) - - for room in rooms: - frappe.publish_realtime('frappe.chat.profile:update', update, room = room.name, after_commit = True) - - if 'enable_chat' in fields: - update = dict(user = user, data = dict(enable_chat = bool(self.enable_chat))) - frappe.publish_realtime('frappe.chat.profile:update', update, user = user, after_commit = True) - -def authenticate(user): - if user != session.user: - frappe.throw(_("Sorry, you're not authorized.")) - -@frappe.whitelist() -def get(user, fields = None): - duser = frappe.get_doc('User', user) - - if frappe.db.exists('Chat Profile', user): - dprof = frappe.get_doc('Chat Profile', user) - - # If you're adding something here, make sure the client recieves it. - profile = dict( - # User - name = duser.name, - email = duser.email, - first_name = duser.first_name, - last_name = duser.last_name, - username = duser.username, - avatar = duser.user_image, - bio = duser.bio, - # Chat Profile - status = dprof.status, - chat_background = dprof.chat_background, - message_preview = bool(dprof.message_preview), - notification_tones = bool(dprof.notification_tones), - conversation_tones = bool(dprof.conversation_tones), - enable_chat = bool(dprof.enable_chat) - ) - profile = filter_dict(profile, fields) - - return dictify(profile) - -@frappe.whitelist() -def create(user, exists_ok = False, fields = None): - authenticate(user) - - exists_ok, fields = safe_json_loads(exists_ok, fields) - - try: - dprof = frappe.new_doc('Chat Profile') - dprof.user = user - dprof.save(ignore_permissions = True) - except frappe.DuplicateEntryError: - frappe.clear_messages() - if not exists_ok: - frappe.throw(_('Chat Profile for User {0} exists.').format(user)) - - profile = get(user, fields = fields) - - return profile - -@frappe.whitelist() -def update(user, data): - authenticate(user) - - data = safe_json_loads(data) - - dprof = frappe.get_doc('Chat Profile', user) - dprof.update(data) - dprof.save(ignore_permissions = True) \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/chat_profile_list.js b/frappe/chat/doctype/chat_profile/chat_profile_list.js deleted file mode 100644 index 4d97b75e65..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile_list.js +++ /dev/null @@ -1,11 +0,0 @@ -frappe.listview_settings['Chat Profile'] = -{ - get_indicator: function (doc) - { - const status = frappe.utils.squash(frappe.chat.profile.STATUSES.filter( - s => s.name === doc.status - )); - - return [__(status.name), status.color, `status,=,${status.name}`] - } -}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room/__init__.py b/frappe/chat/doctype/chat_room/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frappe/chat/doctype/chat_room/chat_room.js b/frappe/chat/doctype/chat_room/chat_room.js deleted file mode 100644 index 00b9c8d8f7..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2017, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Chat Room', { - refresh: function (form) { - - } -}); diff --git a/frappe/chat/doctype/chat_room/chat_room.json b/frappe/chat/doctype/chat_room/chat_room.json deleted file mode 100644 index 1417306c45..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "autoname": "CR.#####", - "beta": 1, - "creation": "2017-11-08 15:27:21.156667", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "type", - "room_name", - "avatar", - "last_message", - "message_count", - "owner", - "user_list", - "users" - ], - "fields": [ - { - "default": "Direct", - "fieldname": "type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Type", - "options": "Direct\nGroup\nVisitor", - "reqd": 1, - "set_only_once": 1 - }, - { - "depends_on": "eval:doc.type==\"Group\"", - "fieldname": "room_name", - "fieldtype": "Data", - "label": "Name" - }, - { - "depends_on": "eval:doc.type==\"Group\"", - "fieldname": "avatar", - "fieldtype": "Attach Image", - "hidden": 1, - "label": "Avatar" - }, - { - "fieldname": "last_message", - "fieldtype": "Data", - "hidden": 1, - "label": "Last Message" - }, - { - "fieldname": "message_count", - "fieldtype": "Int", - "hidden": 1, - "label": "Message Count" - }, - { - "fieldname": "owner", - "fieldtype": "Data", - "hidden": 1, - "label": "Owner", - "read_only": 1 - }, - { - "fieldname": "user_list", - "fieldtype": "Section Break", - "label": "Users" - }, - { - "fieldname": "users", - "fieldtype": "Table", - "label": "Users", - "options": "Chat Room User" - } - ], - "image_field": "avatar", - "modified": "2019-11-07 13:20:24.625329", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Room", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 1, - "share": 1, - "write": 1 - } - ], - "search_fields": "room_name", - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "room_name", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room/chat_room.py b/frappe/chat/doctype/chat_room/chat_room.py deleted file mode 100644 index bdbee44d7a..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room.py +++ /dev/null @@ -1,227 +0,0 @@ -# imports - module imports -from frappe.model.document import Document -from frappe import _ -import frappe - -# imports - frappe module imports -from frappe.chat import authenticate -from frappe.core.doctype.version.version import get_diff -from frappe.chat.doctype.chat_message import chat_message -from frappe.chat.util import ( - safe_json_loads, - dictify, - listify, - squashify, - get_if_empty -) - -session = frappe.session - - -def is_direct(owner, other, bidirectional=False): - def get_room(owner, other): - room = frappe.get_all('Chat Room', filters=[ - ['Chat Room', 'type', 'in', ('Direct', 'Visitor')], - ['Chat Room', 'owner', '=', owner], - ['Chat Room User', 'user', '=', other] - ], distinct=True) - - return room - - exists = len(get_room(owner, other)) == 1 - if bidirectional: - exists = exists or len(get_room(other, owner)) == 1 - - return exists - - -def get_chat_room_user_set(users, filter_=None): - seen, uset = set(), list() - - for u in users: - if filter_(u) and u.user not in seen: - uset.append(u) - seen.add(u.user) - - return uset - - -class ChatRoom(Document): - def validate(self): - if self.is_new(): - users = get_chat_room_user_set(self.users, filter_=lambda u: u.user != session.user) - self.update(dict( - users=users - )) - - if self.type == "Direct": - if len(self.users) != 1: - frappe.throw(_('{0} room must have atmost one user.').format(self.type)) - - other = squashify(self.users) - - if self.is_new(): - if is_direct(self.owner, other.user, bidirectional=True): - frappe.throw(_('Direct room with {0} already exists.').format(other.user)) - - if self.type == "Group" and not self.room_name: - frappe.throw(_('Group name cannot be empty.')) - - def on_update(self): - if not self.is_new(): - before = self.get_doc_before_save() - if not before: return - - after = self - diff = dictify(get_diff(before, after)) - if diff: - update = {} - for changed in diff.changed: - field, old, new = changed - - if field == 'last_message': - new = chat_message.get(new) - - update.update({field: new}) - - if diff.added or diff.removed: - update.update(dict(users=[u.user for u in self.users])) - - update = dict(room=self.name, data=update) - - frappe.publish_realtime('frappe.chat.room:update', update, room=self.name, - after_commit=True) - - -@frappe.whitelist(allow_guest=True) -def get(user=None, token=None, rooms=None, fields=None, filters=None): - # There is this horrible bug out here. - # Looks like if frappe.call sends optional arguments (not in right order), - # the argument turns to an empty string. - # I'm not even going to think searching for it. - # Hence, the hack was get_if_empty (previous assign_if_none) - # - Achilles Rasquinha achilles@frappe.io - data = user or token - authenticate(data) - - rooms, fields, filters = safe_json_loads(rooms, fields, filters) - - rooms = listify(get_if_empty(rooms, [])) - fields = listify(get_if_empty(fields, [])) - - const = [] # constraints - if rooms: - const.append(['Chat Room', 'name', 'in', rooms]) - if filters: - if isinstance(filters[0], list): - const = const + filters - else: - const.append(filters) - - default = ['name', 'type', 'room_name', 'creation', 'owner', 'avatar'] - handle = ['users', 'last_message'] - - param = [f for f in fields if f not in handle] - - rooms = frappe.get_all('Chat Room', - or_filters=[ - ['Chat Room', 'owner', '=', frappe.session.user], - ['Chat Room User', 'user', '=', frappe.session.user] - ], - filters=const, - fields=param + ['name'] if param else default, - distinct=True - ) - - if not fields or 'users' in fields: - for i, r in enumerate(rooms): - droom = frappe.get_doc('Chat Room', r.name) - rooms[i]['users'] = [] - - for duser in droom.users: - rooms[i]['users'].append(duser.user) - - if not fields or 'last_message' in fields: - for i, r in enumerate(rooms): - droom = frappe.get_doc('Chat Room', r.name) - if droom.last_message: - rooms[i]['last_message'] = chat_message.get(droom.last_message) - else: - rooms[i]['last_message'] = None - - rooms = squashify(dictify(rooms)) - - return rooms - - -@frappe.whitelist(allow_guest=True) -def create(kind, token, users=None, name=None): - authenticate(token) - - users = safe_json_loads(users) - create = True - - if kind == 'Visitor': - room = squashify(frappe.db.sql(""" - SELECT name - FROM `tabChat Room` - WHERE owner=%s - """, (frappe.session.user), as_dict=True)) - - if room: - room = frappe.get_doc('Chat Room', room.name) - create = False - - if create: - room = frappe.new_doc('Chat Room') - room.type = kind - room.owner = frappe.session.user - room.room_name = name - - dusers = [] - - if kind != 'Visitor': - if users: - users = listify(users) - for user in users: - duser = frappe.new_doc('Chat Room User') - duser.user = user - dusers.append(duser) - - room.users = dusers - else: - dsettings = frappe.get_single('Website Settings') - room.room_name = dsettings.chat_room_name - - users = [user for user in room.users] if hasattr(room, 'users') else [] - - for user in dsettings.chat_operators: - if user.user not in users: - # appending user to room.users will remove the user from chat_operators - # this is undesirable, create a new Chat Room User instead - chat_room_user = {"doctype": "Chat Room User", "user": user.user} - room.append('users', chat_room_user) - - room.save(ignore_permissions=True) - - room = get(token=token, rooms=room.name) - if room: - users = [room.owner] + [u for u in room.users] - - for user in users: - frappe.publish_realtime('frappe.chat.room:create', room, user=user, after_commit=True) - - return room - - -@frappe.whitelist(allow_guest=True) -def history(room, user, fields=None, limit=10, start=None, end=None): - if frappe.get_doc('Chat Room', room).type != 'Visitor': - authenticate(user) - - fields = safe_json_loads(fields) - - mess = chat_message.history(room, limit=limit, start=start, end=end) - mess = squashify(mess) - - return dictify(mess) diff --git a/frappe/chat/doctype/chat_room/chat_room_list.js b/frappe/chat/doctype/chat_room/chat_room_list.js deleted file mode 100644 index 70c708c7bd..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room_list.js +++ /dev/null @@ -1,6 +0,0 @@ -frappe.listview_settings['Chat Room'] = { - filters: [ - ['Chat Room', 'owner', '=', frappe.session.user, true], - ['Chat Room User', 'user', '=', frappe.session.user, true] - ] -}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room_user/__init__.py b/frappe/chat/doctype/chat_room_user/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.json b/frappe/chat/doctype/chat_room_user/chat_room_user.json deleted file mode 100644 index f7bdf6706b..0000000000 --- a/frappe/chat/doctype/chat_room_user/chat_room_user.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "beta": 1, - "creation": "2017-11-08 15:24:21.029314", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "user", - "is_admin" - ], - "fields": [ - { - "fieldname": "user", - "fieldtype": "Link", - "in_list_view": 1, - "label": "User", - "options": "User", - "reqd": 1 - }, - { - "default": "0", - "fieldname": "is_admin", - "fieldtype": "Check", - "label": "Admin" - } - ], - "in_create": 1, - "istable": 1, - "modified": "2019-11-07 13:21:05.297337", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Room User", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.py b/frappe/chat/doctype/chat_room_user/chat_room_user.py deleted file mode 100644 index f6dbdc7659..0000000000 --- a/frappe/chat/doctype/chat_room_user/chat_room_user.py +++ /dev/null @@ -1,8 +0,0 @@ -# imports - module imports -from frappe.model.document import Document -import frappe - -session = frappe.session - -class ChatRoomUser(Document): - pass \ No newline at end of file diff --git a/frappe/chat/doctype/chat_token/__init__.py b/frappe/chat/doctype/chat_token/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frappe/chat/doctype/chat_token/chat_token.js b/frappe/chat/doctype/chat_token/chat_token.js deleted file mode 100644 index 78f03026ec..0000000000 --- a/frappe/chat/doctype/chat_token/chat_token.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Chat Token', { - refresh: function(frm) { - - } -}); diff --git a/frappe/chat/doctype/chat_token/chat_token.json b/frappe/chat/doctype/chat_token/chat_token.json deleted file mode 100644 index b73505ac2c..0000000000 --- a/frappe/chat/doctype/chat_token/chat_token.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "autoname": "field:token", - "beta": 1, - "creation": "2018-03-26 18:20:13.825652", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "token", - "ip_address", - "country" - ], - "fields": [ - { - "fieldname": "token", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Token", - "reqd": 1 - }, - { - "fieldname": "ip_address", - "fieldtype": "Data", - "label": "IP Address" - }, - { - "fieldname": "country", - "fieldtype": "Data", - "label": "Country" - } - ], - "in_create": 1, - "modified": "2019-11-07 13:21:24.514558", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Token", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_token/chat_token.py b/frappe/chat/doctype/chat_token/chat_token.py deleted file mode 100644 index 0be51b6081..0000000000 --- a/frappe/chat/doctype/chat_token/chat_token.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# License: MIT. See LICENSE - -import frappe -from frappe.model.document import Document - -class ChatToken(Document): - pass diff --git a/frappe/chat/util/__init__.py b/frappe/chat/util/__init__.py deleted file mode 100644 index 383df581cd..0000000000 --- a/frappe/chat/util/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# imports - module imports -from frappe.chat.util.util import ( - get_user_doc, - squashify, - safe_json_loads, - filter_dict, - get_if_empty, - listify, - dictify, - check_url, - create_test_user, - get_emojis -) \ No newline at end of file diff --git a/frappe/chat/util/test_util.py b/frappe/chat/util/test_util.py deleted file mode 100644 index e2d05a4024..0000000000 --- a/frappe/chat/util/test_util.py +++ /dev/null @@ -1,35 +0,0 @@ -# imports - standard imports -import unittest - -# imports - module imports -from frappe.chat.util import ( - get_user_doc, - safe_json_loads -) -import frappe - -class TestChatUtil(unittest.TestCase): - def test_safe_json_loads(self): - number = safe_json_loads("1") - self.assertEqual(type(number), int) - - number = safe_json_loads("1.0") - self.assertEqual(type(number), float) - - string = safe_json_loads("foobar") - self.assertEqual(type(string), str) - - array = safe_json_loads('[{ "foo": "bar" }]') - self.assertEqual(type(array), list) - - objekt = safe_json_loads('{ "foo": "bar" }') - self.assertEqual(type(objekt), dict) - - true, null = safe_json_loads("true", "null") - self.assertEqual(true, True) - self.assertEqual(null, None) - - def test_get_user_doc(self): - # Needs more test cases. - user = get_user_doc() - self.assertEqual(user.name, frappe.session.user) \ No newline at end of file diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py deleted file mode 100644 index b7e7991c2b..0000000000 --- a/frappe/chat/util/util.py +++ /dev/null @@ -1,108 +0,0 @@ -# imports - standard imports -import json -from collections.abc import MutableMapping, MutableSequence, Sequence - -# imports - third-party imports -import requests -from urllib.parse import urlparse - -# imports - module imports -import frappe -from frappe.exceptions import DuplicateEntryError -from frappe.model.document import Document - -session = frappe.session - - -def get_user_doc(user = None): - if isinstance(user, Document): - return user - - user = user or session.user - user = frappe.get_doc('User', user) - - return user - -def squashify(what): - if isinstance(what, Sequence) and len(what) == 1: - return what[0] - - return what - -def safe_json_loads(*args): - results = [] - - for arg in args: - try: - arg = json.loads(arg) - except Exception: - pass - - results.append(arg) - - return squashify(results) - -def filter_dict(what, keys, ignore = False): - copy = dict() - - if keys: - for k in keys: - if k not in what and not ignore: - raise KeyError('{key} not in dict.'.format(key = k)) - else: - copy.update({ - k: what[k] - }) - else: - copy = what.copy() - - return copy - -def get_if_empty(a, b): - if not a: - a = b - return a - -def listify(arg): - if not isinstance(arg, list): - arg = [arg] - return arg - -def dictify(arg): - if isinstance(arg, MutableSequence): - for i, a in enumerate(arg): - arg[i] = dictify(a) - elif isinstance(arg, MutableMapping): - arg = frappe._dict(arg) - - return arg - -def check_url(what, raise_err = False): - if not urlparse(what).scheme: - if raise_err: - raise ValueError('{what} not a valid URL.') - else: - return False - - return True - -def create_test_user(module): - try: - test_user = frappe.new_doc('User') - test_user.first_name = '{module}'.format(module = module) - test_user.email = 'testuser.{module}@example.com'.format(module = module) - test_user.save() - except DuplicateEntryError: - frappe.log('Test User Chat Profile exists.') - -def get_emojis(): - redis = frappe.cache() - emojis = redis.hget('frappe_emojis', 'emojis') - - if not emojis: - resp = requests.get('http://git.io/frappe-emoji') - if resp.ok: - emojis = resp.json() - redis.hset('frappe_emojis', 'emojis', emojis) - - return dictify(emojis) diff --git a/frappe/chat/website/__init__.py b/frappe/chat/website/__init__.py deleted file mode 100644 index 12affd2782..0000000000 --- a/frappe/chat/website/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ - -import frappe -from frappe.chat.util import filter_dict, safe_json_loads - -from frappe.sessions import get_geo_ip_country - -@frappe.whitelist(allow_guest = True) -def settings(fields = None): - fields = safe_json_loads(fields) - - dsettings = frappe.get_single('Website Settings') - response = dict( - socketio = dict( - port = frappe.conf.socketio_port - ), - enable = bool(dsettings.chat_enable), - enable_from = dsettings.chat_enable_from, - enable_to = dsettings.chat_enable_to, - room_name = dsettings.chat_room_name, - welcome_message = dsettings.chat_welcome_message, - operators = [ - duser.user for duser in dsettings.chat_operators - ] - ) - - if fields: - response = filter_dict(response, fields) - - return response - -@frappe.whitelist(allow_guest = True) -def token(): - dtoken = frappe.new_doc('Chat Token') - - dtoken.token = frappe.generate_hash() - dtoken.ip_address = frappe.local.request_ip - country = get_geo_ip_country(dtoken.ip_address) - if country: - dtoken.country = country['iso_code'] - dtoken.save(ignore_permissions = True) - - return dtoken.token \ No newline at end of file diff --git a/frappe/client.py b/frappe/client.py index 0e9be0a7ee..e835e7fee7 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -18,7 +18,7 @@ Requests via FrappeClient are also handled here. @frappe.whitelist() def get_list(doctype, fields=None, filters=None, order_by=None, - limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True): + limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True, or_filters=None): '''Returns a list of records by filters, fields, ordering and limit :param doctype: DocType of the data to be queried @@ -32,8 +32,10 @@ def get_list(doctype, fields=None, filters=None, order_by=None, args = frappe._dict( doctype=doctype, + parent_doctype=parent, fields=fields, filters=filters, + or_filters=or_filters, order_by=order_by, limit_start=limit_start, limit_page_length=limit_page_length, @@ -87,7 +89,7 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren filters = {"name": filters} try: - fields = json.loads(fieldname) + fields = frappe.parse_json(fieldname) except (TypeError, ValueError): # name passed, not json fields = [fieldname] @@ -405,3 +407,45 @@ def is_document_amended(doctype, docname): pass return False + +@frappe.whitelist() +def validate_link(doctype: str, docname: str, fields=None): + if not isinstance(doctype, str): + frappe.throw(_("DocType must be a string")) + + if not isinstance(docname, str): + frappe.throw(_("Document Name must be a string")) + + if doctype != "DocType" and not ( + frappe.has_permission(doctype, "select") + or frappe.has_permission(doctype, "read") + ): + frappe.throw( + _("You do not have Read or Select Permissions for {}") + .format(frappe.bold(doctype)), + frappe.PermissionError + ) + + values = frappe._dict() + values.name = frappe.db.get_value(doctype, docname, cache=True) + + fields = frappe.parse_json(fields) + if not values.name or not fields: + return values + + try: + values.update(get_value(doctype, fields, docname)) + except frappe.PermissionError: + frappe.clear_last_message() + frappe.msgprint( + _("You need {0} permission to fetch values from {1} {2}") + .format( + frappe.bold(_("Read")), + frappe.bold(doctype), + frappe.bold(docname) + ), + title=_("Cannot Fetch Values"), + indicator="orange" + ) + + return values diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 2bd3110481..677325e02d 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -55,8 +55,11 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin @click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file') @click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file') @click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended') +@click.option('--encryption-key', help='Backup encryption key') @pass_context -def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): +def restore(context, sql_file_path, encryption_key=None, mariadb_root_username=None, mariadb_root_password=None, + db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, + with_private_files=None): "Restore site database from an sql file" from frappe.installer import ( _new_site, @@ -66,26 +69,74 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas is_partial, validate_database_sql ) + from frappe.utils.backups import Backup + if not os.path.exists(sql_file_path): + print("Invalid path", sql_file_path) + sys.exit(1) + + _backup = Backup(sql_file_path) site = get_site(context) frappe.init(site=site) - force = context.force or force - decompressed_file_name = extract_sql_from_archive(sql_file_path) - # check if partial backup - if is_partial(decompressed_file_name): - click.secho( - "Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.", - fg="red" - ) - click.secho( - "Use `bench partial-restore` to restore a partial backup to an existing site.", - fg="yellow" - ) - sys.exit(1) + try: + decompressed_file_name = extract_sql_from_archive(sql_file_path) + if is_partial(decompressed_file_name): + click.secho( + "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.", + fg="red" + ) + click.secho( + "Use `bench partial-restore` to restore a partial backup to an existing site.", + fg="yellow" + ) + _backup.decryption_rollback() + sys.exit(1) + + except UnicodeDecodeError: + _backup.decryption_rollback() + if encryption_key: + click.secho( + "Encrypted backup file detected. Decrypting using provided key.", + fg="yellow" + ) + _backup.backup_decryption(encryption_key) + + else: + click.secho( + "Encrypted backup file detected. Decrypting using site config.", + fg="yellow" + ) + encryption_key = frappe.get_site_config().encryption_key + _backup.backup_decryption(encryption_key) + + # Rollback on unsuccessful decryrption + if not os.path.exists(sql_file_path): + click.secho( + "Decryption failed. Please provide a valid key and try again.", + fg="red" + ) + + _backup.decryption_rollback() + sys.exit(1) + + decompressed_file_name = extract_sql_from_archive(sql_file_path) + + if is_partial(decompressed_file_name): + click.secho( + "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.", + fg="red" + ) + click.secho( + "Use `bench partial-restore` to restore a partial backup to an existing site.", + fg="yellow" + ) + _backup.decryption_rollback() + sys.exit(1) + + - # check if valid SQL file validate_database_sql(decompressed_file_name, _raise=not force) # dont allow downgrading to older versions of frappe without force @@ -96,23 +147,51 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas ) click.confirm(warn_message, abort=True) - _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, - mariadb_root_password=mariadb_root_password, admin_password=admin_password, - verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name, - force=True, db_type=frappe.conf.db_type) - # Extract public and/or private files to the restored site, if user has given the path - if with_public_files: - public = extract_files(site, with_public_files) - os.remove(public) - if with_private_files: - private = extract_files(site, with_private_files) - os.remove(private) + try: + _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, + mariadb_root_password=mariadb_root_password, admin_password=admin_password, + verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name, + force=True, db_type=frappe.conf.db_type) + + except Exception as err: + print(err.args[1]) + _backup.decryption_rollback() + sys.exit(1) # Removing temporarily created file if decompressed_file_name != sql_file_path: os.remove(decompressed_file_name) + _backup.decryption_rollback() + + # Extract public and/or private files to the restored site, if user has given the path + if with_public_files: + # Decrypt data if there is a Key + if encryption_key: + _backup = Backup(with_public_files) + _backup.backup_decryption(encryption_key) + if not os.path.exists(with_public_files): + _backup.decryption_rollback() + public = extract_files(site, with_public_files) + + # Removing temporarily created file + os.remove(public) + _backup.decryption_rollback() + + + if with_private_files: + # Decrypt data if there is a Key + if encryption_key: + _backup = Backup(with_private_files) + _backup.backup_decryption(encryption_key) + if not os.path.exists(with_private_files): + _backup.decryption_rollback() + private = extract_files(site, with_private_files) + + # Removing temporarily created file + os.remove(private) + _backup.decryption_rollback() success_message = "Site {0} has been restored{1}".format( site, @@ -120,19 +199,92 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas ) click.secho(success_message, fg="green") - @click.command('partial-restore') @click.argument('sql-file-path') @click.option("--verbose", "-v", is_flag=True) +@click.option('--encryption-key', help='Backup encryption key') @pass_context -def partial_restore(context, sql_file_path, verbose): - from frappe.installer import partial_restore - verbose = context.verbose or verbose +def partial_restore(context, sql_file_path, verbose, encryption_key=None): + from frappe.installer import partial_restore, extract_sql_from_archive + from frappe.utils.backups import Backup + + if not os.path.exists(sql_file_path): + print("Invalid path", sql_file_path) + sys.exit(1) site = get_site(context) frappe.init(site=site) + + _backup = Backup(sql_file_path) + + verbose = context.verbose or verbose + frappe.connect(site=site) + try: + decompressed_file_name = extract_sql_from_archive(sql_file_path) + + with open(decompressed_file_name) as f: + header = " ".join(f.readline() for _ in range(5)) + + #Check for full backup file + if "Partial Backup" not in header: + click.secho( + "Full backup file detected.Use `bench restore` to restore a Frappe Site.", + fg="red" + ) + _backup.decryption_rollback() + sys.exit(1) + + + except UnicodeDecodeError: + _backup.decryption_rollback() + if encryption_key: + click.secho( + "Encrypted backup file detected. Decrypting using provided key.", + fg="yellow" + ) + key = encryption_key + + else: + click.secho( + "Encrypted backup file detected. Decrypting using site config.", + fg="yellow" + ) + key = frappe.get_site_config().encryption_key + + _backup.backup_decryption(key) + + # Rollback on unsuccessful decryrption + if not os.path.exists(sql_file_path): + click.secho( + "Decryption failed. Please provide a valid key and try again.", + fg="red" + ) + _backup.decryption_rollback() + sys.exit(1) + + decompressed_file_name = extract_sql_from_archive(sql_file_path) + + with open(decompressed_file_name) as f: + header = " ".join(f.readline() for _ in range(5)) + + #Check for Full backup file. + if "Partial Backup" not in header: + click.secho( + "Full Backup file detected.Use `bench restore` to restore a Frappe Site.", + fg="red" + ) + _backup.decryption_rollback() + sys.exit(1) + + partial_restore(sql_file_path, verbose) + + # Removing temporarily created file + _backup.decryption_rollback() + if os.path.exists(sql_file_path.rstrip(".gz")): + os.remove(sql_file_path.rstrip(".gz")) + frappe.destroy() @@ -295,11 +447,10 @@ def disable_user(context, email): @pass_context def migrate(context, skip_failing=False, skip_search_index=False): "Run patches, sync schema and rebuild files/translations" - import re from frappe.migrate import migrate for site in context.sites: - print('Migrating', site) + click.secho(f"Migrating {site}", fg="green") frappe.init(site=site) frappe.connect() try: @@ -309,6 +460,7 @@ def migrate(context, skip_failing=False, skip_search_index=False): skip_search_index=skip_search_index ) finally: + print() frappe.destroy() if not context.sites: raise SiteNotSpecifiedError @@ -418,6 +570,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False, compress=False, include="", exclude=""): "Backup" + from frappe.utils.backups import scheduled_backup verbose = verbose or context.verbose exit_code = 0 @@ -441,14 +594,25 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac force=True ) except Exception: - click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red") + click.secho( + "Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), + fg="red" + ) if verbose: print(frappe.get_traceback()) exit_code = 1 continue + if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key: + click.secho( + "Backup encryption is turned on. Please note the backup encryption key.", + fg="yellow" + ) odb.print_summary() - click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green") + click.secho( + "Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), + fg="green" + ) frappe.destroy() if not context.sites: @@ -456,6 +620,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac sys.exit(exit_code) + @click.command('remove-from-installed-apps') @click.argument('app') @pass_context @@ -531,11 +696,9 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= drop_user_and_database(frappe.conf.db_name, root_login, root_password) - if not archived_sites_path: - archived_sites_path = os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived_sites') + archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites') - if not os.path.exists(archived_sites_path): - os.mkdir(archived_sites_path) + os.makedirs(archived_sites_path, exist_ok=True) move(archived_sites_path, site) @@ -659,22 +822,41 @@ def publish_realtime(context, event, message, room, user, doctype, docname, afte @click.command('browse') @click.argument('site', required=False) +@click.option('--user', required=False, help='Login as user') @pass_context -def browse(context, site): +def browse(context, site, user=None): '''Opens the site on web browser''' - import webbrowser - site = context.sites[0] if context.sites else site + from frappe.auth import CookieManager, LoginManager + + site = get_site(context, raise_err=False) or site if not site: - click.echo('''Please provide site name\n\nUsage:\n\tbench browse [site-name]\nor\n\tbench --site [site-name] browse''') - return + raise SiteNotSpecifiedError - site = site.lower() + if site not in frappe.utils.get_sites(): + click.echo(f"\nSite named {click.style(site, bold=True)} doesn't exist\n", err=True) + sys.exit(1) - if site in frappe.utils.get_sites(): - webbrowser.open(frappe.utils.get_site_url(site), new=2) - else: - click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site)) + frappe.init(site=site) + frappe.connect() + + sid = '' + if user: + if frappe.conf.developer_mode or user == "Administrator": + frappe.utils.set_request(path="/") + frappe.local.cookie_manager = CookieManager() + frappe.local.login_manager = LoginManager() + frappe.local.login_manager.login_as(user) + sid = f'/app?sid={frappe.session.sid}' + else: + click.echo("Please enable developer mode to login as a user") + + url = f'{frappe.utils.get_site_url(site)}{sid}' + + if user == "Administrator": + click.echo(f'Login URL: {url}') + + click.launch(url) @click.command('start-recording') diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 416f014164..41b607b192 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -723,7 +723,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=Fals frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile") # run for headless mode - run_or_open = 'run --browser firefox --record' if headless else 'open' + run_or_open = 'run --browser chrome --record' if headless else 'open' formatted_command = f'{site_env} {password_env} {coverage_env} {cypress_path} {run_or_open}' if parallel: @@ -791,10 +791,11 @@ def request(context, args=None, path=None): @click.command('make-app') @click.argument('destination') @click.argument('app_name') -def make_app(destination, app_name): +@click.option('--no-git', is_flag=True, default=False, help='Do not initialize git repository for the app') +def make_app(destination, app_name, no_git=False): "Creates a boilerplate app" from frappe.utils.boilerplate import make_boilerplate - make_boilerplate(destination, app_name) + make_boilerplate(destination, app_name, no_git=no_git) @click.command('set-config') diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py index 48c12fd93f..db2e64e868 100644 --- a/frappe/core/doctype/access_log/access_log.py +++ b/frappe/core/doctype/access_log/access_log.py @@ -11,11 +11,26 @@ class AccessLog(Document): @frappe.whitelist() +def make_access_log( + doctype=None, + document=None, + method=None, + file_type=None, + report_name=None, + filters=None, + page=None, + columns=None, +): + _make_access_log( + doctype, document, method, file_type, report_name, filters, page, columns, + ) + + @frappe.write_only() @retry( stop=stop_after_attempt(3), retry=retry_if_exception_type(frappe.DuplicateEntryError) ) -def make_access_log( +def _make_access_log( doctype=None, document=None, method=None, @@ -42,6 +57,7 @@ def make_access_log( }).db_insert() # `frappe.db.commit` added because insert doesnt `commit` when called in GET requests like `printview` - # dont commit in test mode + # dont commit in test mode. It must be tempting to put this block along with the in_request in the + # whitelisted method...yeah, don't do it. That part would be executed possibly on a read only DB conn if not frappe.flags.in_test or in_request: frappe.db.commit() diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py index 99bd19c106..cd9af498aa 100644 --- a/frappe/core/doctype/comment/test_comment.py +++ b/frappe/core/doctype/comment/test_comment.py @@ -5,6 +5,15 @@ import frappe, json import unittest class TestComment(unittest.TestCase): + def tearDown(self): + frappe.form_dict.comment = None + frappe.form_dict.comment_email = None + frappe.form_dict.comment_by = None + frappe.form_dict.reference_doctype = None + frappe.form_dict.reference_name = None + frappe.form_dict.route = None + frappe.local.request_ip = None + def test_comment_creation(self): test_doc = frappe.get_doc(dict(doctype = 'ToDo', description = 'test')) test_doc.insert() @@ -33,8 +42,16 @@ class TestComment(unittest.TestCase): frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) from frappe.templates.includes.comments.comments import add_comment - add_comment('Good comment with 10 chars', 'test@test.com', 'Good Tester', - 'Blog Post', test_blog.name, test_blog.route) + + frappe.form_dict.comment = 'Good comment with 10 chars' + frappe.form_dict.comment_email = 'test@test.com' + frappe.form_dict.comment_by = 'Good Tester' + frappe.form_dict.reference_doctype = 'Blog Post' + frappe.form_dict.reference_name = test_blog.name + frappe.form_dict.route = test_blog.route + frappe.local.request_ip = '127.0.0.1' + + add_comment() self.assertEqual(frappe.get_all('Comment', fields = ['*'], filters = dict( reference_doctype = test_blog.doctype, @@ -43,8 +60,10 @@ class TestComment(unittest.TestCase): frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) - add_comment('pleez vizits my site http://mysite.com', 'test@test.com', 'bad commentor', - 'Blog Post', test_blog.name, test_blog.route) + frappe.form_dict.comment = 'pleez vizits my site http://mysite.com' + frappe.form_dict.comment_by = 'bad commentor' + + add_comment() self.assertEqual(len(frappe.get_all('Comment', fields = ['*'], filters = dict( reference_doctype = test_blog.doctype, diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json index 849df66a5f..175c64b9eb 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/frappe/core/doctype/communication/communication.json @@ -51,6 +51,7 @@ "email_inbox", "message_id", "uid", + "imap_folder", "email_status", "has_attachment", "feedback_section", @@ -382,12 +383,19 @@ "label": "Timeline Links", "options": "Communication Link", "permlevel": 2 + }, + { + "fieldname": "imap_folder", + "fieldtype": "Data", + "hidden": 1, + "label": "IMAP Folder", + "read_only": 1 } ], "icon": "fa fa-comment", "idx": 1, "links": [], - "modified": "2021-03-25 09:44:28.963538", + "modified": "2021-11-30 09:03:25.728637", "modified_by": "Administrator", "module": "Core", "name": "Communication", diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 3a78a6a599..96c8f271d9 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -488,10 +488,12 @@ def update_parent_document_on_communication(doc): def update_first_response_time(parent, communication): if parent.meta.has_field("first_response_time") and not parent.get("first_response_time"): if is_system_user(communication.sender): - first_responded_on = communication.creation - if parent.meta.has_field("first_responded_on") and communication.sent_or_received == "Sent": - parent.db_set("first_responded_on", first_responded_on) - parent.db_set("first_response_time", round(time_diff_in_seconds(first_responded_on, parent.creation), 2)) + if communication.sent_or_received == "Sent": + first_responded_on = communication.creation + if parent.meta.has_field("first_responded_on"): + parent.db_set("first_responded_on", first_responded_on) + first_response_time = round(time_diff_in_seconds(first_responded_on, parent.creation), 2) + parent.db_set("first_response_time", first_response_time) def set_avg_response_time(parent, communication): if parent.meta.has_field("avg_response_time") and communication.sent_or_received == "Sent": diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 4d22075b78..54ddbce2c4 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -146,25 +146,43 @@ def add_attachments(name, attachments): }) _file.save(ignore_permissions=True) -@frappe.whitelist(allow_guest=True) -def mark_email_as_seen(name=None): +@frappe.whitelist(allow_guest=True, methods=("GET",)) +def mark_email_as_seen(name: str = None): try: - if name and frappe.db.exists("Communication", name) and not frappe.db.get_value("Communication", name, "read_by_recipient"): - frappe.db.set_value("Communication", name, "read_by_recipient", 1) - frappe.db.set_value("Communication", name, "delivery_status", "Read") - frappe.db.set_value("Communication", name, "read_by_recipient_on", get_datetime()) - frappe.db.commit() + update_communication_as_read(name) + frappe.db.commit() # nosemgrep: this will be called in a GET request + except Exception: frappe.log_error(frappe.get_traceback()) - finally: - # Return image as response under all circumstances - from PIL import Image - import io - im = Image.new('RGBA', (1, 1)) - im.putdata([(255,255,255,0)]) - buffered_obj = io.BytesIO() - im.save(buffered_obj, format="PNG") - frappe.response["type"] = 'binary' - frappe.response["filename"] = "imaginary_pixel.png" - frappe.response["filecontent"] = buffered_obj.getvalue() + finally: + frappe.response.update({ + "type": "binary", + "filename": "imaginary_pixel.png", + "filecontent": ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" + b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r" + b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0" + b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82" + ) + }) + +def update_communication_as_read(name): + if not name or not isinstance(name, str): + return + + communication = frappe.db.get_value( + "Communication", + name, + "read_by_recipient", + as_dict=True + ) + + if not communication or communication.read_by_recipient: + return + + frappe.db.set_value("Communication", name, { + "read_by_recipient": 1, + "delivery_status": "Read", + "read_by_recipient_on": get_datetime() + }) diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index b0c8e1fcee..f26e70771b 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -291,6 +291,7 @@ def create_email_account(): "unreplied_for_mins": 20, "send_notification_to": "test_comm@example.com", "pop3_server": "pop.test.example.com", + "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}], "no_remaining":"0", "enable_automatic_linking": 1 }).insert(ignore_permissions=True) diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py index 684328a4c7..c09bd58c25 100644 --- a/frappe/core/doctype/data_import/exporter.py +++ b/frappe/core/doctype/data_import/exporter.py @@ -5,6 +5,7 @@ import typing import frappe +from frappe import _ from frappe.model import ( display_fieldtypes, no_value_fields, @@ -191,7 +192,7 @@ class Exporter: [format_column_name(df) for df in self.fields if df.parent == child_table_doctype] ) ) - data = frappe.db.get_list( + data = frappe.db.get_all( child_table_doctype, filters={ "parent": ("in", parent_names), @@ -215,9 +216,9 @@ class Exporter: for df in self.fields: is_parent = not df.is_child_table_field if is_parent: - label = df.label + label = _(df.label) else: - label = "{0} ({1})".format(df.label, df.child_table_df.label) + label = "{0} ({1})".format(_(df.label), _(df.child_table_df.label)) if label in header: # this label is already in the header, @@ -227,6 +228,7 @@ class Exporter: label = "{0}".format(df.fieldname) else: label = "{0}.{1}".format(df.child_table_df.fieldname, df.fieldname) + header.append(label) self.csv_array.append(header) @@ -253,10 +255,10 @@ class Exporter: self.build_xlsx_response() def build_csv_response(self): - build_csv_response(self.get_csv_array_for_export(), self.doctype) + build_csv_response(self.get_csv_array_for_export(), _(self.doctype)) def build_xlsx_response(self): - build_xlsx_response(self.get_csv_array_for_export(), self.doctype) + build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype)) def group_children_data_by_parent(self, children_data: typing.Dict[str, list]): return groupby_metric(children_data, key='parent') diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index cd20a5c0f3..b9b2050763 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -199,7 +199,7 @@ class Importer: new_doc = frappe.new_doc(self.doctype) new_doc.update(doc) - if (meta.autoname or "").lower() != "prompt": + if not doc.name and (meta.autoname or "").lower() != "prompt": # name can only be set directly if autoname is prompt new_doc.set("name", None) @@ -262,7 +262,7 @@ class Importer: rows = [header_row] rows += [row.data for row in self.import_file.data if row.row_number in row_indexes] - build_csv_response(rows, self.doctype) + build_csv_response(rows, _(self.doctype)) def print_import_log(self, import_log): failed_records = [log for log in import_log if not log.success] @@ -1009,18 +1009,14 @@ def build_fields_dict_for_column_matching(parent_doctype): out = {} # doctypes and fieldname if it is a child doctype - doctypes = [[parent_doctype, None]] + [ - [df.options, df] for df in parent_meta.get_table_fields() + doctypes = [(parent_doctype, None)] + [ + (df.options, df) for df in parent_meta.get_table_fields() ] for doctype, table_df in doctypes: + translated_table_label = _(table_df.label) if table_df else None + # name field - name_by_label = ( - "ID" if doctype == parent_doctype else "ID ({0})".format(table_df.label) - ) - name_by_fieldname = ( - "name" if doctype == parent_doctype else "{0}.name".format(table_df.fieldname) - ) name_df = frappe._dict( { "fieldtype": "Data", @@ -1031,63 +1027,90 @@ def build_fields_dict_for_column_matching(parent_doctype): } ) - if doctype != parent_doctype: + if doctype == parent_doctype: + name_headers = ( + "name", # fieldname + "ID", # label + _("ID"), # translated label + ) + else: + name_headers = ( + "{0}.name".format(table_df.fieldname), # fieldname + "ID ({0})".format(table_df.label), # label + "{0} ({1})".format(_("ID"), translated_table_label), # translated label + ) + name_df.is_child_table_field = True name_df.child_table_df = table_df - out[name_by_label] = name_df - out[name_by_fieldname] = name_df + for header in name_headers: + out[header] = name_df - # other fields fields = get_standard_fields(doctype) + frappe.get_meta(doctype).fields for df in fields: - label = (df.label or "").strip() fieldtype = df.fieldtype or "Data" + if fieldtype in no_value_fields: + continue + + label = (df.label or "").strip() + translated_label = _(label) parent = df.parent or parent_doctype - if fieldtype not in no_value_fields: - if parent_doctype == doctype: - # for parent doctypes keys will be - # Label - # label - # Label (label) - if not out.get(label): - # if Label is already set, don't set it again - # in case of duplicate column headers - out[label] = df - out[df.fieldname] = df - label_with_fieldname = "{0} ({1})".format(label, df.fieldname) - out[label_with_fieldname] = df + + if parent_doctype == doctype: + # for parent doctypes keys will be + # Label, fieldname, Label (fieldname) + + for header in (label, translated_label): + # if Label is already set, don't set it again + # in case of duplicate column headers + if header not in out: + out[header] = df + + for header in ( + df.fieldname, + f"{label} ({df.fieldname})", + f"{translated_label} ({df.fieldname})" + ): + out[header] = df + + else: + # for child doctypes keys will be + # Label (Table Field Label) + # table_field.fieldname + + # create a new df object to avoid mutation problems + if isinstance(df, dict): + new_df = frappe._dict(df.copy()) else: - # in case there are multiple table fields with the same doctype - # for child doctypes keys will be - # Label (Table Field Label) - # table_field.fieldname - table_fields = parent_meta.get( - "fields", {"fieldtype": ["in", table_fieldtypes], "options": parent} - ) - for table_field in table_fields: - by_label = "{0} ({1})".format(label, table_field.label) - by_fieldname = "{0}.{1}".format(table_field.fieldname, df.fieldname) + new_df = df.as_dict() - # create a new df object to avoid mutation problems - if isinstance(df, dict): - new_df = frappe._dict(df.copy()) - else: - new_df = df.as_dict() + new_df.is_child_table_field = True + new_df.child_table_df = table_df - new_df.is_child_table_field = True - new_df.child_table_df = table_field - out[by_label] = new_df - out[by_fieldname] = new_df + for header in ( + # fieldname + "{0}.{1}".format(table_df.fieldname, df.fieldname), + # label + "{0} ({1})".format(label, table_df.label), + # translated label + "{0} ({1})".format(translated_label, translated_table_label), + ): + out[header] = new_df # if autoname is based on field # add an entry for "ID (Autoname Field)" autoname_field = get_autoname_field(parent_doctype) if autoname_field: - out["ID ({})".format(autoname_field.label)] = autoname_field - # ID field should also map to the autoname field - out["ID"] = autoname_field - out["name"] = autoname_field + for header in ( + "ID ({})".format(autoname_field.label), # label + "{0} ({1})".format(_("ID"), _(autoname_field.label)), # translated label + + # ID field should also map to the autoname field + "ID", + _("ID"), + "name", + ): + out[header] = autoname_field return out diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 56c3ff6037..7bbf9422ba 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -20,6 +20,7 @@ "search_index", "column_break_18", "options", + "show_dashboard", "defaults_section", "default", "column_break_6", @@ -97,7 +98,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -526,13 +527,20 @@ { "fieldname": "column_break_35", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Tab Break\"", + "fieldname": "show_dashboard", + "fieldtype": "Check", + "label": "Show Dashboard" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-12-26 23:39:38.341443", + "modified": "2022-01-03 11:56:19.812863", "modified_by": "Administrator", "module": "Core", "name": "DocField", @@ -540,5 +548,6 @@ "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index 262a6efd90..b907ebc0bc 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -1,16 +1,6 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // MIT License. See license.txt -// ------------- -// Menu Display -// ------------- - -// $(cur_frm.wrapper).on("grid-row-render", function(e, grid_row) { -// if(grid_row.doc && grid_row.doc.fieldtype=="Section Break") { -// $(grid_row.row).css({"font-weight": "bold"}); -// } -// }) - frappe.ui.form.on('DocType', { refresh: function(frm) { frm.set_query('role', 'permissions', function(doc) { @@ -129,7 +119,7 @@ frappe.ui.form.on('DocType', { } frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt'); - } + }, }); frappe.ui.form.on("DocField", { @@ -153,11 +143,10 @@ frappe.ui.form.on("DocField", { curr_value.doctype = doctype; curr_value.fieldname = fieldname; } - let curr_df_link_doctype = row.fieldtype == "Link" ? row.options : null; let doctypes = frm.doc.fields .filter(df => df.fieldtype == "Link") - .filter(df => df.options && df.options != curr_df_link_doctype) + .filter(df => df.options && df.fieldname != row.fieldname) .map(df => ({ label: `${df.options} (${df.fieldname})`, value: df.fieldname @@ -217,5 +206,11 @@ frappe.ui.form.on("DocField", { $doctype_select.val(curr_value.doctype); update_fieldname_options(); } + }, + + fieldtype: function(frm) { + frm.trigger("max_attachments"); } }); + +extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm})); diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index e18edc1512..03e3b65ea1 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -1,686 +1,700 @@ { - "actions": [], - "allow_rename": 1, - "autoname": "Prompt", - "creation": "2013-02-18 13:36:19", - "description": "DocType is a Table / Form in the application.", - "doctype": "DocType", - "document_type": "Document", - "engine": "InnoDB", - "field_order": [ - "sb0", - "module", - "is_submittable", - "istable", - "issingle", - "is_tree", - "editable_grid", - "quick_entry", - "cb01", - "track_changes", - "track_seen", - "track_views", - "custom", - "beta", - "is_virtual", - "fields_section_break", - "fields", - "sb1", - "naming_rule", - "autoname", - "name_case", - "allow_rename", - "column_break_15", - "description", - "documentation", - "form_settings_section", - "image_field", - "timeline_field", - "nsm_parent_field", - "max_attachments", - "column_break_23", - "hide_toolbar", - "allow_copy", - "allow_import", - "allow_events_in_timeline", - "allow_auto_repeat", - "view_settings", - "title_field", - "search_fields", - "default_print_format", - "sort_field", - "sort_order", - "column_break_29", - "document_type", - "icon", - "color", - "show_preview_popup", - "show_name_in_global_search", - "email_settings_sb", - "default_email_template", - "column_break_51", - "email_append_to", - "sender_field", - "subject_field", - "sb2", - "permissions", - "restrict_to_domain", - "read_only", - "in_create", - "actions_section", - "actions", - "links_section", - "links", - "web_view", - "has_web_view", - "allow_guest_to_view", - "index_web_pages_for_search", - "route", - "is_published_field", - "website_search_field", - "advanced", - "engine", - "migration_hash" - ], - "fields": [ - { - "fieldname": "sb0", - "fieldtype": "Section Break", - "oldfieldtype": "Section Break" - }, - { - "fieldname": "module", - "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Module", - "oldfieldname": "module", - "oldfieldtype": "Link", - "options": "Module Def", - "reqd": 1, - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.", - "fieldname": "is_submittable", - "fieldtype": "Check", - "label": "Is Submittable" - }, - { - "default": "0", - "description": "Child Tables are shown as a Grid in other DocTypes", - "fieldname": "istable", - "fieldtype": "Check", - "in_standard_filter": 1, - "label": "Is Child Table", - "oldfieldname": "istable", - "oldfieldtype": "Check" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "Single Types have only one record no tables associated. Values are stored in tabSingles", - "fieldname": "issingle", - "fieldtype": "Check", - "in_standard_filter": 1, - "label": "Is Single", - "oldfieldname": "issingle", - "oldfieldtype": "Check", - "set_only_once": 1 - }, - { - "default": "1", - "depends_on": "istable", - "fieldname": "editable_grid", - "fieldtype": "Check", - "label": "Editable Grid" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable && !doc.issingle", - "description": "Open a dialog with mandatory fields to create a new record quickly", - "fieldname": "quick_entry", - "fieldtype": "Check", - "label": "Quick Entry" - }, - { - "fieldname": "cb01", - "fieldtype": "Column Break" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "If enabled, changes to the document are tracked and shown in timeline", - "fieldname": "track_changes", - "fieldtype": "Check", - "label": "Track Changes" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "If enabled, the document is marked as seen, the first time a user opens it", - "fieldname": "track_seen", - "fieldtype": "Check", - "label": "Track Seen" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "If enabled, document views are tracked, this can happen multiple times", - "fieldname": "track_views", - "fieldtype": "Check", - "label": "Track Views" - }, - { - "default": "0", - "fieldname": "custom", - "fieldtype": "Check", - "label": "Custom?" - }, - { - "default": "0", - "fieldname": "beta", - "fieldtype": "Check", - "label": "Beta" - }, - { - "fieldname": "fields_section_break", - "fieldtype": "Section Break", - "label": "Fields", - "oldfieldtype": "Section Break" - }, - { - "fieldname": "fields", - "fieldtype": "Table", - "label": "Fields", - "oldfieldname": "fields", - "oldfieldtype": "Table", - "options": "DocField" - }, - { - "fieldname": "sb1", - "fieldtype": "Section Break", - "label": "Naming" - }, - { - "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", - "fieldname": "autoname", - "fieldtype": "Data", - "label": "Auto Name", - "oldfieldname": "autoname", - "oldfieldtype": "Data" - }, - { - "fieldname": "name_case", - "fieldtype": "Select", - "label": "Name Case", - "oldfieldname": "name_case", - "oldfieldtype": "Select", - "options": "\nTitle Case\nUPPER CASE" - }, - { - "fieldname": "column_break_15", - "fieldtype": "Column Break" - }, - { - "fieldname": "description", - "fieldtype": "Small Text", - "label": "Description", - "oldfieldname": "description", - "oldfieldtype": "Text" - }, - { - "collapsible": 1, - "fieldname": "form_settings_section", - "fieldtype": "Section Break", - "label": "Form Settings" - }, - { - "description": "Must be of type \"Attach Image\"", - "fieldname": "image_field", - "fieldtype": "Data", - "label": "Image Field" - }, - { - "depends_on": "eval:!doc.istable", - "description": "Comments and Communications will be associated with this linked document", - "fieldname": "timeline_field", - "fieldtype": "Data", - "label": "Timeline Field" - }, - { - "fieldname": "max_attachments", - "fieldtype": "Int", - "label": "Max Attachments", - "oldfieldname": "max_attachments", - "oldfieldtype": "Int" - }, - { - "fieldname": "column_break_23", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "hide_toolbar", - "fieldtype": "Check", - "label": "Hide Sidebar and Menu", - "oldfieldname": "hide_toolbar", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_copy", - "fieldtype": "Check", - "label": "Hide Copy", - "oldfieldname": "allow_copy", - "oldfieldtype": "Check" - }, - { - "default": "1", - "fieldname": "allow_rename", - "fieldtype": "Check", - "label": "Allow Rename", - "oldfieldname": "allow_rename", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_import", - "fieldtype": "Check", - "label": "Allow Import (via Data Import Tool)" - }, - { - "default": "0", - "fieldname": "allow_events_in_timeline", - "fieldtype": "Check", - "label": "Allow events in timeline" - }, - { - "default": "0", - "fieldname": "allow_auto_repeat", - "fieldtype": "Check", - "label": "Allow Auto Repeat" - }, - { - "collapsible": 1, - "fieldname": "view_settings", - "fieldtype": "Section Break", - "label": "View Settings" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "title_field", - "fieldtype": "Data", - "label": "Title Field" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "search_fields", - "fieldtype": "Data", - "label": "Search Fields", - "oldfieldname": "search_fields", - "oldfieldtype": "Data" - }, - { - "fieldname": "default_print_format", - "fieldtype": "Data", - "label": "Default Print Format" - }, - { - "default": "modified", - "depends_on": "eval:!doc.istable", - "fieldname": "sort_field", - "fieldtype": "Data", - "label": "Default Sort Field" - }, - { - "default": "DESC", - "depends_on": "eval:!doc.istable", - "fieldname": "sort_order", - "fieldtype": "Select", - "label": "Default Sort Order", - "options": "ASC\nDESC" - }, - { - "fieldname": "column_break_29", - "fieldtype": "Column Break" - }, - { - "fieldname": "document_type", - "fieldtype": "Select", - "label": "Show in Module Section", - "oldfieldname": "document_type", - "oldfieldtype": "Select", - "options": "\nDocument\nSetup\nSystem\nOther" - }, - { - "fieldname": "icon", - "fieldtype": "Data", - "label": "Icon" - }, - { - "fieldname": "color", - "fieldtype": "Data", - "label": "Color" - }, - { - "default": "0", - "fieldname": "show_preview_popup", - "fieldtype": "Check", - "label": "Show Preview Popup" - }, - { - "default": "0", - "fieldname": "show_name_in_global_search", - "fieldtype": "Check", - "label": "Make \"name\" searchable in Global Search" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "sb2", - "fieldtype": "Section Break", - "label": "Permission Rules" - }, - { - "fieldname": "permissions", - "fieldtype": "Table", - "label": "Permissions", - "oldfieldname": "permissions", - "oldfieldtype": "Table", - "options": "DocPerm" - }, - { - "fieldname": "restrict_to_domain", - "fieldtype": "Link", - "label": "Restrict To Domain", - "options": "Domain" - }, - { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "User Cannot Search", - "oldfieldname": "read_only", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "in_create", - "fieldtype": "Check", - "label": "User Cannot Create", - "oldfieldname": "in_create", - "oldfieldtype": "Check" - }, - { - "depends_on": "eval:doc.custom===0", - "fieldname": "web_view", - "fieldtype": "Section Break", - "label": "Web View" - }, - { - "default": "0", - "fieldname": "has_web_view", - "fieldtype": "Check", - "label": "Has Web View" - }, - { - "default": "0", - "depends_on": "has_web_view", - "fieldname": "allow_guest_to_view", - "fieldtype": "Check", - "label": "Allow Guest to View" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "route", - "fieldtype": "Data", - "label": "Route" - }, - { - "depends_on": "has_web_view", - "fieldname": "is_published_field", - "fieldtype": "Data", - "label": "Is Published Field" - }, - { - "collapsible": 1, - "fieldname": "advanced", - "fieldtype": "Section Break", - "hidden": 1, - "label": "Advanced" - }, - { - "default": "InnoDB", - "depends_on": "eval:!doc.issingle", - "fieldname": "engine", - "fieldtype": "Select", - "label": "Database Engine", - "options": "InnoDB\nMyISAM" - }, - { - "default": "0", - "description": "Tree structures are implemented using Nested Set", - "fieldname": "is_tree", - "fieldtype": "Check", - "label": "Is Tree" - }, - { - "depends_on": "is_tree", - "fieldname": "nsm_parent_field", - "fieldtype": "Data", - "label": "Parent Field (Tree)" - }, - { - "description": "URL for documentation or help", - "fieldname": "documentation", - "fieldtype": "Data", - "label": "Documentation Link" - }, - { - "collapsible": 1, - "collapsible_depends_on": "actions", - "fieldname": "actions_section", - "fieldtype": "Section Break", - "label": "Actions" - }, - { - "fieldname": "actions", - "fieldtype": "Table", - "label": "Actions", - "options": "DocType Action" - }, - { - "collapsible": 1, - "collapsible_depends_on": "links", - "fieldname": "links_section", - "fieldtype": "Section Break", - "label": "Linked Documents" - }, - { - "fieldname": "links", - "fieldtype": "Table", - "label": "Links", - "options": "DocType Link" - }, - { - "depends_on": "email_append_to", - "fieldname": "subject_field", - "fieldtype": "Data", - "label": "Subject Field" - }, - { - "depends_on": "email_append_to", - "fieldname": "sender_field", - "fieldtype": "Data", - "label": "Sender Field", - "mandatory_depends_on": "email_append_to" - }, - { - "default": "0", - "fieldname": "email_append_to", - "fieldtype": "Check", - "label": "Allow document creation via Email" - }, - { - "collapsible": 1, - "fieldname": "email_settings_sb", - "fieldtype": "Section Break", - "label": "Email Settings" - }, - { - "default": "1", - "fieldname": "index_web_pages_for_search", - "fieldtype": "Check", - "label": "Index Web Pages for Search" - }, - { - "default": "0", - "fieldname": "is_virtual", - "fieldtype": "Check", - "label": "Is Virtual" - }, - { - "fieldname": "default_email_template", - "fieldtype": "Link", - "label": "Default Email Template", - "options": "Email Template" - }, - { - "fieldname": "column_break_51", - "fieldtype": "Column Break" - }, - { - "depends_on": "has_web_view", - "fieldname": "website_search_field", - "fieldtype": "Data", - "label": "Website Search Field" - }, - { - "fieldname": "naming_rule", - "fieldtype": "Select", - "label": "Naming Rule", - "length": 40, - "options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" - }, - { - "fieldname": "migration_hash", - "fieldtype": "Data", - "hidden": 1 - } - ], - "icon": "fa fa-bolt", - "idx": 6, - "links": [ - { - "group": "Views", - "link_doctype": "Report", - "link_fieldname": "ref_doctype" - }, - { - "group": "Workflow", - "link_doctype": "Workflow", - "link_fieldname": "document_type" - }, - { - "group": "Workflow", - "link_doctype": "Notification", - "link_fieldname": "document_type" - }, - { - "group": "Customization", - "link_doctype": "Custom Field", - "link_fieldname": "dt" - }, - { - "group": "Customization", - "link_doctype": "Client Script", - "link_fieldname": "dt" - }, - { - "group": "Customization", - "link_doctype": "Server Script", - "link_fieldname": "reference_doctype" - }, - { - "group": "Workflow", - "link_doctype": "Webhook", - "link_fieldname": "webhook_doctype" - }, - { - "group": "Views", - "link_doctype": "Print Format", - "link_fieldname": "doc_type" - }, - { - "group": "Views", - "link_doctype": "Web Form", - "link_fieldname": "doc_type" - }, - { - "group": "Views", - "link_doctype": "Calendar View", - "link_fieldname": "reference_doctype" - }, - { - "group": "Views", - "link_doctype": "Kanban Board", - "link_fieldname": "reference_doctype" - }, - { - "group": "Workflow", - "link_doctype": "Onboarding Step", - "link_fieldname": "reference_document" - }, - { - "group": "Rules", - "link_doctype": "Auto Repeat", - "link_fieldname": "reference_doctype" - }, - { - "group": "Rules", - "link_doctype": "Assignment Rule", - "link_fieldname": "document_type" - }, - { - "group": "Rules", - "link_doctype": "Energy Point Rule", - "link_fieldname": "reference_doctype" - } - ], - "modified": "2021-10-29 11:39:13.233403", - "modified_by": "Administrator", - "module": "Core", - "name": "DocType", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Administrator", - "share": 1, - "write": 1 - } - ], - "route": "doctype", - "search_fields": "module", - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2013-02-18 13:36:19", + "description": "DocType is a Table / Form in the application.", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "sb0", + "module", + "is_submittable", + "istable", + "issingle", + "is_tree", + "editable_grid", + "quick_entry", + "cb01", + "track_changes", + "track_seen", + "track_views", + "custom", + "beta", + "is_virtual", + "fields_section_break", + "fields", + "sb1", + "naming_rule", + "autoname", + "name_case", + "allow_rename", + "column_break_15", + "description", + "documentation", + "form_settings_section", + "image_field", + "timeline_field", + "nsm_parent_field", + "max_attachments", + "column_break_23", + "hide_toolbar", + "allow_copy", + "allow_import", + "allow_events_in_timeline", + "allow_auto_repeat", + "view_settings", + "title_field", + "search_fields", + "default_print_format", + "sort_field", + "sort_order", + "column_break_29", + "document_type", + "icon", + "color", + "show_preview_popup", + "show_name_in_global_search", + "email_settings_sb", + "default_email_template", + "column_break_51", + "email_append_to", + "sender_field", + "subject_field", + "sb2", + "permissions", + "restrict_to_domain", + "read_only", + "in_create", + "actions_section", + "actions", + "links_section", + "links", + "document_states_section", + "states", + "web_view", + "has_web_view", + "allow_guest_to_view", + "index_web_pages_for_search", + "route", + "is_published_field", + "website_search_field", + "advanced", + "engine", + "migration_hash" + ], + "fields": [ + { + "fieldname": "sb0", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Module", + "oldfieldname": "module", + "oldfieldtype": "Link", + "options": "Module Def", + "reqd": 1, + "search_index": 1 + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.", + "fieldname": "is_submittable", + "fieldtype": "Check", + "label": "Is Submittable" + }, + { + "default": "0", + "description": "Child Tables are shown as a Grid in other DocTypes", + "fieldname": "istable", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Is Child Table", + "oldfieldname": "istable", + "oldfieldtype": "Check" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "Single Types have only one record no tables associated. Values are stored in tabSingles", + "fieldname": "issingle", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Is Single", + "oldfieldname": "issingle", + "oldfieldtype": "Check", + "set_only_once": 1 + }, + { + "default": "1", + "depends_on": "istable", + "fieldname": "editable_grid", + "fieldtype": "Check", + "label": "Editable Grid" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable && !doc.issingle", + "description": "Open a dialog with mandatory fields to create a new record quickly", + "fieldname": "quick_entry", + "fieldtype": "Check", + "label": "Quick Entry" + }, + { + "fieldname": "cb01", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "If enabled, changes to the document are tracked and shown in timeline", + "fieldname": "track_changes", + "fieldtype": "Check", + "label": "Track Changes" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "If enabled, the document is marked as seen, the first time a user opens it", + "fieldname": "track_seen", + "fieldtype": "Check", + "label": "Track Seen" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "If enabled, document views are tracked, this can happen multiple times", + "fieldname": "track_views", + "fieldtype": "Check", + "label": "Track Views" + }, + { + "default": "0", + "fieldname": "custom", + "fieldtype": "Check", + "label": "Custom?" + }, + { + "default": "0", + "fieldname": "beta", + "fieldtype": "Check", + "label": "Beta" + }, + { + "fieldname": "fields_section_break", + "fieldtype": "Section Break", + "label": "Fields", + "oldfieldtype": "Section Break" + }, + { + "fieldname": "fields", + "fieldtype": "Table", + "label": "Fields", + "oldfieldname": "fields", + "oldfieldtype": "Table", + "options": "DocField" + }, + { + "fieldname": "sb1", + "fieldtype": "Section Break", + "label": "Naming" + }, + { + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", + "fieldname": "autoname", + "fieldtype": "Data", + "label": "Auto Name", + "oldfieldname": "autoname", + "oldfieldtype": "Data" + }, + { + "fieldname": "name_case", + "fieldtype": "Select", + "label": "Name Case", + "oldfieldname": "name_case", + "oldfieldtype": "Select", + "options": "\nTitle Case\nUPPER CASE" + }, + { + "fieldname": "column_break_15", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text" + }, + { + "collapsible": 1, + "fieldname": "form_settings_section", + "fieldtype": "Section Break", + "label": "Form Settings" + }, + { + "description": "Must be of type \"Attach Image\"", + "fieldname": "image_field", + "fieldtype": "Data", + "label": "Image Field" + }, + { + "depends_on": "eval:!doc.istable", + "description": "Comments and Communications will be associated with this linked document", + "fieldname": "timeline_field", + "fieldtype": "Data", + "label": "Timeline Field" + }, + { + "fieldname": "max_attachments", + "fieldtype": "Int", + "label": "Max Attachments", + "oldfieldname": "max_attachments", + "oldfieldtype": "Int" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "hide_toolbar", + "fieldtype": "Check", + "label": "Hide Sidebar and Menu", + "oldfieldname": "hide_toolbar", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "allow_copy", + "fieldtype": "Check", + "label": "Hide Copy", + "oldfieldname": "allow_copy", + "oldfieldtype": "Check" + }, + { + "default": "1", + "fieldname": "allow_rename", + "fieldtype": "Check", + "label": "Allow Rename", + "oldfieldname": "allow_rename", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "allow_import", + "fieldtype": "Check", + "label": "Allow Import (via Data Import Tool)" + }, + { + "default": "0", + "fieldname": "allow_events_in_timeline", + "fieldtype": "Check", + "label": "Allow events in timeline" + }, + { + "default": "0", + "fieldname": "allow_auto_repeat", + "fieldtype": "Check", + "label": "Allow Auto Repeat" + }, + { + "collapsible": 1, + "fieldname": "view_settings", + "fieldtype": "Section Break", + "label": "View Settings" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "title_field", + "fieldtype": "Data", + "label": "Title Field" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "search_fields", + "fieldtype": "Data", + "label": "Search Fields", + "oldfieldname": "search_fields", + "oldfieldtype": "Data" + }, + { + "fieldname": "default_print_format", + "fieldtype": "Data", + "label": "Default Print Format" + }, + { + "default": "modified", + "depends_on": "eval:!doc.istable", + "fieldname": "sort_field", + "fieldtype": "Data", + "label": "Default Sort Field" + }, + { + "default": "DESC", + "depends_on": "eval:!doc.istable", + "fieldname": "sort_order", + "fieldtype": "Select", + "label": "Default Sort Order", + "options": "ASC\nDESC" + }, + { + "fieldname": "column_break_29", + "fieldtype": "Column Break" + }, + { + "fieldname": "document_type", + "fieldtype": "Select", + "label": "Show in Module Section", + "oldfieldname": "document_type", + "oldfieldtype": "Select", + "options": "\nDocument\nSetup\nSystem\nOther" + }, + { + "fieldname": "icon", + "fieldtype": "Data", + "label": "Icon" + }, + { + "fieldname": "color", + "fieldtype": "Data", + "label": "Color" + }, + { + "default": "0", + "fieldname": "show_preview_popup", + "fieldtype": "Check", + "label": "Show Preview Popup" + }, + { + "default": "0", + "fieldname": "show_name_in_global_search", + "fieldtype": "Check", + "label": "Make \"name\" searchable in Global Search" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "sb2", + "fieldtype": "Section Break", + "label": "Permission Rules" + }, + { + "fieldname": "permissions", + "fieldtype": "Table", + "label": "Permissions", + "oldfieldname": "permissions", + "oldfieldtype": "Table", + "options": "DocPerm" + }, + { + "fieldname": "restrict_to_domain", + "fieldtype": "Link", + "label": "Restrict To Domain", + "options": "Domain" + }, + { + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "User Cannot Search", + "oldfieldname": "read_only", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "in_create", + "fieldtype": "Check", + "label": "User Cannot Create", + "oldfieldname": "in_create", + "oldfieldtype": "Check" + }, + { + "depends_on": "eval:doc.custom===0", + "fieldname": "web_view", + "fieldtype": "Section Break", + "label": "Web View" + }, + { + "default": "0", + "fieldname": "has_web_view", + "fieldtype": "Check", + "label": "Has Web View" + }, + { + "default": "0", + "depends_on": "has_web_view", + "fieldname": "allow_guest_to_view", + "fieldtype": "Check", + "label": "Allow Guest to View" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "route", + "fieldtype": "Data", + "label": "Route" + }, + { + "depends_on": "has_web_view", + "fieldname": "is_published_field", + "fieldtype": "Data", + "label": "Is Published Field" + }, + { + "collapsible": 1, + "fieldname": "advanced", + "fieldtype": "Section Break", + "hidden": 1, + "label": "Advanced" + }, + { + "default": "InnoDB", + "depends_on": "eval:!doc.issingle", + "fieldname": "engine", + "fieldtype": "Select", + "label": "Database Engine", + "options": "InnoDB\nMyISAM" + }, + { + "default": "0", + "description": "Tree structures are implemented using Nested Set", + "fieldname": "is_tree", + "fieldtype": "Check", + "label": "Is Tree" + }, + { + "depends_on": "is_tree", + "fieldname": "nsm_parent_field", + "fieldtype": "Data", + "label": "Parent Field (Tree)" + }, + { + "description": "URL for documentation or help", + "fieldname": "documentation", + "fieldtype": "Data", + "label": "Documentation Link" + }, + { + "collapsible": 1, + "collapsible_depends_on": "actions", + "fieldname": "actions_section", + "fieldtype": "Section Break", + "label": "Actions" + }, + { + "fieldname": "actions", + "fieldtype": "Table", + "label": "Actions", + "options": "DocType Action" + }, + { + "collapsible": 1, + "collapsible_depends_on": "links", + "fieldname": "links_section", + "fieldtype": "Section Break", + "label": "Linked Documents" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "DocType Link" + }, + { + "depends_on": "email_append_to", + "fieldname": "subject_field", + "fieldtype": "Data", + "label": "Subject Field" + }, + { + "depends_on": "email_append_to", + "fieldname": "sender_field", + "fieldtype": "Data", + "label": "Sender Field", + "mandatory_depends_on": "email_append_to" + }, + { + "default": "0", + "fieldname": "email_append_to", + "fieldtype": "Check", + "label": "Allow document creation via Email" + }, + { + "collapsible": 1, + "fieldname": "email_settings_sb", + "fieldtype": "Section Break", + "label": "Email Settings" + }, + { + "default": "1", + "fieldname": "index_web_pages_for_search", + "fieldtype": "Check", + "label": "Index Web Pages for Search" + }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Is Virtual" + }, + { + "fieldname": "default_email_template", + "fieldtype": "Link", + "label": "Default Email Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_51", + "fieldtype": "Column Break" + }, + { + "depends_on": "has_web_view", + "fieldname": "website_search_field", + "fieldtype": "Data", + "label": "Website Search Field" + }, + { + "fieldname": "naming_rule", + "fieldtype": "Select", + "label": "Naming Rule", + "length": 40, + "options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" + }, + { + "fieldname": "migration_hash", + "fieldtype": "Data", + "hidden": 1 + }, + { + "fieldname": "states", + "fieldtype": "Table", + "label": "States", + "options": "DocType State" + }, + { + "collapsible": 1, + "fieldname": "document_states_section", + "fieldtype": "Section Break", + "label": "Document States" + } + ], + "icon": "fa fa-bolt", + "idx": 6, + "links": [ + { + "group": "Views", + "link_doctype": "Report", + "link_fieldname": "ref_doctype" + }, + { + "group": "Workflow", + "link_doctype": "Workflow", + "link_fieldname": "document_type" + }, + { + "group": "Workflow", + "link_doctype": "Notification", + "link_fieldname": "document_type" + }, + { + "group": "Customization", + "link_doctype": "Custom Field", + "link_fieldname": "dt" + }, + { + "group": "Customization", + "link_doctype": "Client Script", + "link_fieldname": "dt" + }, + { + "group": "Customization", + "link_doctype": "Server Script", + "link_fieldname": "reference_doctype" + }, + { + "group": "Workflow", + "link_doctype": "Webhook", + "link_fieldname": "webhook_doctype" + }, + { + "group": "Views", + "link_doctype": "Print Format", + "link_fieldname": "doc_type" + }, + { + "group": "Views", + "link_doctype": "Web Form", + "link_fieldname": "doc_type" + }, + { + "group": "Views", + "link_doctype": "Calendar View", + "link_fieldname": "reference_doctype" + }, + { + "group": "Views", + "link_doctype": "Kanban Board", + "link_fieldname": "reference_doctype" + }, + { + "group": "Workflow", + "link_doctype": "Onboarding Step", + "link_fieldname": "reference_document" + }, + { + "group": "Rules", + "link_doctype": "Auto Repeat", + "link_fieldname": "reference_doctype" + }, + { + "group": "Rules", + "link_doctype": "Assignment Rule", + "link_fieldname": "document_type" + }, + { + "group": "Rules", + "link_doctype": "Energy Point Rule", + "link_fieldname": "reference_doctype" + } + ], + "modified": "2021-12-09 14:53:10.717788", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 + } + ], + "route": "doctype", + "search_fields": "module", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 738fb73a34..3754288145 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -75,6 +75,7 @@ class DocType(Document): self.make_repeatable() self.validate_nestedset() self.validate_website() + self.ensure_minimum_max_attachment_limit() validate_links_table_fieldnames(self) if not self.is_new(): @@ -246,6 +247,22 @@ class DocType(Document): # clear website cache clear_cache() + def ensure_minimum_max_attachment_limit(self): + """Ensure that max_attachments is *at least* bigger than number of attach fields.""" + from frappe.model import attachment_fieldtypes + + + if not self.max_attachments: + return + + total_attach_fields = len([d for d in self.fields if d.fieldtype in attachment_fieldtypes]) + if total_attach_fields > self.max_attachments: + self.max_attachments = total_attach_fields + field_label = frappe.bold(self.meta.get_field("max_attachments").label) + frappe.msgprint(_("Number of attachment fields are more than {}, limit updated to {}.") + .format(field_label, total_attach_fields), + title=_("Insufficient attachment limit"), alert=True) + def change_modified_of_parent(self): """Change the timestamp of parent DocType if the current one is a child to clear caches.""" if frappe.flags.in_import: @@ -253,7 +270,7 @@ class DocType(Document): parent_list = frappe.db.get_all('DocField', 'parent', dict(fieldtype=['in', frappe.model.table_fields], options=self.name)) for p in parent_list: - frappe.db.sql('UPDATE `tabDocType` SET modified=%s WHERE `name`=%s', (now(), p.parent)) + frappe.db.update("DocType", p.parent, {}, for_update=False) def scrub_field_names(self): """Sluggify fieldnames if not set from Label.""" @@ -364,7 +381,7 @@ class DocType(Document): document_cls_tag = f"class {despaced_name}(Document)" document_import_tag = "from frappe.model.document import Document" website_generator_cls_tag = f"class {despaced_name}(WebsiteGenerator)" - website_generator_import_tag = "from frappe.website.generators.website_generator import WebsiteGenerator" + website_generator_import_tag = "from frappe.website.website_generator import WebsiteGenerator" with open(controller_path) as f: code = f.read() @@ -1057,6 +1074,11 @@ def validate_fields(meta): if getattr(docfield, 'max_height', None) and (docfield.max_height[-2:] not in ('px', 'em')): frappe.throw('Max for {} height must be in px, em, rem'.format(frappe.bold(docfield.fieldname))) + def check_no_of_ratings(docfield): + if docfield.fieldtype == "Rating": + if docfield.options and (int(docfield.options) > 10 or int(docfield.options) < 3): + frappe.throw(_('Options for Rating field can range from 3 to 10')) + fields = meta.get("fields") fieldname_list = [d.fieldname for d in fields] @@ -1090,6 +1112,7 @@ def validate_fields(meta): scrub_fetch_from(d) validate_data_field_type(d) check_max_height(d) + check_no_of_ratings(d) check_fold(fields) check_search_fields(meta, fields) @@ -1260,7 +1283,7 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): roles = [p.role for p in doc.get("permissions") or []] + default_roles for role in list(set(roles)): - if not frappe.db.exists("Role", role): + if frappe.db.table_exists("Role", cached=False) and not frappe.db.exists("Role", role): r = frappe.get_doc(dict(doctype= "Role", role_name=role, desk_access=1)) r.flags.ignore_mandatory = r.flags.ignore_permissions = True r.insert() diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 4362a52c34..12c227464d 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -15,6 +15,10 @@ from frappe.core.doctype.doctype.doctype import (UniqueFieldnameError, # test_records = frappe.get_test_records('DocType') class TestDocType(unittest.TestCase): + + def tearDown(self): + frappe.db.rollback() + def test_validate_name(self): self.assertRaises(frappe.NameError, new_doctype("_Some DocType").insert) self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert) @@ -42,6 +46,7 @@ class TestDocType(unittest.TestCase): doc1.insert() self.assertRaises(frappe.UniqueValidationError, doc2.insert) + frappe.db.rollback() dt.fields[0].unique = 0 dt.save() diff --git a/frappe/chat/doctype/__init__.py b/frappe/core/doctype/doctype_state/__init__.py similarity index 100% rename from frappe/chat/doctype/__init__.py rename to frappe/core/doctype/doctype_state/__init__.py diff --git a/frappe/core/doctype/doctype_state/doctype_state.json b/frappe/core/doctype/doctype_state/doctype_state.json new file mode 100644 index 0000000000..79797b41c5 --- /dev/null +++ b/frappe/core/doctype/doctype_state/doctype_state.json @@ -0,0 +1,50 @@ +{ + "actions": [], + "creation": "2021-08-23 17:21:28.345841", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "color", + "custom" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "default": "Blue", + "fieldname": "color", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Color", + "options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nYellow", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "custom", + "fieldtype": "Check", + "hidden": 1, + "label": "Custom" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-12-14 14:14:55.716378", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType State", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype_state/doctype_state.py b/frappe/core/doctype/doctype_state/doctype_state.py new file mode 100644 index 0000000000..3172834180 --- /dev/null +++ b/frappe/core/doctype/doctype_state/doctype_state.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class DocTypeState(Document): + pass diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json index b77e7a6677..f8380cfda6 100644 --- a/frappe/core/doctype/feedback/feedback.json +++ b/frappe/core/doctype/feedback/feedback.json @@ -8,34 +8,14 @@ "reference_doctype", "reference_name", "column_break_3", - "rating", - "ip_address", - "section_break_6", - "feedback" + "like", + "ip_address" ], "fields": [ { "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "rating", - "fieldtype": "Float", - "in_list_view": 1, - "label": "Rating", - "precision": "1", - "reqd": 1 - }, - { - "fieldname": "section_break_6", - "fieldtype": "Section Break" - }, - { - "fieldname": "feedback", - "fieldtype": "Small Text", - "label": "Feedback", - "reqd": 1 - }, { "fieldname": "reference_doctype", "fieldtype": "Select", @@ -57,11 +37,17 @@ "hidden": 1, "label": "IP Address", "read_only": 1 + }, + { + "default": "0", + "fieldname": "like", + "fieldtype": "Check", + "label": "Like" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-06-23 12:45:42.045696", + "modified": "2021-11-10 20:53:21.255593", "modified_by": "Administrator", "module": "Core", "name": "Feedback", diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py index f3cf8dfe6b..66f644ccd3 100644 --- a/frappe/core/doctype/feedback/test_feedback.py +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -8,8 +8,7 @@ class TestFeedback(unittest.TestCase): def tearDown(self): frappe.form_dict.reference_doctype = None frappe.form_dict.reference_name = None - frappe.form_dict.rating = None - frappe.form_dict.feedback = None + frappe.form_dict.like = None frappe.local.request_ip = None def test_feedback_creation_updation(self): @@ -18,23 +17,22 @@ class TestFeedback(unittest.TestCase): frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) - from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback + from frappe.templates.includes.feedback.feedback import give_feedback frappe.form_dict.reference_doctype = 'Blog Post' frappe.form_dict.reference_name = test_blog.name - frappe.form_dict.rating = 5 - frappe.form_dict.feedback = 'New feedback' + frappe.form_dict.like = True frappe.local.request_ip = '127.0.0.1' - feedback = add_feedback() + feedback = give_feedback() - self.assertEqual(feedback.feedback, 'New feedback') - self.assertEqual(feedback.rating, 5) + self.assertEqual(feedback.like, True) - updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback') + frappe.form_dict.like = False - self.assertEqual(updated_feedback.feedback, 'Updated feedback') - self.assertEqual(updated_feedback.rating, 6) + updated_feedback = give_feedback() + + self.assertEqual(updated_feedback.like, False) frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 4df9ef3132..adf10b9a03 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -29,6 +29,7 @@ from frappe import _, conf, safe_decode from frappe.model.document import Document from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip from frappe.utils.image import strip_exif_data, optimize_image +from frappe.utils.file_manager import safe_b64decode class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -436,7 +437,7 @@ class File(Document): if b"," in self.content: self.content = self.content.split(b",")[1] - self.content = base64.b64decode(self.content) + self.content = safe_b64decode(self.content) if not self.is_private: self.is_private = 0 @@ -569,6 +570,24 @@ class File(Document): frappe.local.rollback_observers.append(self) self.save() + @staticmethod + def zip_files(files): + from six import string_types + + zip_file = io.BytesIO() + zf = zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) + for _file in files: + if isinstance(_file, string_types): + _file = frappe.get_doc("File", _file) + if not isinstance(_file, File): + continue + if _file.is_folder: + continue + zf.writestr(_file.file_name, _file.get_content()) + zf.close() + return zip_file.getvalue() + + def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) @@ -612,6 +631,16 @@ def move_file(file_list, new_parent, old_parent): frappe.get_doc("File", old_parent).save() frappe.get_doc("File", new_parent).save() + +@frappe.whitelist() +def zip_files(files): + files = frappe.parse_json(files) + zipped_files = File.zip_files(files) + frappe.response["filename"] = "files.zip" + frappe.response["filecontent"] = zipped_files + frappe.response["type"] = "download" + + def setup_folder_path(filename, new_parent): file = frappe.get_doc("File", filename) file.folder = new_parent @@ -716,13 +745,11 @@ def delete_file(path): os.remove(path) - - +@frappe.whitelist() def get_max_file_size(): return cint(conf.get('max_file_size')) or 10485760 - def has_permission(doc, ptype=None, user=None): has_access = False user = user or frappe.session.user @@ -826,7 +853,7 @@ def extract_images_from_html(doc, content, is_private=False): content = content.encode("utf-8") if b"," in content: content = content.split(b",")[1] - content = base64.b64decode(content) + content = safe_b64decode(content) content = optimize_image(content, mtype) @@ -942,20 +969,14 @@ def get_files_by_search_text(text): def update_existing_file_docs(doc): # Update is private and file url of all file docs that point to the same file - frappe.db.sql(""" - UPDATE `tabFile` - SET - file_url = %(file_url)s, - is_private = %(is_private)s - WHERE - content_hash = %(content_hash)s - and name != %(file_name)s - """, dict( - file_url=doc.file_url, - is_private=doc.is_private, - content_hash=doc.content_hash, - file_name=doc.name - )) + file_doctype = frappe.qb.DocType("File") + ( + frappe.qb.update(file_doctype) + .set(file_doctype.file_url, doc.file_url) + .set(file_doctype.is_private, doc.is_private) + .where(file_doctype.content_hash == doc.content_hash) + .where(file_doctype.name != doc.name) + ).run() def attach_files_to_document(doc, event): """ Runs on on_update hook of all documents. diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 9a758b53f5..2c1042e104 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -18,6 +18,7 @@ test_content2 = 'Hello World' def make_test_doc(): d = frappe.new_doc('ToDo') d.description = 'Test' + d.assigned_by = frappe.session.user d.save() return d.doctype, d.name diff --git a/frappe/core/doctype/module_def/module_def.json b/frappe/core/doctype/module_def/module_def.json index 7ddc55fce5..12830c8b4f 100644 --- a/frappe/core/doctype/module_def/module_def.json +++ b/frappe/core/doctype/module_def/module_def.json @@ -10,7 +10,8 @@ "custom", "package", "app_name", - "restrict_to_domain" + "restrict_to_domain", + "connections_tab" ], "fields": [ { @@ -50,6 +51,12 @@ "fieldtype": "Link", "label": "Package", "options": "Package" + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 } ], "icon": "fa fa-sitemap", @@ -116,7 +123,7 @@ "link_fieldname": "module" } ], - "modified": "2021-09-05 21:58:40.253909", + "modified": "2022-01-03 13:56:52.817954", "modified_by": "Administrator", "module": "Core", "name": "Module Def", @@ -154,5 +161,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/module_profile/module_profile.js b/frappe/core/doctype/module_profile/module_profile.js index 9c92042dda..3714d31ade 100644 --- a/frappe/core/doctype/module_profile/module_profile.js +++ b/frappe/core/doctype/module_profile/module_profile.js @@ -1,19 +1,23 @@ // Copyright (c) 2020, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Module Profile', { - refresh: function(frm) { +frappe.ui.form.on("Module Profile", { + refresh: function (frm) { if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) { if (!frm.module_editor && frm.doc.__onload && frm.doc.__onload.all_modules) { - let module_area = $('
') - .appendTo(frm.fields_dict.module_html.wrapper); - + const module_area = $(frm.fields_dict.module_html.wrapper); frm.module_editor = new frappe.ModuleEditor(frm, module_area); } } if (frm.module_editor) { - frm.module_editor.refresh(); + frm.module_editor.show(); + } + }, + + validate: function (frm) { + if (frm.module_editor) { + frm.module_editor.set_modules_in_table(); } } }); diff --git a/frappe/core/doctype/module_profile/module_profile.json b/frappe/core/doctype/module_profile/module_profile.json index 0e4e56962e..32bc757427 100644 --- a/frappe/core/doctype/module_profile/module_profile.json +++ b/frappe/core/doctype/module_profile/module_profile.json @@ -34,11 +34,17 @@ } ], "index_web_pages_for_search": 1, - "links": [], - "modified": "2021-01-03 15:36:52.622696", + "links": [ + { + "link_doctype": "User", + "link_fieldname": "module_profile" + } + ], + "modified": "2021-12-03 15:47:21.296443", "modified_by": "Administrator", "module": "Core", "name": "Module Profile", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/core/doctype/navbar_settings/navbar_settings.py b/frappe/core/doctype/navbar_settings/navbar_settings.py index 46eb5c3e7a..c46d0081b6 100644 --- a/frappe/core/doctype/navbar_settings/navbar_settings.py +++ b/frappe/core/doctype/navbar_settings/navbar_settings.py @@ -13,6 +13,9 @@ class NavbarSettings(Document): def validate_standard_navbar_items(self): doc_before_save = self.get_doc_before_save() + if not doc_before_save: + return + before_save_items = [item for item in \ doc_before_save.help_dropdown + doc_before_save.settings_dropdown if item.is_standard] @@ -32,7 +35,3 @@ def get_app_logo(): def get_navbar_settings(): navbar_settings = frappe.get_single('Navbar Settings') return navbar_settings - - - - diff --git a/frappe/core/doctype/report/boilerplate/controller.js b/frappe/core/doctype/report/boilerplate/controller.js index 5148f34462..9cf71a8c09 100644 --- a/frappe/core/doctype/report/boilerplate/controller.js +++ b/frappe/core/doctype/report/boilerplate/controller.js @@ -1,4 +1,4 @@ -// Copyright (c) 2016, {app_publisher} and contributors +// Copyright (c) {year}, {app_publisher} and contributors // For license information, please see license.txt /* eslint-disable */ diff --git a/frappe/core/doctype/report/boilerplate/controller.py b/frappe/core/doctype/report/boilerplate/controller.py index ccf732a405..72da0c7ce5 100644 --- a/frappe/core/doctype/report/boilerplate/controller.py +++ b/frappe/core/doctype/report/boilerplate/controller.py @@ -1,5 +1,5 @@ -# Copyright (c) 2013, {app_publisher} and contributors -# License: MIT. See LICENSE +# Copyright (c) {year}, {app_publisher} and contributors +# For license information, please see license.txt # import frappe diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index be0346d869..266017dd71 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -51,6 +51,14 @@ class Report(Document): and not frappe.flags.in_patch): frappe.throw(_("You are not allowed to delete Standard Report")) delete_custom_role('report', self.name) + self.delete_prepared_reports() + + def delete_prepared_reports(self): + prepared_reports = frappe.get_all("Prepared Report", filters={'ref_report_doctype': self.name}, pluck='name') + + for report in prepared_reports: + frappe.delete_doc("Prepared Report", report, ignore_missing=True, force=True, + delete_permanently=True) def get_columns(self): return [d.as_dict(no_default_fields = True) for d in self.columns] diff --git a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py index 375ea02e0e..dc17526047 100644 --- a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py +++ b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py @@ -2,9 +2,10 @@ import frappe from ..role import desk_properties def execute(): + frappe.reload_doctype('user') frappe.reload_doctype('role') for role in frappe.get_all('Role', ['name', 'desk_access']): role_doc = frappe.get_doc('Role', role.name) for key in desk_properties: role_doc.set(key, role_doc.desk_access) - role_doc.save() \ No newline at end of file + role_doc.save() diff --git a/frappe/core/doctype/role/role.json b/frappe/core/doctype/role/role.json index 0135cbf9e8..ba82e023a9 100644 --- a/frappe/core/doctype/role/role.json +++ b/frappe/core/doctype/role/role.json @@ -17,7 +17,6 @@ "navigation_settings_section", "search_bar", "notifications", - "chat", "list_settings_section", "list_sidebar", "bulk_actions", @@ -85,12 +84,6 @@ "fieldtype": "Check", "label": "Search Bar" }, - { - "default": "1", - "fieldname": "chat", - "fieldtype": "Check", - "label": "Chat" - }, { "fieldname": "list_settings_section", "fieldtype": "Section Break", @@ -155,10 +148,11 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-01-27 10:35:37.638350", + "modified": "2021-10-08 14:06:55.729364", "modified_by": "Administrator", "module": "Core", "name": "Role", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index f4fa855ea1..389e18dd4c 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -2,15 +2,22 @@ # License: MIT. See LICENSE import frappe - from frappe.model.document import Document -desk_properties = ("search_bar", "notifications", "chat", "list_sidebar", +desk_properties = ("search_bar", "notifications", "list_sidebar", "bulk_actions", "view_switcher", "form_sidebar", "timeline", "dashboard") +STANDARD_ROLES = ( + "Administrator", + "System Manager", + "Script Manager", + "All", + "Guest" +) + class Role(Document): def before_rename(self, old, new, merge=False): - if old in ("Guest", "Administrator", "System Manager", "All"): + if old in STANDARD_ROLES: frappe.throw(frappe._("Standard roles cannot be renamed")) def after_insert(self): @@ -23,7 +30,7 @@ class Role(Document): self.set_desk_properties() def disable_role(self): - if self.name in ("Guest", "Administrator", "System Manager", "All"): + if self.name in STANDARD_ROLES: frappe.throw(frappe._("Standard roles cannot be disabled")) else: self.remove_roles() @@ -82,4 +89,4 @@ def role_query(doctype, txt, searchfield, start, page_len, filters): report_filters.extend(filters) return frappe.get_all('Role', limit_start=start, limit_page_length=page_len, - filters=report_filters, as_list=1) \ No newline at end of file + filters=report_filters, as_list=1) diff --git a/frappe/core/doctype/role_profile/role_profile.json b/frappe/core/doctype/role_profile/role_profile.json index 4b3f35aa57..7cd60a16d1 100644 --- a/frappe/core/doctype/role_profile/role_profile.json +++ b/frappe/core/doctype/role_profile/role_profile.json @@ -1,175 +1,80 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "role_profile", - "beta": 0, - "creation": "2017-08-31 04:16:38.764465", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "role_profile", + "creation": "2017-08-31 04:16:38.764465", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "role_profile", + "roles_html", + "roles" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "role_profile", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Role Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, + "fieldname": "role_profile", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Role Name", + "reqd": 1, "unique": 1 - }, + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "roles_html", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Roles HTML", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "roles_html", + "fieldtype": "HTML", + "label": "Roles HTML", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "roles", - "fieldtype": "Table", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Roles Assigned", - "length": 0, - "no_copy": 0, - "options": "Has Role", - "permlevel": 1, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "roles", + "fieldtype": "Table", + "hidden": 1, + "label": "Roles Assigned", + "options": "Has Role", + "permlevel": 1, + "print_hide": 1, + "read_only": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2017-10-17 11:05:11.183066", - "modified_by": "Administrator", - "module": "Core", - "name": "Role Profile", - "name_case": "", - "owner": "Administrator", + ], + "links": [ + { + "link_doctype": "User", + "link_fieldname": "role_profile_name" + } + ], + "modified": "2021-12-03 15:45:45.270963", + "modified_by": "Administrator", + "module": "Core", + "name": "Role Profile", + "naming_rule": "Expression (old style)", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "email": 1, + "export": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "role_profile", - "track_changes": 1, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "role_profile", + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index dc3353b176..a11966c47e 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -10,7 +10,7 @@ from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs class TestScheduledJobType(unittest.TestCase): def setUp(self): frappe.db.rollback() - frappe.db.sql('truncate `tabScheduled Job Type`') + frappe.db.truncate("Scheduled Job Type") sync_jobs() frappe.db.commit() diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js index dda39115bf..ca34af11ab 100644 --- a/frappe/core/doctype/server_script/server_script.js +++ b/frappe/core/doctype/server_script/server_script.js @@ -10,6 +10,13 @@ frappe.ui.form.on('Server Script', { frm.dashboard.hide(); } + if (!frm.is_new()) { + frm.add_custom_button(__('Compare Versions'), () => { + new frappe.ui.DiffView("Server Script", "script", frm.doc.name); + }); + } + + frm.call('get_autocompletion_items') .then(r => r.message) .then(items => { diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index 12a8fa47fa..d8c945fb6d 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -19,13 +19,6 @@ EVENT_MAP = { 'on_update_after_submit': 'After Save (Submitted Document)' } -def run_server_script_api(method): - # called via handler, execute an API script - script_name = get_server_script_map().get('_api', {}).get(method) - if script_name: - frappe.get_doc('Server Script', script_name).execute_method() - return True - def run_server_script_for_doc_event(doc, event): # run document event method if not event in EVENT_MAP: @@ -41,7 +34,19 @@ def run_server_script_for_doc_event(doc, event): if scripts: # run all scripts for this doctype + event for script_name in scripts: - frappe.get_doc('Server Script', script_name).execute_doc(doc) + try: + frappe.get_doc('Server Script', script_name).execute_doc(doc) + except Exception as e: + message = frappe._('Error executing Server Script {0}. Open Browser Console to see traceback.').format( + frappe.utils.get_link_to_form('Server Script', script_name) + ) + exception = type(e) + if getattr(frappe, 'request', None): + # all exceptions throw 500 which is internal server error + # however server script error is a user error + # so we should throw 417 which is expectation failed + exception.http_status_code = 417 + frappe.throw(title=frappe._('Server Script Error'), msg=message, exc=exception) def get_server_script_map(): # fetch cached server script methods diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index 3c091fec0b..bc92061f42 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -76,7 +76,7 @@ class TestServerScript(unittest.TestCase): @classmethod def setUpClass(cls): frappe.db.commit() - frappe.db.sql('truncate `tabServer Script`') + frappe.db.truncate("Server Script") frappe.get_doc('User', 'Administrator').add_roles('Script Manager') for script in scripts: script_doc = frappe.get_doc(doctype ='Server Script') @@ -88,7 +88,7 @@ class TestServerScript(unittest.TestCase): @classmethod def tearDownClass(cls): frappe.db.commit() - frappe.db.sql('truncate `tabServer Script`') + frappe.db.truncate("Server Script") frappe.cache().delete_value('server_script_map') def setUp(self): diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js index c0c9074cbc..5128ae24cb 100644 --- a/frappe/core/doctype/system_settings/system_settings.js +++ b/frappe/core/doctype/system_settings/system_settings.js @@ -10,6 +10,10 @@ frappe.ui.form.on("System Settings", { frm.set_value(key, val); frappe.sys_defaults[key] = val; }); + if (frm.re_setup_moment) { + frappe.app.setup_moment(); + delete frm.re_setup_moment; + } } }); }, @@ -32,5 +36,14 @@ frappe.ui.form.on("System Settings", { frm.set_value('prepared_report_expiry_period', 7); } } - } + }, + on_update: function(frm) { + if (frappe.boot.time_zone && frappe.boot.time_zone.system !== frm.doc.time_zone) { + // Clear cache after saving to refresh the values of boot. + frappe.ui.toolbar.clear_cache(); + } + }, + first_day_of_the_week(frm) { + frm.re_setup_moment = true; + }, }); diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 4b53983702..61410fb1a8 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -17,12 +17,14 @@ "date_and_number_format", "date_format", "time_format", - "column_break_7", "number_format", + "column_break_7", "float_precision", "currency_precision", + "first_day_of_the_week", "sec_backup_limit", "backup_limit", + "encrypt_backup", "background_workers", "enable_scheduler", "dormant_days", @@ -66,8 +68,8 @@ "prepared_report_section", "enable_prepared_report_auto_deletion", "prepared_report_expiry_period", - "chat", - "enable_chat" + "system_updates_section", + "disable_system_update_notification" ], "fields": [ { @@ -96,6 +98,7 @@ "fieldname": "time_zone", "fieldtype": "Select", "label": "Time Zone", + "read_only": 1, "reqd": 1 }, { @@ -381,18 +384,6 @@ "fieldtype": "Check", "label": "Hide footer in auto email reports" }, - { - "collapsible": 1, - "fieldname": "chat", - "fieldtype": "Section Break", - "label": "Chat" - }, - { - "default": "1", - "fieldname": "enable_chat", - "fieldtype": "Check", - "label": "Enable Chat" - }, { "fieldname": "column_break_21", "fieldtype": "Column Break" @@ -469,12 +460,37 @@ "fieldname": "strip_exif_metadata_from_uploaded_images", "fieldtype": "Check", "label": "Strip EXIF tags from uploaded images" + }, + { + "default": "0", + "fieldname": "encrypt_backup", + "fieldtype": "Check", + "label": "Encrypt Backups" + }, + { + "collapsible": 1, + "fieldname": "system_updates_section", + "fieldtype": "Section Break", + "label": "System Updates" + }, + { + "default": "0", + "fieldname": "disable_system_update_notification", + "fieldtype": "Check", + "label": "Disable System Update Notification" + }, + { + "default": "Sunday", + "fieldname": "first_day_of_the_week", + "fieldtype": "Select", + "label": "First Day of the Week", + "options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2021-03-30 11:47:47.330437", + "modified": "2022-01-04 11:28:34.881192", "modified_by": "Administrator", "module": "Core", "name": "System Settings", @@ -491,5 +507,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/transaction_log/transaction_log.py b/frappe/core/doctype/transaction_log/transaction_log.py index e2e75b130c..0a480f6660 100644 --- a/frappe/core/doctype/transaction_log/transaction_log.py +++ b/frappe/core/doctype/transaction_log/transaction_log.py @@ -1,12 +1,14 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors +# Copyright (c) 2021, Frappe Technologies and contributors # License: MIT. See LICENSE +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 -import hashlib + class TransactionLog(Document): def before_insert(self): @@ -28,26 +30,27 @@ class TransactionLog(Document): def hash_line(self): sha = hashlib.sha256() sha.update( - frappe.safe_encode(str(self.row_index)) + \ - frappe.safe_encode(str(self.timestamp)) + \ - frappe.safe_encode(str(self.data)) + frappe.safe_encode(str(self.row_index)) + + frappe.safe_encode(str(self.timestamp)) + + frappe.safe_encode(str(self.data)) ) return sha.hexdigest() def hash_chain(self): sha = hashlib.sha256() - sha.update( - frappe.safe_encode(str(self.transaction_hash)) + \ - frappe.safe_encode(str(self.previous_hash)) - ) + sha.update(frappe.safe_encode(str(self.transaction_hash)) + frappe.safe_encode(str(self.previous_hash))) return sha.hexdigest() def get_current_index(): - current = frappe.db.sql("""SELECT `current` - FROM `tabSeries` - WHERE `name` = 'TRANSACTLOG' - FOR UPDATE""") + series = DocType("Series") + current = ( + frappe.qb.from_(series) + .where(series.name == "TRANSACTLOG") + .for_update() + .select("current") + ).run() + if current and current[0][0] is not None: current = current[0][0] diff --git a/frappe/core/doctype/translation/translation.json b/frappe/core/doctype/translation/translation.json index e91ffc2941..560f3b2ce2 100644 --- a/frappe/core/doctype/translation/translation.json +++ b/frappe/core/doctype/translation/translation.json @@ -43,8 +43,7 @@ { "fieldname": "context", "fieldtype": "Data", - "label": "Context", - "read_only": 1 + "label": "Context" }, { "default": "0", @@ -83,7 +82,7 @@ } ], "links": [], - "modified": "2020-03-12 13:28:48.223409", + "modified": "2021-12-31 10:19:52.541055", "modified_by": "Administrator", "module": "Core", "name": "Translation", diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index e47846958a..d1291acfc4 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -70,7 +70,7 @@ class TestUser(unittest.TestCase): delete_contact("_test@example.com") delete_doc("User", "_test@example.com") - self.assertTrue(not frappe.db.sql("""select * from `tabToDo` where owner=%s""", + self.assertTrue(not frappe.db.sql("""select * from `tabToDo` where allocated_to=%s""", ("_test@example.com",))) from frappe.core.doctype.role.test_role import test_records as role_records @@ -251,7 +251,7 @@ class TestUser(unittest.TestCase): c = FrappeClient(url) res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) - self.assertEqual(res1.status_code, 200) + self.assertEqual(res1.status_code, 400) self.assertEqual(res2.status_code, 417) def test_user_rename(self): diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 96726d875c..77c199cdd4 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -50,7 +50,7 @@ frappe.ui.form.on('User', { let d = frm.add_child("block_modules"); d.module = v.module; }); - frm.module_editor && frm.module_editor.refresh(); + frm.module_editor && frm.module_editor.show(); } }); } @@ -77,7 +77,12 @@ frappe.ui.form.on('User', { } }, refresh: function(frm) { - var doc = frm.doc; + let doc = frm.doc; + + if (frm.is_new()) { + frm.set_value("time_zone", frappe.sys_defaults.time_zone); + } + if (in_list(['System User', 'Website User'], frm.doc.user_type) && !frm.is_new() && !frm.roles_editor && frm.can_edit_roles) { frm.reload_doc(); @@ -180,7 +185,7 @@ frappe.ui.form.on('User', { frm.roles_editor.show(); } - frm.module_editor && frm.module_editor.refresh(); + frm.module_editor && frm.module_editor.show(); if(frappe.session.user==doc.name) { // update display settings @@ -263,9 +268,16 @@ frappe.ui.form.on('User', { callback: function(r) { if (r.message) { frappe.msgprint(__("Save API Secret: {0}", [r.message.api_secret])); + frm.reload_doc(); } } }); + }, + on_update: function(frm) { + if (frappe.boot.time_zone && frappe.boot.time_zone.user !== frm.doc.time_zone) { + // Clear cache after saving to refresh the values of boot. + frappe.ui.toolbar.clear_cache(); + } } }); diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index cd7dcd6a34..a47f539466 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -10,15 +10,15 @@ "enabled", "section_break_3", "email", - "last_name", - "language", - "column_break0", "first_name", - "full_name", - "time_zone", - "column_break_11", "middle_name", + "last_name", + "column_break0", + "full_name", "username", + "column_break_11", + "language", + "time_zone", "send_welcome_email", "unsubscribed", "user_image", @@ -555,20 +555,22 @@ "collapsible": 1, "fieldname": "api_access", "fieldtype": "Section Break", - "label": "Api Access" + "label": "API Access" }, { - "description": "API Key cannot be regenerated", + "description": "API Key cannot be regenerated", "fieldname": "api_key", "fieldtype": "Data", "label": "API Key", + "permlevel": 1, "read_only": 1, "unique": 1 }, { "fieldname": "generate_keys", "fieldtype": "Button", - "label": "Generate Keys" + "label": "Generate Keys", + "permlevel": 1 }, { "fieldname": "column_break_65", @@ -578,6 +580,7 @@ "fieldname": "api_secret", "fieldtype": "Password", "label": "API Secret", + "permlevel": 1, "read_only": 1 }, { @@ -596,7 +599,7 @@ "fieldname": "desk_theme", "fieldtype": "Select", "label": "Desk Theme", - "options": "Light\nDark" + "options": "Light\nDark\nAutomatic" }, { "fieldname": "module_profile", @@ -614,11 +617,6 @@ "link_doctype": "Contact", "link_fieldname": "user" }, - { - "group": "Profile", - "link_doctype": "Chat Profile", - "link_fieldname": "user" - }, { "group": "Profile", "link_doctype": "Blogger", @@ -662,7 +660,7 @@ { "group": "Activity", "link_doctype": "ToDo", - "link_fieldname": "owner" + "link_fieldname": "allocated_to" }, { "group": "Integrations", @@ -671,7 +669,7 @@ } ], "max_attachments": 5, - "modified": "2021-10-18 16:56:05.578379", + "modified": "2022-01-03 11:53:25.250822", "modified_by": "Administrator", "module": "Core", "name": "User", @@ -704,6 +702,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "full_name", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 45f7d47a27..ef7845d3b0 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -7,7 +7,7 @@ import frappe.defaults import frappe.permissions from frappe.model.document import Document from frappe.utils import (cint, flt, has_gravatar, escape_html, format_datetime, - now_datetime, get_formatted_email, today) + now_datetime, get_formatted_email, today, get_time_zone) from frappe import throw, msgprint, _ from frappe.utils.password import update_password as _update_password, check_password, get_password_reset_limit from frappe.desk.notifications import clear_notifications @@ -74,6 +74,7 @@ class User(Document): self.validate_roles() self.validate_allowed_modules() self.validate_user_image() + self.set_time_zone() if self.language == "Loading...": self.language = None @@ -213,15 +214,12 @@ class User(Document): user_type_doc.update_modules_in_user(self) def has_desk_access(self): - '''Return true if any of the set roles has desk access''' + """Return true if any of the set roles has desk access""" if not self.roles: return False - return len(frappe.db.sql("""select name - from `tabRole` where desk_access=1 - and name in ({0}) limit 1""".format(', '.join(['%s'] * len(self.roles))), - [d.role for d in self.roles])) - + role_table = DocType("Role") + return frappe.db.count(role_table, ((role_table.desk_access == 1) & (role_table.name.isin([d.role for d in self.roles])))) def share_with_self(self): frappe.share.add(self.doctype, self.name, self.name, write=1, share=1, @@ -230,11 +228,11 @@ class User(Document): def validate_share(self, docshare): pass # if docshare.user == self.name: - # if self.user_type=="System User": - # if docshare.share != 1: - # frappe.throw(_("Sorry! User should have complete access to their own record.")) - # else: - # frappe.throw(_("Sorry! Sharing with Website User is prohibited.")) + # if self.user_type=="System User": + # if docshare.share != 1: + # frappe.throw(_("Sorry! User should have complete access to their own record.")) + # else: + # frappe.throw(_("Sorry! Sharing with Website User is prohibited.")) def send_password_notification(self, new_password): try: @@ -279,12 +277,20 @@ class User(Document): return link def get_other_system_managers(self): - return frappe.db.sql("""select distinct `user`.`name` from `tabHas Role` as `user_role`, `tabUser` as `user` - where user_role.role='System Manager' - and `user`.docstatus<2 - and `user`.enabled=1 - and `user_role`.parent = `user`.name - and `user_role`.parent not in ('Administrator', %s) limit 1""", (self.name,)) + user_doctype = DocType("User").as_("user") + user_role_doctype = DocType("Has Role").as_("user_role") + return ( + frappe.qb.from_(user_doctype) + .from_(user_role_doctype) + .select(user_doctype.name) + .where(user_role_doctype.role == 'System Manager') + .where(user_doctype.docstatus < 2) + .where(user_doctype.enabled == 1) + .where(user_role_doctype.parent == user_doctype.name) + .where(user_role_doctype.parent.notin(["Administrator", self.name])) + .limit(1) + .distinct() + ).run() def get_fullname(self): """get first_name space last_name""" @@ -357,9 +363,13 @@ class User(Document): frappe.local.login_manager.logout(user=self.name) # delete todos - frappe.db.delete("ToDo", {"owner": self.name}) - frappe.db.sql("""UPDATE `tabToDo` SET `assigned_by`=NULL WHERE `assigned_by`=%s""", - (self.name,)) + frappe.db.delete("ToDo", {"allocated_to": self.name}) + todo_table = DocType("ToDo") + ( + frappe.qb.update(todo_table) + .set(todo_table.assigned_by, None) + .where(todo_table.assigned_by == self.name) + ).run() # delete events frappe.db.delete("Event", {"owner": self.name, "event_type": "Private"}) @@ -421,17 +431,11 @@ class User(Document): WHERE `%s` = %s""" % (tab, field, '%s', field, '%s'), (new_name, old_name)) - if frappe.db.exists("Chat Profile", old_name): - frappe.rename_doc("Chat Profile", old_name, new_name, force=True, show_alert=False) - if frappe.db.exists("Notification Settings", old_name): frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False) # set email - table = DocType("User") - frappe.qb.update(table).where( - table.name == new_name - ).set("email", new_name).run() + frappe.db.update("User", new_name, "email", new_name) def append_roles(self, *roles): """Add roles to user""" @@ -593,6 +597,10 @@ class User(Document): return user + def set_time_zone(self): + if not self.time_zone: + self.time_zone = get_time_zone() + @frappe.whitelist() def get_timezones(): import pytz @@ -701,26 +709,19 @@ def has_email_account(email): @frappe.whitelist(allow_guest=False) def get_email_awaiting(user): - waiting = frappe.db.sql("""select email_account,email_id - from `tabUser Email` - where awaiting_password = 1 - and parent = %(user)s""", {"user":user}, as_dict=1) + waiting = frappe.get_all("User Email", fields=["email_account", "email_id"], filters={"awaiting_password": 1, "parent": user}) if waiting: return waiting else: - frappe.db.sql("""update `tabUser Email` - set awaiting_password =0 - where parent = %(user)s""",{"user":user}) + user_email_table = DocType("User Email") + frappe.qb.update(user_email_table).set(user_email_table.user_email_table, 0).where(user_email_table.parent == user).run() return False def ask_pass_update(): # update the sys defaults as to awaiting users from frappe.utils import set_default - users = frappe.db.sql("""SELECT DISTINCT(parent) as user FROM `tabUser Email` - WHERE awaiting_password = 1""", as_dict=True) - - password_list = [ user.get("user") for user in users ] + password_list = frappe.get_all("User Email", filters={"awaiting_password": True}, pluck="parent", distinct=True) set_default("email_user_password", u','.join(password_list)) def _get_user_for_update_password(key, old_password): @@ -812,6 +813,7 @@ def reset_password(user): return frappe.msgprint(_("Password reset instructions have been sent to your email")) except frappe.DoesNotExistError: + frappe.local.response['http_status_code'] = 400 frappe.clear_messages() return 'not found' @@ -888,8 +890,7 @@ def get_active_users(): def get_website_users(): """Returns total no. of website users""" - return frappe.db.sql("""select count(*) from `tabUser` - where enabled = 1 and user_type = 'Website User'""")[0][0] + return frappe.db.count("User", filters={"enabled": True, "user_type": "Website User"}) def get_active_website_users(): """Returns No. of website users who logged in, in the last 3 days""" @@ -1047,7 +1048,7 @@ def generate_keys(user): @frappe.whitelist() def switch_theme(theme): - if theme in ["Dark", "Light"]: + if theme in ["Dark", "Light", "Automatic"]: frappe.db.set_value("User", frappe.session.user, "desk_theme", theme) def get_enabled_users(): @@ -1055,4 +1056,4 @@ def get_enabled_users(): enabled_users = frappe.get_all("User", filters={"enabled": "1"}, pluck="name") return enabled_users - return frappe.cache().get_value("enabled_users", _get_enabled_users) \ No newline at end of file + return frappe.cache().get_value("enabled_users", _get_enabled_users) diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 85db846982..cf905c2ce2 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -73,7 +73,7 @@ class TestUserPermission(unittest.TestCase): def test_for_applicable_on_update_from_apply_to_all(self): ''' Update User Permission from all to some applicable Doctypes''' user = create_user('test_bulk_creation_update@example.com') - param = get_params(user,'User', user.name, applicable = ["Chat Room", "Chat Message"]) + param = get_params(user,'User', user.name, applicable = ["Comment", "Contact"]) # Initially create User Permission document with apply_to_all checked is_created = add_user_permissions(get_params(user, 'User', user.name)) @@ -84,8 +84,8 @@ class TestUserPermission(unittest.TestCase): frappe.db.commit() removed_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user)) - is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room")) - is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message")) + is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment")) + is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact")) # Check that apply_to_all is removed self.assertIsNone(removed_apply_to_all) @@ -101,14 +101,14 @@ class TestUserPermission(unittest.TestCase): param = get_params(user, 'User', user.name) # create User permissions that with applicable - is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Chat Room", "Chat Message"])) + is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Comment", "Contact"])) self.assertEqual(is_created, 1) is_created = add_user_permissions(param) is_created_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user)) - removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room")) - removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message")) + removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment")) + removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact")) # To check that a User permission with apply_to_all exists self.assertIsNotNone(is_created_apply_to_all) diff --git a/frappe/core/doctype/user_permission/user_permission.js b/frappe/core/doctype/user_permission/user_permission.js index 4c3f5b4eb8..8d5c5c1a23 100644 --- a/frappe/core/doctype/user_permission/user_permission.js +++ b/frappe/core/doctype/user_permission/user_permission.js @@ -44,7 +44,7 @@ frappe.ui.form.on('User Permission', { set_applicable_for_constraint: frm => { frm.toggle_reqd('applicable_for', !frm.doc.apply_to_all_doctypes); - if (frm.doc.apply_to_all_doctypes) { + if (frm.doc.apply_to_all_doctypes && frm.doc.applicable_for) { frm.set_value('applicable_for', null); } }, diff --git a/frappe/core/doctype/user_permission/user_permission.json b/frappe/core/doctype/user_permission/user_permission.json index 9cea0856c9..60b6779bfd 100644 --- a/frappe/core/doctype/user_permission/user_permission.json +++ b/frappe/core/doctype/user_permission/user_permission.json @@ -8,8 +8,8 @@ "field_order": [ "user", "allow", - "column_break_3", "for_value", + "column_break_3", "is_default", "advanced_control_section", "apply_to_all_doctypes", @@ -37,10 +37,6 @@ "options": "DocType", "reqd": 1 }, - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, { "fieldname": "for_value", "fieldtype": "Dynamic Link", @@ -87,10 +83,14 @@ "fieldtype": "Check", "hidden": 1, "label": "Hide Descendants" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" } ], "links": [], - "modified": "2021-01-21 18:14:10.839381", + "modified": "2022-01-03 11:25:03.726150", "modified_by": "Administrator", "module": "Core", "name": "User Permission", @@ -111,6 +111,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "user", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/form_tour/doctype/doctype.json b/frappe/core/form_tour/doctype/doctype.json new file mode 100644 index 0000000000..391d3ecf40 --- /dev/null +++ b/frappe/core/form_tour/doctype/doctype.json @@ -0,0 +1,56 @@ +{ + "creation": "2021-11-23 12:38:52.807353", + "docstatus": 0, + "doctype": "Form Tour", + "first_document": 0, + "idx": 0, + "include_name_field": 1, + "is_standard": 1, + "modified": "2021-11-25 17:03:01.646360", + "modified_by": "Administrator", + "module": "Core", + "name": "Doctype", + "owner": "Administrator", + "reference_doctype": "DocType", + "save_on_complete": 1, + "steps": [ + { + "description": "Select a Module to which this DocType would belong", + "field": "", + "fieldname": "module", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Module", + "parent_field": "", + "position": "Right", + "title": "Module" + }, + { + "description": "Check this to make the DocType as Custom", + "field": "", + "fieldname": "custom", + "fieldtype": "Check", + "has_next_condition": 1, + "is_table_field": 0, + "label": "Custom?", + "next_step_condition": "eval: doc.custom", + "parent_field": "", + "position": "Left", + "title": "Custom " + }, + { + "description": "A Field (or a docfield) defines a property of a DocType. You can define the column name, label, datatype and more for DocFields. For instance, a ToDo doctype has fields description, status and priority. These ultimately become columns in the database table tabToDo.", + "field": "", + "fieldname": "fields", + "fieldtype": "Table", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Fields", + "parent_field": "", + "position": "Top", + "title": "Fields" + } + ], + "title": "Doctype" +} \ No newline at end of file diff --git a/frappe/core/notifications.py b/frappe/core/notifications.py index b43d424df5..be3e723af6 100644 --- a/frappe/core/notifications.py +++ b/frappe/core/notifications.py @@ -2,6 +2,9 @@ # License: MIT. See LICENSE import frappe +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now + def get_notification_config(): return { @@ -20,7 +23,7 @@ def get_things_todo(as_list=False): data = frappe.get_list("ToDo", fields=["name", "description"] if as_list else "count(*)", filters=[["ToDo", "status", "=", "Open"]], - or_filters=[["ToDo", "owner", "=", frappe.session.user], + or_filters=[["ToDo", "allocated_to", "=", frappe.session.user], ["ToDo", "assigned_by", "=", frappe.session.user]], as_list=True) @@ -39,28 +42,40 @@ def get_todays_events(as_list=False): def get_unseen_likes(): """Returns count of unseen likes""" - return frappe.db.sql("""select count(*) from `tabComment` - where - comment_type='Like' - and modified >= (NOW() - INTERVAL '1' YEAR) - and owner is not null and owner!=%(user)s - and reference_owner=%(user)s - and seen=0 - """, {"user": frappe.session.user})[0][0] + + comment_doctype = DocType("Comment") + return frappe.db.count(comment_doctype, + filters=( + (comment_doctype.comment_type == "Like") + & (comment_doctype.modified >= Now() - Interval(years=1)) + & (comment_doctype.owner.notnull()) + & (comment_doctype.owner != frappe.session.user) + & (comment_doctype.reference_owner == frappe.session.user) + & (comment_doctype.seen == 0) + ) + ) + def get_unread_emails(): - "returns unread emails for a user" + "returns count of unread emails for a user" - return frappe.db.sql("""\ - SELECT count(*) - FROM `tabCommunication` - WHERE communication_type='Communication' - AND communication_medium='Email' - AND sent_or_received='Received' - AND email_status not in ('Spam', 'Trash') - AND email_account in ( - SELECT distinct email_account from `tabUser Email` WHERE parent=%(user)s + communication_doctype = DocType("Communication") + user_doctype = DocType("User") + distinct_email_accounts = ( + frappe.qb.from_(user_doctype) + .select(user_doctype.email_account) + .where(user_doctype.parent == frappe.session.user) + .distinct() + ) + + return frappe.db.count(communication_doctype, + filters=( + (communication_doctype.communication_type == "Communication") + & (communication_doctype.communication_medium == "Email") + & (communication_doctype.sent_or_received == "Received") + & (communication_doctype.email_status.notin(["spam", "Trash"])) + & (communication_doctype.email_account.isin(distinct_email_accounts)) + & (communication_doctype.modified >= Now() - Interval(years=1)) + & (communication_doctype.seen == 0) ) - AND modified >= (NOW() - INTERVAL '1' YEAR) - AND seen=0 - """, {"user": frappe.session.user})[0][0] + ) diff --git a/frappe/core/report/transaction_log_report/transaction_log_report.py b/frappe/core/report/transaction_log_report/transaction_log_report.py index 0a74ece322..e9c68cb0c7 100644 --- a/frappe/core/report/transaction_log_report/transaction_log_report.py +++ b/frappe/core/report/transaction_log_report/transaction_log_report.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019, Frappe Technologies and contributors +# Copyright (c) 2021, Frappe Technologies and contributors # License: MIT. See LICENSE import frappe @@ -12,13 +12,17 @@ def execute(filters=None): return columns, data def get_data(filters=None): - - logs = frappe.db.sql("SELECT * FROM `tabTransaction Log` order by creation desc ", as_dict=1) result = [] + logs = frappe.get_all("Transaction Log", fields=["*"], order_by="creation desc") + for l in logs: row_index = int(l.row_index) if row_index > 1: - previous_hash = frappe.db.sql("SELECT chaining_hash FROM `tabTransaction Log` WHERE row_index = {0}".format(row_index - 1)) + previous_hash = frappe.get_all( + "Transaction Log", + fields=["chaining_hash"], + filters={"row_index": row_index - 1}, + ) if not previous_hash: integrity = False else: diff --git a/frappe/custom/doctype/client_script/client_script.js b/frappe/custom/doctype/client_script/client_script.js index 27d11af4d1..ad9c9e4e42 100644 --- a/frappe/custom/doctype/client_script/client_script.js +++ b/frappe/custom/doctype/client_script/client_script.js @@ -43,6 +43,12 @@ frappe.ui.form.on('Client Script', { d.show(); }); }); + + if (!frm.is_new()) { + frm.add_custom_button(__('Compare Versions'), () => { + new frappe.ui.DiffView("Client Script", "script", frm.doc.name); + }); + } } frm.set_query('dt', { diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 8c22d3c45c..8f7b21dd24 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -8,6 +8,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.docfield import supports_translation from frappe.model import core_doctypes_list +from frappe.query_builder.functions import IfNull class CustomField(Document): def autoname(self): @@ -115,9 +116,7 @@ def get_fields_label(doctype=None): def create_custom_field_if_values_exist(doctype, df): df = frappe._dict(df) if df.fieldname in frappe.db.get_table_columns(doctype) and \ - frappe.db.sql("""select count(*) from `tab{doctype}` - where ifnull({fieldname},'')!=''""".format(doctype=doctype, fieldname=df.fieldname))[0][0]: - + frappe.db.count(dt=doctype, filters=IfNull(df.fieldname, "") != ""): create_custom_field(doctype, df) def create_custom_field(doctype, df, ignore_validate=False): diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 4e00456f0d..4862185b99 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -114,6 +114,7 @@ frappe.ui.form.on("Customize Form", { frm.page.clear_icons(); if (frm.doc.doc_type) { + frm.page.set_title(__('Customize Form - {0}', [frm.doc.doc_type])); frappe.customize_form.set_primary_action(frm); frm.add_custom_button( @@ -276,6 +277,21 @@ frappe.ui.form.on("DocType Action", { } }); +// can't delete standard states +frappe.ui.form.on("DocType State", { + before_states_remove: function(frm, doctype, name) { + let row = frappe.get_doc(doctype, name); + if (!(row.custom || row.__islocal)) { + frappe.msgprint(__("Cannot delete standard document state.")); + throw "cannot delete standard document state"; + } + }, + states_add: function(frm, cdt, cdn) { + let f = frappe.model.get_doc(cdt, cdn); + f.custom = 1; + } +}); + frappe.customize_form.set_primary_action = function(frm) { frm.page.set_primary_action(__("Update"), function() { if (frm.doc.doc_type) { @@ -332,3 +348,4 @@ frappe.customize_form.clear_locals_and_refresh = function(frm) { frm.refresh(); } +extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm})); diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index c2940a92e3..bdf95ad351 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -41,6 +41,8 @@ "actions", "document_links_section", "links", + "document_states_section", + "states", "section_break_8", "sort_field", "column_break_10", @@ -280,6 +282,20 @@ "fieldname": "autoname", "fieldtype": "Data", "label": "Auto Name" + }, + { + "collapsible": 1, + "collapsible_depends_on": "states", + "depends_on": "doc_type", + "fieldname": "document_states_section", + "fieldtype": "Section Break", + "label": "Document States" + }, + { + "fieldname": "states", + "fieldtype": "Table", + "label": "States", + "options": "DocType State" } ], "hide_toolbar": 1, @@ -288,10 +304,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-06-21 19:01:06.920663", + "modified": "2021-12-14 16:45:04.308690", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -308,5 +325,6 @@ "search_fields": "doc_type", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 94f25a41aa..24a5d1358b 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -72,7 +72,7 @@ class CustomizeForm(Document): new_d[prop] = d.get(prop) self.append("fields", new_d) - for fieldname in ('links', 'actions'): + for fieldname in ('links', 'actions', 'states'): for d in meta.get(fieldname): self.append(fieldname, d) @@ -258,7 +258,8 @@ class CustomizeForm(Document): ''' for doctype, fieldname, field_map in ( ('DocType Link', 'links', doctype_link_properties), - ('DocType Action', 'actions', doctype_action_properties) + ('DocType Action', 'actions', doctype_action_properties), + ('DocType State', 'states', doctype_state_properties), ): has_custom = False items = [] @@ -515,6 +516,7 @@ docfield_properties = { 'options': 'Text', 'fetch_from': 'Small Text', 'fetch_if_empty': 'Check', + 'show_dashboard': 'Check', 'permlevel': 'Int', 'width': 'Data', 'print_width': 'Data', @@ -568,6 +570,11 @@ doctype_action_properties = { 'hidden': 'Check' } +doctype_state_properties = { + 'title': 'Data', + 'color': 'Select' +} + ALLOWED_FIELDTYPE_CHANGE = ( ('Currency', 'Float', 'Percent'), diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 971bc51f96..bb856f6805 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -28,6 +28,7 @@ "options", "fetch_from", "fetch_if_empty", + "show_dashboard", "permissions", "depends_on", "permlevel", @@ -82,7 +83,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nTab Break", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -422,18 +423,27 @@ "fieldname": "non_negative", "fieldtype": "Check", "label": "Non Negative" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Tab Break'", + "fieldname": "show_dashboard", + "fieldtype": "Check", + "label": "Show Dashboard" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-07-11 21:57:24.479749", + "modified": "2022-01-03 14:50:32.035768", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } diff --git a/frappe/custom/doctype/property_setter/property_setter.json b/frappe/custom/doctype/property_setter/property_setter.json index fcb36637fe..9707f1ee1c 100644 --- a/frappe/custom/doctype/property_setter/property_setter.json +++ b/frappe/custom/doctype/property_setter/property_setter.json @@ -37,7 +37,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Applied On", - "options": "\nDocField\nDocType\nDocType Link\nDocType Action", + "options": "\nDocField\nDocType\nDocType Link\nDocType Action\nDocType State", "read_only_depends_on": "eval:!doc.__islocal", "reqd": 1 }, @@ -109,7 +109,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-09-04 12:46:17.860769", + "modified": "2021-12-14 14:15:41.929071", "modified_by": "Administrator", "module": "Custom", "name": "Property Setter", @@ -141,5 +141,6 @@ "search_fields": "doc_type,property", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py index d71b7b0021..7f40be9725 100644 --- a/frappe/custom/doctype/property_setter/property_setter.py +++ b/frappe/custom/doctype/property_setter/property_setter.py @@ -43,20 +43,28 @@ class PropertySetter(Document): def get_setup_data(self): return { - 'doctypes': [d[0] for d in frappe.db.sql("select name from tabDocType")], + 'doctypes': frappe.get_all("DocType", pluck="name"), 'dt_properties': self.get_property_list('DocType'), 'df_properties': self.get_property_list('DocField') } def get_field_ids(self): - return frappe.db.sql("select name, fieldtype, label, fieldname from tabDocField where parent=%s", self.doc_type, as_dict = 1) + return frappe.db.get_values( + "DocField", + filters={"parent": self.doc_type}, + fieldname=["name", "fieldtype", "label", "fieldname"], + as_dict=True, + ) def get_defaults(self): if not self.field_name: - return frappe.db.sql("select * from `tabDocType` where name=%s", self.doc_type, as_dict = 1)[0] + return frappe.get_all("DocType", filters={"name": self.doc_type}, fields="*")[0] else: - return frappe.db.sql("select * from `tabDocField` where fieldname=%s and parent=%s", - (self.field_name, self.doc_type), as_dict = 1)[0] + return frappe.db.get_values( + "DocField", + filters={"fieldname": self.field_name, "parent": self.doc_type}, + fieldname="*", + )[0] def on_update(self): if frappe.flags.in_patch: diff --git a/frappe/custom/fixtures/temp_doctype.json b/frappe/custom/fixtures/temp_doctype.json new file mode 100644 index 0000000000..343aa2cb37 --- /dev/null +++ b/frappe/custom/fixtures/temp_doctype.json @@ -0,0 +1,168 @@ +{ + "docstatus": 0, + "doctype": "DocType", + "name": "new-doctype-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "is_submittable": 0, + "istable": 0, + "issingle": 0, + "is_tree": 0, + "editable_grid": 1, + "quick_entry": 1, + "track_changes": 1, + "track_seen": 0, + "track_views": 0, + "custom": 1, + "beta": 0, + "is_virtual": 0, + "naming_rule": "", + "name_case": "", + "allow_rename": 1, + "hide_toolbar": 0, + "allow_copy": 0, + "allow_import": 0, + "allow_events_in_timeline": 0, + "allow_auto_repeat": 0, + "sort_field": "modified", + "sort_order": "DESC", + "document_type": "", + "show_preview_popup": 0, + "show_name_in_global_search": 0, + "email_append_to": 0, + "read_only": 0, + "in_create": 0, + "has_web_view": 0, + "allow_guest_to_view": 0, + "index_web_pages_for_search": 1, + "engine": "InnoDB", + "permissions": [ + { + "docstatus": 0, + "doctype": "DocPerm", + "name": "new-docperm-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "if_owner": 0, + "permlevel": 0, + "select": 0, + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 0, + "cancel": 0, + "amend": 0, + "report": 1, + "export": 1, + "import": 0, + "set_user_permissions": 0, + "share": 1, + "print": 1, + "email": 1, + "parent": "new-doctype-2", + "parentfield": "permissions", + "parenttype": "DocType", + "idx": 1, + "role": "System Manager" + } + ], + "__newname": "temp_doctype", + "module": "Custom", + "fields": [ + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 1, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-2", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 1, + "__unedited": false, + "label": "member_name" + }, + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 0, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-2", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 2, + "__unedited": false, + "label": "email" + } + ] +} diff --git a/frappe/custom/fixtures/temp_singles.json b/frappe/custom/fixtures/temp_singles.json new file mode 100644 index 0000000000..b7e2536f25 --- /dev/null +++ b/frappe/custom/fixtures/temp_singles.json @@ -0,0 +1,168 @@ +{ + "docstatus": 0, + "doctype": "DocType", + "name": "new-doctype-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "is_submittable": 0, + "istable": 0, + "issingle": 1, + "is_tree": 0, + "editable_grid": 1, + "quick_entry": 0, + "track_changes": 1, + "track_seen": 0, + "track_views": 0, + "custom": 1, + "beta": 0, + "is_virtual": 0, + "naming_rule": "", + "name_case": "", + "allow_rename": 1, + "hide_toolbar": 0, + "allow_copy": 0, + "allow_import": 0, + "allow_events_in_timeline": 0, + "allow_auto_repeat": 0, + "sort_field": "modified", + "sort_order": "DESC", + "document_type": "", + "show_preview_popup": 0, + "show_name_in_global_search": 0, + "email_append_to": 0, + "read_only": 0, + "in_create": 0, + "has_web_view": 0, + "allow_guest_to_view": 0, + "index_web_pages_for_search": 1, + "engine": "InnoDB", + "permissions": [ + { + "docstatus": 0, + "doctype": "DocPerm", + "name": "new-docperm-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "if_owner": 0, + "permlevel": 0, + "select": 0, + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 0, + "cancel": 0, + "amend": 0, + "report": 1, + "export": 1, + "import": 0, + "set_user_permissions": 0, + "share": 1, + "print": 1, + "email": 1, + "parent": "new-doctype-1", + "parentfield": "permissions", + "parenttype": "DocType", + "idx": 1, + "role": "System Manager" + } + ], + "__newname": "temp_singles", + "module": "Custom", + "fields": [ + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 0, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-1", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 1, + "__unedited": false, + "label": "member_name" + }, + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 0, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-1", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 2, + "__unedited": false, + "label": "email" + } + ] +} diff --git a/frappe/custom/form_tour/custom_field/custom_field.json b/frappe/custom/form_tour/custom_field/custom_field.json new file mode 100644 index 0000000000..3279449e7c --- /dev/null +++ b/frappe/custom/form_tour/custom_field/custom_field.json @@ -0,0 +1,79 @@ +{ + "creation": "2021-11-23 12:22:32.922700", + "docstatus": 0, + "doctype": "Form Tour", + "first_document": 0, + "idx": 0, + "include_name_field": 0, + "is_standard": 1, + "modified": "2021-11-24 19:15:34.244244", + "modified_by": "Administrator", + "module": "Custom", + "name": "Custom Field", + "owner": "Administrator", + "reference_doctype": "Custom Field", + "save_on_complete": 1, + "steps": [ + { + "description": "Select a Document for which you want the Custom Field", + "field": "", + "fieldname": "dt", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Document", + "parent_field": "", + "position": "Right", + "title": "Document" + }, + { + "description": "Enter a Label for this field", + "field": "", + "fieldname": "label", + "fieldtype": "Data", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Label", + "parent_field": "", + "position": "Right", + "title": "Label" + }, + { + "description": "Select the label after which you want to insert new field.", + "field": "", + "fieldname": "insert_after", + "fieldtype": "Select", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Insert After", + "parent_field": "", + "position": "Right", + "title": "Insert After" + }, + { + "description": "Select an appropriate Field Type that suits your requirements", + "field": "", + "fieldname": "fieldtype", + "fieldtype": "Select", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Field Type", + "parent_field": "", + "position": "Left", + "title": "Field Type" + }, + { + "description": "Check this to make it a mandatory field", + "field": "", + "fieldname": "reqd", + "fieldtype": "Check", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Is Mandatory Field", + "parent_field": "", + "position": "Left", + "title": "Is Mandatory Field" + } + ], + "title": "Custom Field" +} \ No newline at end of file diff --git a/frappe/custom/module_onboarding/customization/customization.json b/frappe/custom/module_onboarding/customization/customization.json new file mode 100644 index 0000000000..99b7cc1f2b --- /dev/null +++ b/frappe/custom/module_onboarding/customization/customization.json @@ -0,0 +1,44 @@ +{ + "allow_roles": [ + { + "role": "All" + } + ], + "creation": "2021-11-23 12:21:11.384229", + "docstatus": 0, + "doctype": "Module Onboarding", + "documentation_url": "https://docs.erpnext.com/docs/v13/user/manual/en/customize-erpnext", + "idx": 0, + "is_complete": 0, + "modified": "2021-11-24 17:04:31.523715", + "modified_by": "Administrator", + "module": "Custom", + "name": "Customization", + "owner": "Administrator", + "steps": [ + { + "step": "Custom Field" + }, + { + "step": "Custom Doctype" + }, + { + "step": "Naming Series" + }, + { + "step": "Workflows" + }, + { + "step": "Role Permissions" + }, + { + "step": "Print Format" + }, + { + "step": "Report Builder" + } + ], + "subtitle": "Custom Field, Custom Doctype, Naming Series, Role Permission, Workflow, Print Formats, Reports", + "success_message": "Customization onboarding is all done!", + "title": "Customization" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json b/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json new file mode 100644 index 0000000000..1f8601abee --- /dev/null +++ b/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json @@ -0,0 +1,21 @@ +{ + "action": "Create Entry", + "action_label": "Learn more about creating new DocTypes", + "creation": "2021-11-23 12:30:04.407568", + "description": "A DocType (Document Type) is used to insert forms in ERPNext. Forms such as Customer, Orders, and Invoices are Doctypes in the backend. You can also create new DocTypes to create new forms in ERPNext as per your business needs.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-23 12:30:04.407568", + "modified_by": "Administrator", + "name": "Custom Doctype", + "owner": "Administrator", + "reference_document": "DocType", + "show_form_tour": 1, + "show_full_form": 1, + "title": "Custom Document Types", + "validate_action": 1 +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/custom_field/custom_field.json b/frappe/custom/onboarding_step/custom_field/custom_field.json new file mode 100644 index 0000000000..4044cf2456 --- /dev/null +++ b/frappe/custom/onboarding_step/custom_field/custom_field.json @@ -0,0 +1,21 @@ +{ + "action": "Create Entry", + "action_label": "Learn how to add Custom Fields", + "creation": "2021-11-23 12:21:09.479808", + "description": "Every form in ERPNext has a standard set of fields. If you need to capture some information, but there is no standard Field available for it, you can insert Custom Field for it.\n\nOnce custom fields are added, you can use them for reports and analytics charts as well.\n", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-23 12:21:09.479808", + "modified_by": "Administrator", + "name": "Custom Field", + "owner": "Administrator", + "reference_document": "Custom Field", + "show_form_tour": 1, + "show_full_form": 1, + "title": "Create Custom Fields", + "validate_action": 1 +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/naming_series/naming_series.json b/frappe/custom/onboarding_step/naming_series/naming_series.json new file mode 100644 index 0000000000..3b15e4afde --- /dev/null +++ b/frappe/custom/onboarding_step/naming_series/naming_series.json @@ -0,0 +1,20 @@ +{ + "action": "Watch Video", + "creation": "2021-11-23 13:57:45.091427", + "description": "Each document created in ERPNext can have a unique ID generated for it, using a prefix defined for it. Though each document has some prefix pre-configured, you can further customize it using tools like Naming Series Tool and Document Naming Rule.\n", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 15:04:14.662684", + "modified_by": "Administrator", + "name": "Naming Series", + "owner": "Administrator", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Setup Naming Series", + "validate_action": 1, + "video_url": "https://youtu.be/IGyISSfI1qU" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/print_format/print_format.json b/frappe/custom/onboarding_step/print_format/print_format.json new file mode 100644 index 0000000000..681ef85b95 --- /dev/null +++ b/frappe/custom/onboarding_step/print_format/print_format.json @@ -0,0 +1,21 @@ +{ + "action": "Create Entry", + "action_label": "Learn about Standard and Custom Print Formats", + "creation": "2021-11-23 15:04:12.728513", + "description": "Print Formats allow you can define looks for documents when printed or converted to PDF. You can also create a custom Print Format using drag-and-drop tools.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-23 15:04:12.728513", + "modified_by": "Administrator", + "name": "Print Format", + "owner": "Administrator", + "reference_document": "Print Format", + "show_form_tour": 1, + "show_full_form": 1, + "title": "Customize Print Formats", + "validate_action": 1 +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/report_builder/report_builder.json b/frappe/custom/onboarding_step/report_builder/report_builder.json new file mode 100644 index 0000000000..4a0b5f9130 --- /dev/null +++ b/frappe/custom/onboarding_step/report_builder/report_builder.json @@ -0,0 +1,22 @@ +{ + "action": "Watch Video", + "action_label": "Learn more about Report Builders", + "creation": "2021-11-24 17:04:18.762838", + "description": "In each module, you will find a host of single-click reports, ranging from financial statements to sales and purchase analytics and stock tracking reports. If a required new report is not available out-of-the-box, you can create custom reports in ERPNext by pulling values from the same multiple ERPNext tables.\n", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 17:04:18.762838", + "modified_by": "Administrator", + "name": "Report Builder", + "owner": "Administrator", + "reference_document": "Report", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Generate Custom Reports", + "validate_action": 1, + "video_url": "https://youtu.be/TxJGUNarcQs" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/role_permissions/role_permissions.json b/frappe/custom/onboarding_step/role_permissions/role_permissions.json new file mode 100644 index 0000000000..a817126989 --- /dev/null +++ b/frappe/custom/onboarding_step/role_permissions/role_permissions.json @@ -0,0 +1,20 @@ +{ + "action": "Watch Video", + "creation": "2021-11-23 14:00:27.208500", + "description": "In ERPNext, you can add your Employees as Users, and give them restricted access. Tools like Role Permission and User Permission allow you to define rules which give restricted access to the user to masters and transactions.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 15:04:14.615232", + "modified_by": "Administrator", + "name": "Role Permissions", + "owner": "Administrator", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Setup Limited Access for a User", + "validate_action": 1, + "video_url": "https://youtu.be/g3mk45o1zAg" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/workflows/workflows.json b/frappe/custom/onboarding_step/workflows/workflows.json new file mode 100644 index 0000000000..683b7a398a --- /dev/null +++ b/frappe/custom/onboarding_step/workflows/workflows.json @@ -0,0 +1,20 @@ +{ + "action": "Watch Video", + "creation": "2021-11-23 13:58:58.530044", + "description": "Workflows allow you to define custom rules for the approval process of a particular document in ERPNext. You can also set complex Workflow Rules and set approval conditions.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 15:04:14.632144", + "modified_by": "Administrator", + "name": "Workflows", + "owner": "Administrator", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Setup Approval Workflows", + "validate_action": 1, + "video_url": "https://youtu.be/yObJUg9FxFs" +} \ No newline at end of file diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json index 7aec530604..8938bdec9c 100644 --- a/frappe/custom/workspace/customization/customization.json +++ b/frappe/custom/workspace/customization/customization.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Customize Form\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Custom Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Client Script\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Server Script\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Dashboards\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Form Customization\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other\", \"col\": 4}}]", + "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Custom Role\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Dashboards\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Form Customization\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other\",\"col\":4}}]", "creation": "2020-03-02 15:15:03.839594", "docstatus": 0, "doctype": "Workspace", @@ -123,7 +123,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:15:57.486113", + "modified": "2021-11-24 16:20:03.500885", "modified_by": "Administrator", "module": "Custom", "name": "Customization", diff --git a/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py b/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py index 94ed77e2ec..d13912b431 100644 --- a/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py +++ b/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py @@ -1,5 +1,4 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, Frappe Technologies and contributors +# Copyright (c) 2021, Frappe Technologies and contributors # License: MIT. See LICENSE import frappe @@ -8,6 +7,20 @@ from frappe.modules.export_file import export_to_files, create_init_py from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.model.document import Document + +def get_mapping_module(module, mapping_name): + app_name = frappe.db.get_value("Module Def", module, "app_name") + mapping_name = frappe.scrub(mapping_name) + module = frappe.scrub(module) + + try: + return frappe.get_module( + f"{app_name}.{module}.data_migration_mapping.{mapping_name}" + ) + except ImportError: + return None + + class DataMigrationPlan(Document): def on_update(self): # update custom fields in mappings @@ -54,26 +67,14 @@ class DataMigrationPlan(Document): frappe.flags.ignore_in_install = False def pre_process_doc(self, mapping_name, doc): - module = self.get_mapping_module(mapping_name) + module = get_mapping_module(self.module, mapping_name) if module and hasattr(module, 'pre_process'): return module.pre_process(doc) return doc def post_process_doc(self, mapping_name, local_doc=None, remote_doc=None): - module = self.get_mapping_module(mapping_name) + module = get_mapping_module(self.module, mapping_name) if module and hasattr(module, 'post_process'): return module.post_process(local_doc=local_doc, remote_doc=remote_doc) - - def get_mapping_module(self, mapping_name): - try: - module_def = frappe.get_doc("Module Def", self.module) - module = frappe.get_module('{app}.{module}.data_migration_mapping.{mapping_name}'.format( - app= module_def.app_name, - module=frappe.scrub(self.module), - mapping_name=frappe.scrub(mapping_name) - )) - return module - except ImportError: - return None diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py index b0e3183d4f..7b26ac31b3 100644 --- a/frappe/database/__init__.py +++ b/frappe/database/__init__.py @@ -4,6 +4,8 @@ # Database Module # -------------------- +from frappe.database.database import savepoint + def setup_database(force, source_sql=None, verbose=None, no_mariadb_socket=False): import frappe if frappe.conf.db_type == 'postgres': diff --git a/frappe/database/database.py b/frappe/database/database.py index c0d377fd42..65242e0419 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -4,23 +4,25 @@ # Database Module # -------------------- -import re -import time -from typing import Dict, List, Union -import frappe import datetime +import random +import re +import string +from contextlib import contextmanager +from time import time +from typing import Dict, List, Union, Tuple + +import frappe import frappe.defaults import frappe.model.meta - from frappe import _ -from time import time from frappe.utils import now, getdate, cast, get_datetime from frappe.model.utils.link_count import flush_local_link_count from frappe.query_builder.functions import Count from frappe.query_builder.functions import Min, Max, Avg, Sum from frappe.query_builder.utils import Column from .query import Query -from pypika.terms import PseudoColumn +from pypika.terms import Criterion, PseudoColumn class Database(object): @@ -162,19 +164,19 @@ class Database(object): frappe.errprint(("Execution time: {0} sec").format(round(time_end - time_start, 2))) except Exception as e: - if frappe.conf.db_type == 'postgres': - self.rollback() - - elif self.is_syntax_error(e): + if self.is_syntax_error(e): # only for mariadb frappe.errprint('Syntax error in query:') frappe.errprint(query) elif self.is_deadlocked(e): - raise frappe.QueryDeadlockError + raise frappe.QueryDeadlockError(e) elif self.is_timedout(e): - raise frappe.QueryTimeoutError + raise frappe.QueryTimeoutError(e) + + elif frappe.conf.db_type == 'postgres': + raise if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): pass @@ -260,13 +262,12 @@ class Database(object): self.commit() self.sql(query, debug=debug) + def check_transaction_status(self, query): """Raises exception if more than 20,000 `INSERT`, `UPDATE` queries are executed in one transaction. This is to ensure that writes are always flushed otherwise this could cause the system to hang.""" - if self.transaction_writes and \ - query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create', "begin", "truncate"]: - raise Exception('This statement can cause implicit commit') + self.check_implicit_commit(query) if query and query.strip().lower() in ('commit', 'rollback'): self.transaction_writes = 0 @@ -279,6 +280,11 @@ class Database(object): else: frappe.throw(_("Too many writes in one request. Please send smaller requests"), frappe.ValidationError) + def check_implicit_commit(self, query): + if self.transaction_writes and \ + query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create', "begin", "truncate"]: + raise Exception('This statement can cause implicit commit') + def fetch_as_dict(self, formatted=0, as_utf8=0): """Internal. Converts results to dict.""" result = self._cursor.fetchall() @@ -334,8 +340,21 @@ class Database(object): """Returns `get_value` with fieldname='*'""" return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache) - def get_value(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, - debug=False, order_by=None, cache=False, for_update=False, run=True): + def get_value( + self, + doctype, + filters=None, + fieldname="name", + ignore=None, + as_dict=False, + debug=False, + order_by="KEEP_DEFAULT_ORDERING", + cache=False, + for_update=False, + run=True, + pluck=False, + distinct=False, + ): """Returns a document property or list of properties. :param doctype: DocType name. @@ -362,7 +381,7 @@ class Database(object): """ ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug, - order_by, cache=cache, for_update=for_update, run=run) + order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct) if not run: return ret @@ -370,7 +389,8 @@ class Database(object): return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, - debug=False, order_by=None, update=None, cache=False, for_update=False, run=True): + debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False, + run=True, pluck=False, distinct=False): """Returns multiple document properties. :param doctype: DocType name. @@ -379,7 +399,8 @@ class Database(object): :param ignore: Don't raise exception if table, column is missing. :param as_dict: Return values as dict. :param debug: Print query in error log. - :param order_by: Column to order by + :param order_by: Column to order by, + :param distinct: Get Distinct results. Example: @@ -394,9 +415,20 @@ class Database(object): (doctype, filters, fieldname) in self.value_cache: return self.value_cache[(doctype, filters, fieldname)] + if distinct: + order_by = None + if isinstance(filters, list): - order_by = order_by or "modified_desc" - out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug, run=run) + out = self._get_value_for_many_names( + doctype, + filters, + fieldname, + order_by, + debug=debug, + run=run, + pluck=pluck, + distinct=distinct, + ) else: fields = fieldname @@ -408,9 +440,20 @@ class Database(object): if (filters is not None) and (filters!=doctype or doctype=="DocType"): try: - order_by = order_by or "modified" + if order_by: + order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by out = self._get_values_from_table( - fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update, run=run + fields, + filters, + doctype, + as_dict, + debug, + order_by, + update, + for_update=for_update, + run=run, + pluck=pluck, + distinct=distinct ) except Exception as e: if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)): @@ -418,19 +461,30 @@ class Database(object): out = None elif (not ignore) and frappe.db.is_table_missing(e): # table not found, look in singles - out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) + out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run, distinct=distinct) else: raise else: - out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) + out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run, pluck=pluck, distinct=distinct) if cache and isinstance(filters, str): self.value_cache[(doctype, filters, fieldname)] = out return out - def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None, run=True): + def get_values_from_single( + self, + fields, + filters, + doctype, + as_dict=False, + debug=False, + update=None, + run=True, + pluck=False, + distinct=False, + ): """Get values from `tabSingles` (Single DocTypes) (internal). :param fields: List of fields, @@ -456,10 +510,13 @@ class Database(object): return [map(values.get, fields)] else: - r = self.sql("""select field, value - from `tabSingles` where field in (%s) and doctype=%s""" - % (', '.join(['%s'] * len(fields)), '%s'), - tuple(fields) + (doctype,), as_dict=False, debug=debug, run=run) + r = self.query.get_sql( + "Singles", + filters={"field": ("in", tuple(fields)), "doctype": doctype}, + fields=["field", "value"], + distinct=distinct, + ).run(pluck=pluck, debug=debug, as_dict=False) + if not run: return r if as_dict: @@ -484,14 +541,10 @@ class Database(object): # Get coulmn and value of the single doctype Accounts Settings account_settings = frappe.db.get_singles_dict("Accounts Settings") """ - result = self.sql(""" - SELECT field, value - FROM `tabSingles` - WHERE doctype = %s - """, doctype) - + result = self.query.get_sql( + "Singles", filters={"doctype": doctype}, fields=["field", "value"] + ).run() dict_ = frappe._dict(result) - return dict_ @staticmethod @@ -520,8 +573,11 @@ class Database(object): if fieldname in self.value_cache[doctype]: return self.value_cache[doctype][fieldname] - val = self.sql("""select `value` from - `tabSingles` where `doctype`=%s and `field`=%s""", (doctype, fieldname)) + val = self.query.get_sql( + table="Singles", + filters={"doctype": doctype, "field": fieldname}, + fields="value", + ).run() val = val[0][0] if val else None df = frappe.get_meta(doctype).get_field(fieldname) @@ -539,37 +595,64 @@ class Database(object): """Alias for get_single_value""" return self.get_single_value(*args, **kwargs) - def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, - update=None, for_update=False, run=True): + def _get_values_from_table( + self, + fields, + filters, + doctype, + as_dict, + debug, + order_by=None, + update=None, + for_update=False, + run=True, + pluck=False, + distinct=False, + ): field_objects = [] - for field in fields: - if "(" in field or " as " in field: - field_objects.append(PseudoColumn(field)) - else: - field_objects.append(field) + if not isinstance(fields, Criterion): + for field in fields: + if "(" in str(field) or " as " in str(field): + field_objects.append(PseudoColumn(field)) + else: + field_objects.append(field) - criterion = self.query.build_conditions( - table=doctype, filters=filters, orderby=order_by, for_update=for_update + query = self.query.get_sql( + table=doctype, + filters=filters, + orderby=order_by, + for_update=for_update, + field_objects=field_objects, + fields=fields, + distinct=distinct, ) + if ( + fields == "*" + and not isinstance(fields, (list, tuple)) + and not isinstance(fields, Criterion) + ): + as_dict = True - if isinstance(fields, (list, tuple)): - query = criterion.select(*field_objects) - else: - if fields=="*": - query = criterion.select(fields) - as_dict = True - r = self.sql(query, as_dict=as_dict, debug=debug, update=update, run=run) + r = self.sql( + query, as_dict=as_dict, debug=debug, update=update, run=run, pluck=pluck + ) return r - def _get_value_for_many_names(self, doctype, names, field, debug=False, run=True): + def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False): names = list(filter(None, names)) - if names: - return self.get_all(doctype, - fields=['name', field], - filters=[['name', 'in', names]], - debug=debug, as_list=1, run=run) + return self.get_all( + doctype, + fields=field, + filters=names, + order_by=order_by, + pluck=pluck, + debug=debug, + as_list=1, + run=run, + distinct=distinct, + ) else: return {} @@ -621,6 +704,8 @@ class Database(object): self.sql("""update `tab{0}` set {1} where name=%(name)s""".format(dt, ', '.join(set_values)), values, debug=debug) + + frappe.clear_document_cache(dt, values['name']) else: # for singles keys = list(to_update) @@ -633,10 +718,11 @@ class Database(object): self.sql('''insert into `tabSingles` (doctype, field, value) values (%s, %s, %s)''', (dt, key, value), debug=debug) + frappe.clear_document_cache(dt, dn) + if dt in self.value_cache: del self.value_cache[dt] - frappe.clear_document_cache(dt, dn) @staticmethod def set(doc, field, val): @@ -723,14 +809,30 @@ class Database(object): frappe.local.realtime_log = [] - def rollback(self): - """`ROLLBACK` current transaction.""" - self.sql("rollback") - self.begin() - for obj in frappe.local.rollback_observers: - if hasattr(obj, "on_rollback"): - obj.on_rollback() - frappe.local.rollback_observers = [] + def savepoint(self, save_point): + """Savepoints work as a nested transaction. + + Changes can be undone to a save point by doing frappe.db.rollback(save_point) + + Note: rollback watchers can not work with save points. + so only changes to database are undone when rolling back to a savepoint. + Avoid using savepoints when writing to filesystem.""" + self.sql(f"savepoint {save_point}") + + def release_savepoint(self, save_point): + self.sql(f"release savepoint {save_point}") + + def rollback(self, *, save_point=None): + """`ROLLBACK` current transaction. Optionally rollback to a known save_point.""" + if save_point: + self.sql(f"rollback to savepoint {save_point}") + else: + self.sql("rollback") + self.begin() + for obj in frappe.local.rollback_observers: + if hasattr(obj, "on_rollback"): + obj.on_rollback() + frappe.local.rollback_observers = [] def field_exists(self, dt, fn): """Return true of field exists.""" @@ -739,16 +841,16 @@ class Database(object): 'parent': dt }) - def table_exists(self, doctype): + def table_exists(self, doctype, cached=True): """Returns True if table for given doctype exists.""" - return ("tab" + doctype) in self.get_tables() + return ("tab" + doctype) in self.get_tables(cached=cached) def has_table(self, doctype): return self.table_exists(doctype) - def get_tables(self): + def get_tables(self, cached=True): tables = frappe.cache().get_value('db_tables') - if not tables: + if not tables or not cached: table_rows = self.sql(""" SELECT table_name FROM information_schema.tables @@ -785,25 +887,13 @@ class Database(object): except Exception: return None - def min(self, dt, fieldname, filters=None, **kwargs): - return self.query.build_conditions(dt, filters=filters).select(Min(Column(fieldname))).run(**kwargs)[0][0] or 0 - - def max(self, dt, fieldname, filters=None, **kwargs): - return self.query.build_conditions(dt, filters=filters).select(Max(Column(fieldname))).run(**kwargs)[0][0] or 0 - - def avg(self, dt, fieldname, filters=None, **kwargs): - return self.query.build_conditions(dt, filters=filters).select(Avg(Column(fieldname))).run(**kwargs)[0][0] or 0 - - def sum(self, dt, fieldname, filters=None, **kwargs): - return self.query.build_conditions(dt, filters=filters).select(Sum(Column(fieldname))).run(**kwargs)[0][0] or 0 - def count(self, dt, filters=None, debug=False, cache=False): """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.build_conditions(table=dt, filters=filters).select(Count("*")) + query = self.query.get_sql(table=dt, filters=filters, fields=Count("*")) if filters: count = self.sql(query, debug=debug)[0][0] return count @@ -1018,3 +1108,28 @@ def enqueue_jobs_after_commit(): q.enqueue_call(execute_job, timeout=job.get("timeout"), kwargs=job.get("queue_args")) frappe.flags.enqueue_after_commit = [] + +@contextmanager +def savepoint(catch: Union[type, Tuple[type, ...]] = Exception): + """ Wrapper for wrapping blocks of DB operations in a savepoint. + + as contextmanager: + + for doc in docs: + with savepoint(catch=DuplicateError): + doc.insert() + + as decorator (wraps FULL function call): + + @savepoint(catch=DuplicateError) + def process_doc(doc): + doc.insert() + """ + try: + savepoint = ''.join(random.sample(string.ascii_lowercase, 10)) + frappe.db.savepoint(savepoint) + yield # control back to calling function + except catch: + frappe.db.rollback(save_point=savepoint) + else: + frappe.db.release_savepoint(savepoint) diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 9e0e8e1b51..21d5a9962c 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -43,7 +43,7 @@ class MariaDBDatabase(Database): 'Dynamic Link': ('varchar', self.VARCHAR_LEN), 'Password': ('text', ''), 'Select': ('varchar', self.VARCHAR_LEN), - 'Rating': ('int', '1'), + 'Rating': ('decimal', '3,2'), 'Read Only': ('varchar', self.VARCHAR_LEN), 'Attach': ('text', ''), 'Attach Image': ('text', ''), @@ -136,9 +136,10 @@ class MariaDBDatabase(Database): table_name = get_table_name(doctype) return self.sql(f"DESC `{table_name}`") - def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]: + def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]: table_name = get_table_name(doctype) - return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} NOT NULL") + null_constraint = "NOT NULL" if not nullable else "" + return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}") # exception types @staticmethod diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 73b98f0ff3..cfb4e243a2 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -25,6 +25,7 @@ CREATE TABLE `tabDocField` ( `oldfieldtype` varchar(255) DEFAULT NULL, `options` text, `search_index` int(1) NOT NULL DEFAULT 0, + `show_dashboard` int(1) NOT NULL DEFAULT 0, `hidden` int(1) NOT NULL DEFAULT 0, `set_only_once` int(1) NOT NULL DEFAULT 0, `allow_in_quick_entry` int(1) NOT NULL DEFAULT 0, diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index 8088cc2331..1585e4537b 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -92,7 +92,7 @@ def bootstrap_database(db_name, verbose, source_sql=None): import_db_from_sql(source_sql, verbose) frappe.connect(db_name=db_name) - if 'tabDefaultValue' not in frappe.db.get_tables(): + if 'tabDefaultValue' not in frappe.db.get_tables(cached=False): from click import secho secho( diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 3ad3c10869..b1ecc0a40b 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -3,7 +3,7 @@ from typing import List, Tuple, Union import psycopg2 import psycopg2.extensions -from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from psycopg2.extensions import ISOLATION_LEVEL_REPEATABLE_READ from psycopg2.errorcodes import STRING_DATA_RIGHT_TRUNCATION import frappe @@ -53,7 +53,7 @@ class PostgresDatabase(Database): 'Dynamic Link': ('varchar', self.VARCHAR_LEN), 'Password': ('text', ''), 'Select': ('varchar', self.VARCHAR_LEN), - 'Rating': ('smallint', None), + 'Rating': ('decimal', '3,2'), 'Read Only': ('varchar', self.VARCHAR_LEN), 'Attach': ('text', ''), 'Attach Image': ('text', ''), @@ -70,7 +70,7 @@ class PostgresDatabase(Database): conn = psycopg2.connect("host='{}' dbname='{}' user='{}' password='{}' port={}".format( self.host, self.user, self.user, self.password, self.port )) - conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) # TODO: Remove this + conn.set_isolation_level(ISOLATION_LEVEL_REPEATABLE_READ) return conn @@ -104,7 +104,7 @@ class PostgresDatabase(Database): return super(PostgresDatabase, self).sql(*args, **kwargs) - def get_tables(self): + def get_tables(self, cached=True): return [d[0] for d in self.sql("""select table_name from information_schema.tables where table_catalog='{0}' @@ -139,6 +139,10 @@ class PostgresDatabase(Database): # http://initd.org/psycopg/docs/extensions.html?highlight=datatype#psycopg2.extensions.QueryCanceledError return isinstance(e, psycopg2.extensions.QueryCanceledError) + @staticmethod + def is_syntax_error(e): + return isinstance(e, psycopg2.errors.SyntaxError) + @staticmethod def is_table_missing(e): return getattr(e, 'pgcode', None) == '42P01' @@ -184,9 +188,12 @@ class PostgresDatabase(Database): table_name = get_table_name(doctype) return self.sql(f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}'") - def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]: + def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]: table_name = get_table_name(doctype) - return self.sql(f'ALTER TABLE "{table_name}" ALTER COLUMN "{column}" TYPE {type}') + null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL" + return self.sql(f"""ALTER TABLE "{table_name}" + ALTER COLUMN "{column}" TYPE {type}, + ALTER COLUMN "{column}" {null_constraint}""") def create_auth_table(self): self.sql_ddl("""create table if not exists "__Auth" ( @@ -253,8 +260,8 @@ class PostgresDatabase(Database): key=key ) - def check_transaction_status(self, query): - pass + def check_implicit_commit(self, query): + pass # postgres can run DDL in transactions without implicit commits def has_index(self, table_name, index_name): return self.sql("""SELECT 1 FROM pg_indexes WHERE tablename='{table_name}' diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index e8e047f194..f911e34650 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -27,6 +27,7 @@ CREATE TABLE "tabDocField" ( "search_index" smallint NOT NULL DEFAULT 0, "hidden" smallint NOT NULL DEFAULT 0, "set_only_once" smallint NOT NULL DEFAULT 0, + "show_dashboard" smallint NOT NULL DEFAULT 0, "allow_in_quick_entry" smallint NOT NULL DEFAULT 0, "print_hide" smallint NOT NULL DEFAULT 0, "report_hide" smallint NOT NULL DEFAULT 0, diff --git a/frappe/database/query.py b/frappe/database/query.py index 3545efb412..6d2be5fa25 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -1,8 +1,10 @@ import operator +import re from typing import Any, Dict, List, Tuple, Union import frappe -from frappe.query_builder import Criterion, Order, Field +from frappe import _ +from frappe.query_builder import Criterion, Field, Order def like(key: str, value: str) -> frappe.qb: @@ -224,6 +226,7 @@ class Query: """ conditions = self.get_condition(table, **kwargs) if not filters: + conditions = self.add_conditions(conditions, **kwargs) return conditions for key in filters: @@ -245,7 +248,12 @@ class Query: conditions = self.add_conditions(conditions, **kwargs) return conditions - def build_conditions(self, table: str, filters: Union[Dict[str, Union[str, int]], str, int] = None, **kwargs) -> frappe.qb: + def build_conditions( + self, + table: str, + filters: Union[Dict[str, Union[str, int]], str, int] = None, + **kwargs + ) -> frappe.qb: """Build conditions for sql query Args: @@ -255,13 +263,67 @@ class Query: Returns: frappe.qb: frappe.qb conditions object """ - if isinstance(filters, Criterion): - return self.criterion_query(table, filters, **kwargs) - if isinstance(filters, int) or isinstance(filters, str): filters = {"name": str(filters)} - if isinstance(filters, (list, tuple)): - return self.misc_query(table, filters, **kwargs) + if isinstance(filters, Criterion): + criterion = self.criterion_query(table, filters, **kwargs) - return self.dict_query(filters=filters, table=table, **kwargs) + elif isinstance(filters, (list, tuple)): + criterion = self.misc_query(table, filters, **kwargs) + + else: + criterion = self.dict_query(filters=filters, table=table, **kwargs) + + return criterion + + def get_sql( + self, + table: str, + fields: Union[List, Tuple], + filters: Union[Dict[str, Union[str, int]], str, int] = None, + **kwargs + ): + criterion = self.build_conditions(table, filters, **kwargs) + if isinstance(fields, (list, tuple)): + query = criterion.select(*kwargs.get("field_objects", fields)) + + elif isinstance(fields, Criterion): + query = criterion.select(fields) + + else: + query = criterion.select(fields) + + return query + + +class Permission: + @classmethod + def check_permissions(cls, query, **kwargs): + if not isinstance(query, str): + query = query.get_sql() + + doctype = cls.get_tables_from_query(query) + if isinstance(doctype, str): + doctype = [doctype] + + for dt in doctype: + dt = re.sub("tab", "", dt) + if not frappe.has_permission( + dt, + "select", + user=kwargs.get("user"), + parent_doctype=kwargs.get("parent_doctype"), + ) and not frappe.has_permission( + dt, + "read", + user=kwargs.get("user"), + parent_doctype=kwargs.get("parent_doctype"), + ): + frappe.throw( + _("Insufficient Permission for {0}").format(frappe.bold(dt)) + ) + + @staticmethod + def get_tables_from_query(query: str): + return [table for table in re.findall(r"\w+", query) if table.startswith("tab")] diff --git a/frappe/database/schema.py b/frappe/database/schema.py index ce9fcb4147..10582eff8f 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -206,6 +206,12 @@ class DbColumn: if not current_def: self.fieldname = validate_column_name(self.fieldname) self.table.add_column.append(self) + + if column_type not in ('text', 'longtext'): + if self.unique: + self.table.add_unique.append(self) + if self.set_index: + self.table.add_index.append(self) return # type diff --git a/frappe/deferred_insert.py b/frappe/deferred_insert.py index 499fc5e41b..b1338a73b0 100644 --- a/frappe/deferred_insert.py +++ b/frappe/deferred_insert.py @@ -5,7 +5,6 @@ from frappe.utils import cstr queue_prefix = 'insert_queue_for_' -@frappe.whitelist() def deferred_insert(doctype, records): frappe.cache().rpush(queue_prefix + doctype, records) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index 635d32d969..e0d2cab8ef 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -45,6 +45,7 @@ frappe.ui.form.on('Dashboard Chart', { frm.set_df_property("filters_section", "hidden", 1); frm.set_df_property("dynamic_filters_section", "hidden", 1); + frm.trigger('set_parent_document_type'); frm.trigger('set_time_series'); frm.set_query('document_type', function() { return { @@ -110,9 +111,11 @@ frappe.ui.form.on('Dashboard Chart', { frm.set_value('source', ''); frm.set_value('based_on', ''); frm.set_value('value_based_on', ''); + frm.set_value('parent_document_type', ''); frm.set_value('filters_json', '[]'); frm.set_value('dynamic_filters_json', '[]'); frm.trigger('update_options'); + frm.trigger('set_parent_document_type'); }, report_name: function(frm) { @@ -125,7 +128,6 @@ frappe.ui.form.on('Dashboard Chart', { frm.trigger('set_chart_report_filters'); }, - set_chart_report_filters: function(frm) { let report_name = frm.doc.report_name; @@ -148,6 +150,10 @@ frappe.ui.form.on('Dashboard Chart', { } }, + use_report_chart: function(frm) { + !frm.doc.use_report_chart && frm.trigger('set_chart_field_options'); + }, + set_chart_field_options: function(frm) { let filters = frm.doc.filters_json.length > 2 ? JSON.parse(frm.doc.filters_json) : null; if (frm.doc.dynamic_filters_json && frm.doc.dynamic_filters_json.length > 2) { @@ -179,6 +185,9 @@ frappe.ui.form.on('Dashboard Chart', { } else { frappe.msgprint(__('Report has no data, please modify the filters or change the Report Name')); } + } else { + frm.set_value('use_report_chart', 1); + frm.set_df_property('use_report_chart', 'hidden', false); } }); }, @@ -365,6 +374,7 @@ frappe.ui.form.on('Dashboard Chart', { frm.filter_group = new frappe.ui.FilterGroup({ parent: dialog.get_field('filter_area').$wrapper, doctype: frm.doc.document_type, + parent_doctype: frm.doc.parent_document_type, on_change: () => {}, }); @@ -481,6 +491,36 @@ frappe.ui.form.on('Dashboard Chart', { frm.dynamic_filter_table.find('tbody').html(filter_rows); } + }, + + set_parent_document_type: async function(frm) { + let document_type = frm.doc.document_type; + let doc_is_table = document_type && + (await frappe.db.get_value('DocType', document_type, 'istable')).message.istable; + + frm.set_df_property('parent_document_type', 'hidden', !doc_is_table); + + if (document_type && doc_is_table) { + let parent = await frappe.db.get_list('DocField', { + filters: { + 'fieldtype': 'Table', + 'options': document_type + }, + fields: ['parent'] + }); + + parent && frm.set_query('parent_document_type', function() { + return { + filters: { + "name": ['in', parent.map(({ parent }) => parent)] + } + }; + }); + + if (parent.length === 1) { + frm.set_value('parent_document_type', parent[0].parent); + } + } } }); diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json index d4bba53068..a5d30c10e5 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json @@ -17,6 +17,7 @@ "y_axis", "source", "document_type", + "parent_document_type", "based_on", "value_based_on", "group_by_type", @@ -268,10 +269,18 @@ "fieldname": "use_report_chart", "fieldtype": "Check", "label": "Use Report Chart" + }, + { + "depends_on": "eval: doc.chart_type !== 'Custom' && doc.chart_type !== 'Report'", + "description": "The document type selected is a child table, so the parent document type is required.", + "fieldname": "parent_document_type", + "fieldtype": "Link", + "label": "Parent Document Type", + "options": "DocType" } ], "links": [], - "modified": "2020-07-23 11:10:33.509497", + "modified": "2021-11-09 17:18:11.456145", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Chart", diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 598b5e0b2b..cb77ef7a1a 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -333,7 +333,10 @@ class DashboardChart(Document): def check_required_field(self): if not self.document_type: - frappe.throw(_("Document type is required to create a dashboard chart")) + frappe.throw(_("Document type is required to create a dashboard chart")) + + if self.document_type and frappe.get_meta(self.document_type).istable and not self.parent_document_type: + frappe.throw(_("Parent document type is required to create a dashboard chart")) if self.chart_type == 'Group By': if not self.group_by_based_on: diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 5562f2fc92..5c986b5b7c 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -8,6 +8,7 @@ from frappe.desk.doctype.dashboard_chart.dashboard_chart import get from datetime import datetime from dateutil.relativedelta import relativedelta +from unittest.mock import patch class TestDashboardChart(unittest.TestCase): def test_period_ending(self): @@ -15,8 +16,9 @@ class TestDashboardChart(unittest.TestCase): getdate('2019-04-10')) # week starts on monday - self.assertEqual(get_period_ending('2019-04-10', 'Weekly'), - getdate('2019-04-14')) + with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): + self.assertEqual(get_period_ending('2019-04-10', 'Weekly'), + getdate('2019-04-14')) self.assertEqual(get_period_ending('2019-04-10', 'Monthly'), getdate('2019-04-30')) @@ -200,13 +202,14 @@ class TestDashboardChart(unittest.TestCase): timeseries = 1 )).insert() - result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) + with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): + result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) - self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0]) - self.assertEqual( - result.get('labels'), - ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] - ) + self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0]) + self.assertEqual( + result.get('labels'), + ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] + ) frappe.db.rollback() @@ -231,13 +234,13 @@ class TestDashboardChart(unittest.TestCase): timeseries = 1 )).insert() - result = get(chart_name='Test Average Dashboard Chart', refresh = 1) - - self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 150.0, 266.6666666666667, 0.0]) - self.assertEqual( - result.get('labels'), - ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] - ) + with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): + result = get(chart_name='Test Average Dashboard Chart', refresh = 1) + self.assertEqual( + result.get('labels'), + ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] + ) + self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 150.0, 266.6666666666667, 0.0]) frappe.db.rollback() diff --git a/frappe/desk/doctype/event/event.json b/frappe/desk/doctype/event/event.json index 5768f00f32..2f67c36fc0 100644 --- a/frappe/desk/doctype/event/event.json +++ b/frappe/desk/doctype/event/event.json @@ -53,7 +53,7 @@ }, { "fieldname": "subject", - "fieldtype": "Data", + "fieldtype": "Small Text", "in_global_search": 1, "in_list_view": 1, "label": "Subject", @@ -277,10 +277,11 @@ "icon": "fa fa-calendar", "idx": 1, "links": [], - "modified": "2020-01-14 21:47:15.825287", + "modified": "2021-11-18 05:06:24.881742", "modified_by": "Administrator", "module": "Desk", "name": "Event", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index d4c185e56f..86f0656bc6 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -11,6 +11,7 @@ from frappe import _ from frappe.model.document import Document from frappe.utils.user import get_enabled_system_users from frappe.desk.reportview import get_filters_cond +from frappe.desk.doctype.notification_settings.notification_settings import is_email_notifications_enabled_for_type weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] communication_mapping = {"": "Event", "Event": "Event", "Meeting": "Meeting", "Call": "Phone", "Sent/Received Email": "Email", "Other": "Other"} @@ -141,7 +142,12 @@ def has_permission(doc, user): def send_event_digest(): today = nowdate() - for user in get_enabled_system_users(): + + # select only those users that have event reminder email notifications enabled + users = [user for user in get_enabled_system_users() if + is_email_notifications_enabled_for_type(user.name, 'Event Reminders')] + + for user in users: events = get_events(today, today, user.name, for_reminder=True) if events: frappe.set_user_lang(user.name, user.language) diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py index 6b7f6ee471..b0269a80cc 100644 --- a/frappe/desk/doctype/event/test_event.py +++ b/frappe/desk/doctype/event/test_event.py @@ -93,7 +93,7 @@ class TestEvent(unittest.TestCase): # Remove an assignment todo = frappe.get_doc("ToDo", {"reference_type": ev.doctype, "reference_name": ev.name, - "owner": self.test_user}) + "allocated_to": self.test_user}) todo.status = "Cancelled" todo.save() diff --git a/frappe/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js index 8d70dcd3dc..6a7c736fac 100644 --- a/frappe/desk/doctype/form_tour/form_tour.js +++ b/frappe/desk/doctype/form_tour/form_tour.js @@ -15,10 +15,13 @@ frappe.ui.form.on('Form Tour', { frm.add_custom_button(__('Show Tour'), async () => { const issingle = await check_if_single(frm.doc.reference_doctype); + const name = await get_first_document(frm.doc.reference_doctype); let route_changed = null; if (issingle) { route_changed = frappe.set_route('Form', frm.doc.reference_doctype); + } else if (frm.doc.first_document) { + route_changed = frappe.set_route('Form', frm.doc.reference_doctype, name); } else { route_changed = frappe.set_route('Form', frm.doc.reference_doctype, 'new'); } @@ -120,4 +123,15 @@ function get_child_field(child_table, child_name, fieldname) { async function check_if_single(doctype) { const { message } = await frappe.db.get_value('DocType', doctype, 'issingle'); return message.issingle || 0; -} \ No newline at end of file +} + +async function get_first_document(doctype) { + let docname; + + await frappe.db.get_list(doctype, { order_by: "creation" }).then(res => { + if (Array.isArray(res) && res.length) + docname = res[0].name; + }); + + return docname || 'new'; +} diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json index e4ea528fcc..6f3bd56a4e 100644 --- a/frappe/desk/doctype/form_tour/form_tour.json +++ b/frappe/desk/doctype/form_tour/form_tour.json @@ -9,8 +9,11 @@ "title", "reference_doctype", "module", + "column_break_6", "is_standard", "save_on_complete", + "first_document", + "include_name_field", "section_break_3", "steps" ], @@ -62,14 +65,32 @@ "label": "Module", "options": "Module Def", "read_only": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "first_document", + "fieldtype": "Check", + "label": "Show First Document Tour" + }, + { + "default": "0", + "depends_on": "eval:!doc.first_document", + "fieldname": "include_name_field", + "fieldtype": "Check", + "label": "Include Name Field" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-06-06 20:32:54.068774", + "modified": "2021-11-24 12:03:45.449311", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/global_search_settings/global_search_settings.py b/frappe/desk/doctype/global_search_settings/global_search_settings.py index 9ffe9aaf06..e9a47cecd1 100644 --- a/frappe/desk/doctype/global_search_settings/global_search_settings.py +++ b/frappe/desk/doctype/global_search_settings/global_search_settings.py @@ -33,7 +33,7 @@ class GlobalSearchSettings(Document): def get_doctypes_for_global_search(): def get_from_db(): - doctypes = frappe.get_list("Global Search DocType", fields=["document_type"], order_by="idx ASC") + doctypes = frappe.get_all("Global Search DocType", fields=["document_type"], order_by="idx ASC") return [d.document_type for d in doctypes] or [] return frappe.cache().hget("global_search", "search_priorities", get_from_db) diff --git a/frappe/desk/doctype/kanban_board_column/kanban_board_column.json b/frappe/desk/doctype/kanban_board_column/kanban_board_column.json index 95d9294e9a..c0acde5da5 100644 --- a/frappe/desk/doctype/kanban_board_column/kanban_board_column.json +++ b/frappe/desk/doctype/kanban_board_column/kanban_board_column.json @@ -1,155 +1,55 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-10-19 12:26:42.569185", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2016-10-19 12:26:42.569185", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "column_name", + "status", + "indicator", + "order" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Column Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "column_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Column Name" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Active", - "fieldname": "status", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Status", - "length": 0, - "no_copy": 0, - "options": "Active\nArchived", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "Active", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Active\nArchived" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "darkgrey", - "fieldname": "indicator", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Indicator", - "length": 0, - "no_copy": 0, - "options": "blue\norange\nred\ngreen\ndarkgrey\npurple\nyellow\nlightblue", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "Gray", + "fieldname": "indicator", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Indicator", + "options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nRed\nYellow" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "order", - "fieldtype": "Code", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Order", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "order", + "fieldtype": "Code", + "label": "Order" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2017-01-17 15:23:43.520379", - "modified_by": "Administrator", - "module": "Desk", - "name": "Kanban Board Column", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2021-12-14 13:13:38.804259", + "modified_by": "Administrator", + "module": "Desk", + "name": "Kanban Board Column", + "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/note/note.css b/frappe/desk/doctype/note/note.css deleted file mode 100644 index b5026d2e46..0000000000 --- a/frappe/desk/doctype/note/note.css +++ /dev/null @@ -1,3 +0,0 @@ -.like-disabled-input{ - background-color: #fff; -} \ No newline at end of file diff --git a/frappe/desk/doctype/notification_settings/notification_settings.json b/frappe/desk/doctype/notification_settings/notification_settings.json index fc12022e89..1a6efd5a0d 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.json +++ b/frappe/desk/doctype/notification_settings/notification_settings.json @@ -14,8 +14,11 @@ "enable_email_assignment", "enable_email_energy_point", "enable_email_share", + "enable_email_event_reminders", "user", - "seen" + "seen", + "system_notifications_section", + "energy_points_system_notifications" ], "fields": [ { @@ -84,15 +87,34 @@ "fieldtype": "Check", "hidden": 1, "label": "Seen" + }, + { + "fieldname": "system_notifications_section", + "fieldtype": "Section Break", + "label": "System Notifications" + }, + { + "default": "1", + "fieldname": "energy_points_system_notifications", + "fieldtype": "Check", + "label": "Energy Points" + }, + { + "default": "1", + "depends_on": "enable_email_notifications", + "fieldname": "enable_email_event_reminders", + "fieldtype": "Check", + "label": "Event Reminders" } ], "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-11-04 12:54:57.989317", + "modified": "2021-11-24 14:45:31.931154", "modified_by": "Administrator", "module": "Desk", "name": "Notification Settings", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/notification_settings/test_notification_settings.py b/frappe/desk/doctype/notification_settings/test_notification_settings.py new file mode 100644 index 0000000000..e3dac0af5f --- /dev/null +++ b/frappe/desk/doctype/notification_settings/test_notification_settings.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestNotificationSettings(unittest.TestCase): + pass diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.js b/frappe/desk/doctype/onboarding_step/onboarding_step.js index 793e044d98..3c9bbab9ac 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.js +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.js @@ -2,6 +2,17 @@ // For license information, please see license.txt frappe.ui.form.on("Onboarding Step", { + + setup: function(frm) { + frm.set_query("form_tour", function() { + return { + filters: { + reference_doctype: frm.doc.reference_document + } + }; + }); + }, + refresh: function(frm) { frappe.boot.developer_mode && frm.set_intro( diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json index f71e821f65..b5d7851eca 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -20,6 +20,7 @@ "reference_document", "show_full_form", "show_form_tour", + "form_tour", "is_single", "reference_report", "report_reference_doctype", @@ -206,13 +207,21 @@ "fieldname": "show_form_tour", "fieldtype": "Check", "label": "Show Form Tour" + }, + { + "depends_on": "show_form_tour", + "fieldname": "form_tour", + "fieldtype": "Link", + "label": "Form Tour", + "options": "Form Tour" } ], "links": [], - "modified": "2020-10-30 14:54:06.646513", + "modified": "2021-12-02 10:56:04.448580", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/route_history/route_history.py b/frappe/desk/doctype/route_history/route_history.py index 01184fcc3a..a49d5d5418 100644 --- a/frappe/desk/doctype/route_history/route_history.py +++ b/frappe/desk/doctype/route_history/route_history.py @@ -1,9 +1,13 @@ # Copyright (c) 2021, Frappe Technologies and contributors # License: MIT. See LICENSE +import json + import frappe +from frappe.deferred_insert import deferred_insert as _deferred_insert from frappe.model.document import Document + class RouteHistory(Document): pass @@ -35,3 +39,16 @@ def flush_old_route_records(): "modified": ("<=", last_record_to_keep[0].modified), "user": user }) + +@frappe.whitelist() +def deferred_insert(routes): + routes = [ + { + "user": frappe.session.user, + "route": route.get("route"), + "creation": route.get("creation"), + } + for route in frappe.parse_json(routes) + ] + + _deferred_insert("Route History", json.dumps(routes)) diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js index 0fe3932671..fc83069fd2 100644 --- a/frappe/desk/doctype/system_console/system_console.js +++ b/frappe/desk/doctype/system_console/system_console.js @@ -100,5 +100,5 @@ frappe.ui.form.on('System Console', { ${rows}`); }); - } + }, }); diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index aff1bd6973..381c24a765 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -4,6 +4,7 @@ import frappe from frappe.model.document import Document from frappe.utils import unique +from frappe.query_builder import DocType class Tag(Document): pass @@ -11,7 +12,8 @@ class Tag(Document): def check_user_tags(dt): "if the user does not have a tags column, then it creates one" try: - frappe.db.sql("select `_user_tags` from `tab%s` limit 1" % dt) + doctype = DocType(dt) + frappe.qb.from_(doctype).select(doctype._user_tags).limit(1).run() except Exception as e: if frappe.db.is_column_missing(e): DocTags(dt).setup() @@ -42,10 +44,12 @@ def remove_tag(tag, dt, dn): @frappe.whitelist() def get_tagged_docs(doctype, tag): frappe.has_permission(doctype, throw=True) - - return frappe.db.sql("""SELECT name - FROM `tab{0}` - WHERE _user_tags LIKE '%{1}%'""".format(doctype, tag)) + doctype = DocType(doctype) + return ( + frappe.qb.from_(doctype) + .where(doctype._user_tags.like(tag)) + .select(doctype.name) + ).run() @frappe.whitelist() def get_tags(doctype, txt): diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json index 15e0e4abe1..518ca00374 100644 --- a/frappe/desk/doctype/todo/todo.json +++ b/frappe/desk/doctype/todo/todo.json @@ -13,7 +13,7 @@ "column_break_2", "color", "date", - "owner", + "allocated_to", "description_section", "description", "section_break_6", @@ -69,15 +69,6 @@ "oldfieldname": "date", "oldfieldtype": "Date" }, - { - "fieldname": "owner", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "in_global_search": 1, - "in_standard_filter": 1, - "label": "Allocated To", - "options": "User" - }, { "fieldname": "description_section", "fieldtype": "Section Break" @@ -153,12 +144,21 @@ "label": "Assignment Rule", "options": "Assignment Rule", "read_only": 1 + }, + { + "fieldname": "allocated_to", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_global_search": 1, + "in_standard_filter": 1, + "label": "Allocated To", + "options": "User" } ], "icon": "fa fa-check", "idx": 2, "links": [], - "modified": "2020-01-14 17:04:36.971002", + "modified": "2021-09-16 11:36:34.586898", "modified_by": "Administrator", "module": "Desk", "name": "ToDo", diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 6f3f4160e6..e689faafbe 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -16,10 +16,10 @@ class ToDo(Document): self._assignment = None if self.is_new(): - if self.assigned_by == self.owner: + if self.assigned_by == self.allocated_to: assignment_message = frappe._("{0} self assigned this task: {1}").format(get_fullname(self.assigned_by), self.description) else: - assignment_message = frappe._("{0} assigned {1}: {2}").format(get_fullname(self.assigned_by), get_fullname(self.owner), self.description) + assignment_message = frappe._("{0} assigned {1}: {2}").format(get_fullname(self.assigned_by), get_fullname(self.allocated_to), self.description) self._assignment = { "text": assignment_message, @@ -29,12 +29,12 @@ class ToDo(Document): else: # NOTE the previous value is only available in validate method if self.get_db_value("status") != self.status: - if self.owner == frappe.session.user: + if self.allocated_to == frappe.session.user: removal_message = frappe._("{0} removed their assignment.").format( get_fullname(frappe.session.user)) else: removal_message = frappe._("Assignment of {0} removed by {1}").format( - get_fullname(self.owner), get_fullname(frappe.session.user)) + get_fullname(self.allocated_to), get_fullname(frappe.session.user)) self._assignment = { "text": removal_message, @@ -69,15 +69,13 @@ class ToDo(Document): return try: - assignments = [d[0] for d in frappe.get_all("ToDo", - filters={ - "reference_type": self.reference_type, - "reference_name": self.reference_name, - "status": ("!=", "Cancelled") - }, - fields=["owner"], as_list=True)] - + assignments = frappe.get_all("ToDo", filters={ + "reference_type": self.reference_type, + "reference_name": self.reference_name, + "status": ("!=", "Cancelled") + }, pluck="allocated_to") assignments.reverse() + frappe.db.set_value(self.reference_type, self.reference_name, "_assign", json.dumps(assignments), update_modified=False) @@ -98,8 +96,8 @@ class ToDo(Document): def get_owners(cls, filters=None): """Returns list of owners after applying filters on todo's. """ - rows = frappe.get_all(cls.DocType, filters=filters or {}, fields=['owner']) - return [parse_addr(row.owner)[1] for row in rows if row.owner] + rows = frappe.get_all(cls.DocType, filters=filters or {}, fields=['allocated_to']) + return [parse_addr(row.allocated_to)[1] for row in rows if row.allocated_to] # NOTE: todo is viewable if a user is an owner, or set as assigned_to value, or has any role that is allowed to access ToDo doctype. def on_doctype_update(): @@ -115,7 +113,7 @@ def get_permission_query_conditions(user): if any(check in todo_roles for check in frappe.get_roles(user)): return None else: - return """(`tabToDo`.owner = {user} or `tabToDo`.assigned_by = {user})"""\ + return """(`tabToDo`.allocated_to = {user} or `tabToDo`.assigned_by = {user})"""\ .format(user=frappe.db.escape(user)) def has_permission(doc, ptype="read", user=None): @@ -127,7 +125,7 @@ def has_permission(doc, ptype="read", user=None): if any(check in todo_roles for check in frappe.get_roles(user)): return True else: - return doc.owner==user or doc.assigned_by==user + return doc.allocated_to==user or doc.assigned_by==user @frappe.whitelist() def new_todo(description): diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index bf77170eeb..049d33c1ec 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -19,11 +19,11 @@ def get(args=None): if not args: args = frappe.local.form_dict - return frappe.get_all('ToDo', fields=['owner', 'name'], filters=dict( - reference_type = args.get('doctype'), - reference_name = args.get('name'), - status = ('!=', 'Cancelled') - ), limit=5) + return frappe.get_all("ToDo", fields=["allocated_to as owner", "name"], filters={ + "reference_type": args.get("doctype"), + "reference_name": args.get("name"), + "status": ("!=", "Cancelled") + }, limit=5) @frappe.whitelist() def add(args=None): @@ -48,7 +48,7 @@ def add(args=None): "reference_type": args['doctype'], "reference_name": args['name'], "status": "Open", - "owner": assign_to + "allocated_to": assign_to } if frappe.get_all("ToDo", filters=filters): @@ -61,7 +61,7 @@ def add(args=None): d = frappe.get_doc({ "doctype": "ToDo", - "owner": assign_to, + "allocated_to": assign_to, "reference_type": args['doctype'], "reference_name": args['name'], "description": args.get('description'), @@ -87,7 +87,7 @@ def add(args=None): follow_document(args['doctype'], args['name'], assign_to) # notify - notify_assignment(d.assigned_by, d.owner, d.reference_type, d.reference_name, action='ASSIGN', + notify_assignment(d.assigned_by, d.allocated_to, d.reference_type, d.reference_name, action='ASSIGN', description=args.get("description")) if shared_with_users: @@ -112,13 +112,13 @@ def add_multiple(args=None): add(args) def close_all_assignments(doctype, name): - assignments = frappe.db.get_all('ToDo', fields=['owner'], filters = + assignments = frappe.db.get_all('ToDo', fields=['allocated_to'], filters = dict(reference_type = doctype, reference_name = name, status=('!=', 'Cancelled'))) if not assignments: return False for assign_to in assignments: - set_status(doctype, name, assign_to.owner, status="Closed") + set_status(doctype, name, assign_to.allocated_to, status="Closed") return True @@ -130,13 +130,13 @@ def set_status(doctype, name, assign_to, status="Cancelled"): """remove from todo""" try: todo = frappe.db.get_value("ToDo", {"reference_type":doctype, - "reference_name":name, "owner":assign_to, "status": ('!=', status)}) + "reference_name":name, "allocated_to":assign_to, "status": ('!=', status)}) if todo: todo = frappe.get_doc("ToDo", todo) todo.status = status todo.save(ignore_permissions=True) - notify_assignment(todo.assigned_by, todo.owner, todo.reference_type, todo.reference_name) + notify_assignment(todo.assigned_by, todo.allocated_to, todo.reference_type, todo.reference_name) except frappe.DoesNotExistError: pass @@ -150,25 +150,26 @@ def clear(doctype, name): ''' Clears assignments, return False if not assigned. ''' - assignments = frappe.db.get_all('ToDo', fields=['owner'], filters = + assignments = frappe.db.get_all('ToDo', fields=['allocated_to'], filters = dict(reference_type = doctype, reference_name = name)) if not assignments: return False for assign_to in assignments: - set_status(doctype, name, assign_to.owner, "Cancelled") + set_status(doctype, name, assign_to.allocated_to, "Cancelled") return True -def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE', +def notify_assignment(assigned_by, allocated_to, doc_type, doc_name, action='CLOSE', description=None): """ Notify assignee that there is a change in assignment """ - if not (assigned_by and owner and doc_type and doc_name): return + if not (assigned_by and allocated_to and doc_type and doc_name): + return # return if self assigned or user disabled - if assigned_by == owner or not frappe.db.get_value('User', owner, 'enabled'): + if assigned_by == allocated_to or not frappe.db.get_value('User', allocated_to, 'enabled'): return # Search for email address in description -- i.e. assignee @@ -194,7 +195,7 @@ def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE', 'email_content': description_html } - enqueue_create_notification(owner, notification_doc) + enqueue_create_notification(allocated_to, notification_doc) def format_message_for_assign_to(users): return "

" + "
".join(users) \ No newline at end of file diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 4550fdf0e6..cd87c898d8 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -2,6 +2,8 @@ # License: MIT. See LICENSE import json from collections import defaultdict +import itertools +from typing import List import frappe import frappe.desk.form.load @@ -12,69 +14,296 @@ from frappe.modules import load_doctype_module @frappe.whitelist() -def get_submitted_linked_docs(doctype, name, docs=None, visited=None): +def get_submitted_linked_docs(doctype: str, name: str) -> List[tuple]: + """ Get all the nested submitted documents those are present in referencing tables (dependent tables). + + :param doctype: Document type + :param name: Name of the document + + Usecase: + * User should be able to cancel the linked documents along with the one user trying to cancel. + + Case1: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to sd2-n2 and sd2-n2 is linked to sd3-n3, + Getting submittable linked docs of `sd1-n1`should give both sd2-n2 and sd3-n3. + Case2: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to d2-n2 and d2-n2 is linked to sd3-n3, + Getting submittable linked docs of `sd1-n1`should give None. (because d2-n2 is not a submittable doctype) + Case3: If document sd1-n1 (document name n1 from submittable doctype sd1) is linked to d2-n2 & sd2-n2. d2-n2 is linked to sd3-n3. + Getting submittable linked docs of `sd1-n1`should give sd2-n2. + + Logic: + ----- + 1. We can find linked documents only if we know how the doctypes are related. + 2. As we need only submittable documents, we can limit doctype relations search to submittable doctypes by + finding the relationships(Foreign key references) across submittable doctypes. + 3. Searching for links is going to be a tree like structure where at every level, + you will be finding documents using parent document and parent document links. """ - Get all nested submitted linked doctype linkinfo + tree = SubmittableDocumentTree(doctype, name) + visited_documents = tree.get_all_children() + docs = [] - Arguments: - doctype (str) - The doctype for which get all linked doctypes - name (str) - The docname for which get all linked doctypes + for dt, names in visited_documents.items(): + docs.extend([{'doctype': dt, 'name': name, 'docstatus': 1} for name in names]) - Keyword Arguments: - docs (list of dict) - (Optional) Get list of dictionary for linked doctype. - - Returns: - dict - Return list of documents and link count - """ - - if not docs: - docs = [] - - if not visited: - visited = {} - - if doctype not in visited: - visited[doctype] = [] - - if name in visited[doctype]: - return - - linkinfo = get_linked_doctypes(doctype) - linked_docs = get_linked_docs(doctype, name, linkinfo) - - link_count = 0 - visited[doctype].append(name) - - for link_doctype, link_names in linked_docs.items(): - - for link in link_names: - if link['name'] == name: - continue - - docinfo = link.update({"doctype": link_doctype}) - validated_doc = validate_linked_doc(docinfo) - - if not validated_doc: - continue - - link_count += 1 - - links = get_submitted_linked_docs(link_doctype, link.name, docs, visited) - if links: - docs.append({ - "doctype": link_doctype, - "name": link.name, - "docstatus": link.docstatus, - "link_count": links.get("count") - }) - - # sort linked documents by ascending number of links - docs.sort(key=lambda doc: doc.get("link_count")) return { "docs": docs, - "count": link_count + "count": len(docs) } +class SubmittableDocumentTree: + def __init__(self, doctype: str, name: str): + """Construct a tree for the submitable linked documents. + + * Node has properties like doctype and docnames. Represented as Node(doctype, docnames). + * Nodes are linked by doctype relationships like table, link and dynamic links. + * Node is referenced(linked) by many other documents and those are the child nodes. + + NOTE: child document is a property of child node (not same as Frappe child docs of a table field). + """ + self.root_doctype = doctype + self.root_docname = name + + # Documents those are yet to be visited for linked documents. + self.to_be_visited_documents = {doctype: [name]} + self.visited_documents = defaultdict(list) + + self._submittable_doctypes = None # All submittable doctypes in the system + self._references_across_doctypes = None # doctype wise links/references + + def get_all_children(self): + """Get all nodes of a tree except the root node (all the nested submitted + documents those are present in referencing tables (dependent tables). + """ + while self.to_be_visited_documents: + next_level_children = defaultdict(list) + for parent_dt in list(self.to_be_visited_documents): + parent_docs = self.to_be_visited_documents.get(parent_dt) + if not parent_docs: + del self.to_be_visited_documents[parent_dt] + continue + + child_docs = self.get_next_level_children(parent_dt, parent_docs) + self.visited_documents[parent_dt].extend(parent_docs) + for linked_dt, linked_names in child_docs.items(): + not_visited_child_docs = set(linked_names) - set(self.visited_documents.get(linked_dt, [])) + next_level_children[linked_dt].extend(not_visited_child_docs) + + self.to_be_visited_documents = next_level_children + + # Remove root node from visited documents + if self.root_docname in self.visited_documents.get(self.root_doctype, []): + self.visited_documents[self.root_doctype].remove(self.root_docname) + + return self.visited_documents + + def get_next_level_children(self, parent_dt, parent_names): + """Get immediate children of a Node(parent_dt, parent_names) + """ + referencing_fields = self.get_doctype_references(parent_dt) + + child_docs = defaultdict(list) + for field in referencing_fields: + links = get_referencing_documents(parent_dt, parent_names.copy(), field, get_parent_if_child_table_doc=True, + parent_filters=[('docstatus', '=', 1)], allowed_parents=self.get_link_sources()) or {} + for dt, names in links.items(): + child_docs[dt].extend(names) + return child_docs + + def get_doctype_references(self, doctype): + """Get references for a given document. + """ + if self._references_across_doctypes is None: + get_links_to = self.get_document_sources() + limit_link_doctypes = self.get_link_sources() + self._references_across_doctypes = get_references_across_doctypes( + get_links_to, limit_link_doctypes) + return self._references_across_doctypes.get(doctype, []) + + def get_document_sources(self): + """Returns list of doctypes from where we access submittable documents. + """ + return list(set(self.get_link_sources() + [self.root_doctype])) + + def get_link_sources(self): + """limit doctype links to these doctypes. + """ + return list(set(self.get_submittable_doctypes()) - set(get_exempted_doctypes() or [])) + + def get_submittable_doctypes(self) -> List[str]: + """Returns list of submittable doctypes. + """ + if not self._submittable_doctypes: + self._submittable_doctypes = frappe.db.get_all('DocType', {'is_submittable': 1}, pluck='name') + return self._submittable_doctypes + + +def get_child_tables_of_doctypes(doctypes: List[str]=None): + """Returns child tables by doctype. + """ + filters=[['fieldtype','=', 'Table']] + filters_for_docfield = filters + filters_for_customfield = filters + + if doctypes: + filters_for_docfield = filters + [['parent', 'in', tuple(doctypes)]] + filters_for_customfield = filters + [['dt', 'in', tuple(doctypes)]] + + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as child_table"], + filters=filters_for_docfield, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as child_table"], + filters=filters_for_customfield, + as_list=1) + + child_tables_by_doctype = defaultdict(list) + for doctype, fieldname, child_table in links: + child_tables_by_doctype[doctype].append( + {'doctype': doctype, 'fieldname': fieldname, 'child_table': child_table}) + return child_tables_by_doctype + + +def get_references_across_doctypes(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None) -> List: + """Find doctype wise foreign key references. + + :param to_doctypes: Get links of these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + + * Include child table, link and dynamic link references. + """ + if limit_link_doctypes: + child_tables_by_doctype = get_child_tables_of_doctypes(limit_link_doctypes) + all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())] + limit_link_doctypes = limit_link_doctypes + all_child_tables + else: + child_tables_by_doctype = get_child_tables_of_doctypes() + all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())] + + references_by_link_fields = get_references_across_doctypes_by_link_field(to_doctypes, limit_link_doctypes) + references_by_dlink_fields = get_references_across_doctypes_by_dynamic_link_field(to_doctypes, limit_link_doctypes) + + references = references_by_link_fields.copy() + for k, v in references_by_dlink_fields.items(): + references.setdefault(k, []).extend(v) + + for doctype, links in references.items(): + for link in links: + link['is_child'] = (link['doctype'] in all_child_tables) + return references + + +def get_references_across_doctypes_by_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None): + """Find doctype wise foreign key references based on link fields. + + :param to_doctypes: Get links to these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + """ + filters=[['fieldtype','=', 'Link']] + + if to_doctypes: + filters += [['options', 'in', tuple(to_doctypes)]] + + filters_for_docfield = filters[:] + filters_for_customfield = filters[:] + + if limit_link_doctypes: + filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]] + filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]] + + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as linked_to"], + filters=filters_for_docfield, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as linked_to"], + filters=filters_for_customfield, + as_list=1) + + links_by_doctype = defaultdict(list) + for doctype, fieldname, linked_to in links: + links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname}) + return links_by_doctype + + +def get_references_across_doctypes_by_dynamic_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None): + """Find doctype wise foreign key references based on dynamic link fields. + + :param to_doctypes: Get links to these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + """ + + filters=[['fieldtype','=', 'Dynamic Link']] + + filters_for_docfield = filters[:] + filters_for_customfield = filters[:] + + if limit_link_doctypes: + filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]] + filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]] + + # find dynamic links of parents + links = frappe.get_all("DocField", + fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters_for_docfield, + as_list=1) + + links += frappe.get_all("Custom Field", + fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters_for_customfield, + as_list=1) + + links_by_doctype = defaultdict(list) + for doctype, fieldname, doctype_fieldname in links: + try: + filters = [[doctype_fieldname, 'in', to_doctypes]] if to_doctypes else [] + for linked_to in frappe.db.get_all(doctype, pluck=doctype_fieldname, filters = filters, distinct=1): + if linked_to: + links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname, 'doctype_fieldname': doctype_fieldname}) + except frappe.db.ProgrammingError: + # TODO: FIXME + continue + return links_by_doctype + +def get_referencing_documents(reference_doctype: str, reference_names: List[str], + link_info: dict, get_parent_if_child_table_doc: bool=True, + parent_filters: List[list]=None, child_filters=None, allowed_parents=None): + """Get linked documents based on link_info. + + :param reference_doctype: reference doctype to find links + :param reference_names: reference document names to find links for + :param link_info: linking details to get the linked documents + Ex: {'doctype': 'Purchase Invoice Advance', 'fieldname': 'reference_name', + 'doctype_fieldname': 'reference_type', 'is_child': True} + :param get_parent_if_child_table_doc: Get parent record incase linked document is a child table record. + :param parent_filters: filters to apply on if not a child table. + :param child_filters: apply filters if it is a child table. + :param allowed_parents: list of parents allowed in case of get_parent_if_child_table_doc + is enabled. + """ + from_table = link_info['doctype'] + filters = [[link_info['fieldname'], 'in', tuple(reference_names)]] + if link_info.get('doctype_fieldname'): + filters.append([link_info['doctype_fieldname'], '=', reference_doctype]) + + if not link_info.get('is_child'): + filters.extend(parent_filters or []) + return {from_table: frappe.db.get_all(from_table, filters, pluck='name')} + + + filters.extend(child_filters or []) + res = frappe.db.get_all(from_table, filters = filters, fields = ['name', 'parenttype', 'parent']) + documents = defaultdict(list) + + for parent, rows in itertools.groupby(res, key = lambda row: row['parenttype']): + if allowed_parents and parent not in allowed_parents: + continue + filters = (parent_filters or []) + [['name', 'in', tuple([row.parent for row in rows])]] + documents[parent].extend(frappe.db.get_all(parent, filters=filters, pluck='name') or []) + return documents + @frappe.whitelist() def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=None): @@ -109,7 +338,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=None): Returns: bool: True if linked document passes all validations, else False """ - #ignore doctype to cancel if docinfo.get("doctype") in (ignore_doctypes_on_cancel_all or []): return False @@ -132,7 +360,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=None): def get_exempted_doctypes(): """ Get list of doctypes exempted from being auto-cancelled """ - auto_cancel_exempt_doctypes = [] for doctypes in frappe.get_hooks('auto_cancel_exempted_doctypes'): auto_cancel_exempt_doctypes.append(doctypes) @@ -183,11 +410,11 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): try: if link.get("filters"): - ret = frappe.get_list(doctype=dt, fields=fields, filters=link.get("filters")) + ret = frappe.get_all(doctype=dt, fields=fields, filters=link.get("filters")) elif link.get("get_parent"): if me and me.parent and me.parenttype == dt: - ret = frappe.get_list(doctype=dt, fields=fields, + ret = frappe.get_all(doctype=dt, fields=fields, filters=[[dt, "name", '=', me.parent]]) else: ret = None @@ -199,7 +426,7 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): if link.get("doctype_fieldname"): filters.append([link.get('child_doctype'), link.get("doctype_fieldname"), "=", doctype]) - ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True) + ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True) else: link_fieldnames = link.get("fieldname") @@ -210,7 +437,7 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): # dynamic link if link.get("doctype_fieldname"): filters.append([dt, link.get("doctype_fieldname"), "=", doctype]) - ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters) + ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters) else: ret = None diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 89e6598859..0e644c3cf5 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -253,7 +253,7 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields= def get_assignments(dt, dn): cl = frappe.get_all("ToDo", - fields=['name', 'owner', 'description', 'status'], + fields=['name', 'allocated_to as owner', 'description', 'status'], filters={ 'reference_type': dt, 'reference_name': dn, diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index 43ad104f0d..3d6f1254a2 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -29,16 +29,16 @@ def get_group_by_count(doctype, current_filters, field): subquery = frappe.get_all(doctype, filters=current_filters, run=False) if field == 'assigned_to': subquery_condition = ' and `tabToDo`.reference_name in ({subquery})'.format(subquery = subquery) - return frappe.db.sql("""select `tabToDo`.owner as name, count(*) as count + return frappe.db.sql("""select `tabToDo`.allocated_to as name, count(*) as count from `tabToDo`, `tabUser` where `tabToDo`.status!='Cancelled' and - `tabToDo`.owner = `tabUser`.name and + `tabToDo`.allocated_to = `tabUser`.name and `tabUser`.user_type = 'System User' {subquery_condition} group by - `tabToDo`.owner + `tabToDo`.allocated_to order by count desc limit 50""".format(subquery_condition = subquery_condition), as_dict=True) diff --git a/frappe/desk/page/backups/backups.html b/frappe/desk/page/backups/backups.html index e63481487c..ff10f1bd06 100644 --- a/frappe/desk/page/backups/backups.html +++ b/frappe/desk/page/backups/backups.html @@ -1,20 +1,27 @@
- {% for f in files %} - - {% endfor %} + {% for f in files %} + + {% endfor %}
\ No newline at end of file diff --git a/frappe/desk/page/backups/backups.js b/frappe/desk/page/backups/backups.js index 337ad33f43..d6cab750f0 100644 --- a/frappe/desk/page/backups/backups.js +++ b/frappe/desk/page/backups/backups.js @@ -1,4 +1,4 @@ -frappe.pages['backups'].on_page_load = function(wrapper) { +frappe.pages['backups'].on_page_load = function (wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, title: __('Download Backups'), @@ -11,12 +11,35 @@ frappe.pages['backups'].on_page_load = function(wrapper) { page.add_inner_button(__("Download Files Backup"), function () { frappe.call({ - method:"frappe.desk.page.backups.backups.schedule_files_backup", - args: {"user_email": frappe.session.user_email} + method: "frappe.desk.page.backups.backups.schedule_files_backup", + args: { "user_email": frappe.session.user_email } }); }); + page.add_inner_button(__("Get Backup Encryption Key"), function () { + if (frappe.user.has_role("System Manager")) { + frappe.verify_password(function () { + frappe.call({ + method: "frappe.utils.backups.get_backup_encryption_key", + callback: function (r) { + frappe.msgprint({ + title: __('Backup Encryption Key'), + message: __(r.message), + indicator: 'blue' + }); + } + }); + }); + } else { + frappe.msgprint({ + title: __('Error'), + message: __('System Manager privileges required.'), + indicator: 'red' + }); + } + }); + frappe.breadcrumbs.add("Setup"); $(frappe.render_template("backups")).appendTo(page.body.addClass("no-border")); -} +}; diff --git a/frappe/desk/page/backups/backups.py b/frappe/desk/page/backups/backups.py index 2229a6d89e..14ed025e08 100644 --- a/frappe/desk/page/backups/backups.py +++ b/frappe/desk/page/backups/backups.py @@ -11,6 +11,10 @@ def get_context(context): dt = os.path.getmtime(path) return convert_utc_to_user_timezone(datetime.datetime.utcfromtimestamp(dt)).strftime('%a %b %d %H:%M %Y') + def get_encrytion_status(path): + if "-enc" in path: + return True + def get_size(path): size = os.path.getsize(path) if size > 1048576: @@ -26,8 +30,9 @@ def get_context(context): cleanup_old_backups(path, files, backup_limit) files = [('/backups/' + _file, - get_time(os.path.join(path, _file)), - get_size(os.path.join(path, _file))) for _file in files if _file.endswith('sql.gz')] + get_time(os.path.join(path, _file)), + get_encrytion_status(os.path.join(path, _file)), + get_size(os.path.join(path, _file))) for _file in files if _file.endswith('sql.gz')] files.sort(key=lambda x: x[1], reverse=True) return {"files": files[:backup_limit]} diff --git a/frappe/desk/page/leaderboard/leaderboard.js b/frappe/desk/page/leaderboard/leaderboard.js index 076d672db5..aa1678af37 100644 --- a/frappe/desk/page/leaderboard/leaderboard.js +++ b/frappe/desk/page/leaderboard/leaderboard.js @@ -382,10 +382,10 @@ class Leaderboard { let timespan = this.options.selected_timespan.toLowerCase(); let current_date = frappe.datetime.now_date(); let date_range_map = { - "this week": [frappe.datetime.week_start(), current_date], - "this month": [frappe.datetime.month_start(), current_date], - "this quarter": [frappe.datetime.quarter_start(), current_date], - "this year": [frappe.datetime.year_start(), current_date], + "this week": [frappe.datetime.week_start(), frappe.datetime.week_end()], + "this month": [frappe.datetime.month_start(), frappe.datetime.month_end()], + "this quarter": [frappe.datetime.quarter_start(), frappe.datetime.quarter_end()], + "this year": [frappe.datetime.year_start(), frappe.datetime.year_end()], "last week": [frappe.datetime.add_days(current_date, -7), current_date], "last month": [frappe.datetime.add_months(current_date, -1), current_date], "last quarter": [frappe.datetime.add_months(current_date, -3), current_date], diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index f44a57e339..cc91a16345 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -46,14 +46,6 @@ frappe.pages['setup-wizard'].on_page_load = function (wrapper) { slide_class: frappe.setup.SetupWizardSlide, unidirectional: 1, done_state: 1, - before_load: ($footer) => { - $footer.find('.next-btn').removeClass('btn-default') - .addClass('btn-primary'); - $footer.find('.text-right').prepend( - $(``)); - - } } frappe.wizard = new frappe.setup.SetupWizard(wizard_settings); frappe.setup.run_event("after_load"); @@ -97,7 +89,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { super.make(); this.container.addClass("container setup-wizard-slide with-form"); this.$next_btn.addClass('action'); - this.$complete_btn = this.$footer.find('.complete-btn').addClass('action'); + this.$complete_btn.addClass('action'); this.setup_keyboard_nav(); } @@ -145,7 +137,6 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { this.$next_btn.removeClass("btn-primary").hide(); this.$complete_btn.addClass("btn-primary").show() .on('click', () => this.action_on_complete()); - } else { this.$next_btn.addClass("btn-primary").show(); this.$complete_btn.removeClass("btn-primary").hide(); @@ -178,6 +169,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { this.setup(); this.show_slide(this.current_id); + this.refresh(this.current_id); setTimeout(() => { this.container.find('.form-control').first().focus(); }, 200); @@ -197,6 +189,8 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { callback: (r) => { if (r.message.status === 'ok') { this.post_setup_success(); + } else if (r.message.status === 'registered') { + this.update_setup_message(__("starting the setup...")); } else if (r.message.fail !== undefined) { this.abort_setup(r.message.fail); } @@ -238,6 +232,9 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { if (data.fail_msg) { this.abort_setup(data.fail_msg); } + if (data.status === 'ok') { + this.post_setup_success(); + } }) } @@ -342,7 +339,6 @@ frappe.setup.SetupWizardSlide = class SetupWizardSlide extends frappe.ui.Slide { // Frappe slides settings // ====================================================== - frappe.setup.slides_settings = [ { // Welcome (language) slide @@ -360,10 +356,10 @@ frappe.setup.slides_settings = [ onload: function (slide) { this.setup_fields(slide); + let browser_language = frappe.setup.utils.get_language_name_from_code(navigator.language); + let language_field = slide.get_field("language"); - var language_field = slide.get_field("language"); - - language_field.set_input(frappe.setup.data.default_language || "English"); + language_field.set_input(browser_language || "English"); if (!frappe.setup._from_load_messages) { language_field.$input.trigger("change"); @@ -387,17 +383,24 @@ frappe.setup.slides_settings = [ fields: [ { fieldname: "country", label: __("Your Country"), reqd: 1, - fieldtype: "Select" + fieldtype: "Autocomplete", + placeholder: __('Select Country') }, { fieldtype: "Section Break" }, { - fieldname: "timezone", label: __("Time Zone"), reqd: 1, - fieldtype: "Select" + fieldname: "timezone", + label: __("Time Zone"), + placeholder: __('Select Time Zone'), + reqd: 1, + fieldtype: "Select", }, { fieldtype: "Column Break" }, { - fieldname: "currency", label: __("Currency"), reqd: 1, - fieldtype: "Select" + fieldname: "currency", + label: __("Currency"), + placeholder: __('Select Currency'), + reqd: 1, + fieldtype: "Select", } ], @@ -507,7 +510,7 @@ frappe.setup.utils = { frappe.setup.data.email = r.message.email; callback(slide); } - }) + }); }, setup_language_field: function (slide) { @@ -520,20 +523,29 @@ frappe.setup.utils = { /* Set a slide's country, timezone and currency fields */ - var data = frappe.setup.data.regional_data; + let data = frappe.setup.data.regional_data; + let country_field = slide.get_field('country'); + let translated_countries = []; - var country_field = slide.get_field('country'); + Object.keys(data.country_info).sort().forEach(country => { + translated_countries.push({ + label: __(country), + value: country + }); + }); - slide.get_input("country").empty() - .add_options([""].concat(Object.keys(data.country_info).sort())); + country_field.set_data(translated_countries); - slide.get_input("currency").empty() - .add_options(frappe.utils.unique([""].concat( - $.map(data.country_info, opts => opts.currency) - )).sort()); + slide.get_input("currency") + .empty() + .add_options( + frappe.utils.unique( + $.map(data.country_info, opts => opts.currency).sort() + ) + ); slide.get_input("timezone").empty() - .add_options([""].concat(data.all_timezones)); + .add_options(data.all_timezones); // set values if present if (frappe.wizard.values.country) { @@ -542,13 +554,9 @@ frappe.setup.utils = { country_field.set_input(data.default_country); } - if (frappe.wizard.values.currency) { - slide.get_field("currency").set_input(frappe.wizard.values.currency); - } + slide.get_field("currency").set_input(frappe.wizard.values.currency); - if (frappe.wizard.values.timezone) { - slide.get_field("timezone").set_input(frappe.wizard.values.timezone); - } + slide.get_field("timezone").set_input(frappe.wizard.values.timezone); }, @@ -573,6 +581,10 @@ frappe.setup.utils = { }); }, + get_language_name_from_code: function (language_code) { + return frappe.setup.data.lang.codes_to_names[language_code] || "English"; + }, + bind_region_events: function (slide) { /* Bind a slide's country, timezone and currency fields @@ -584,17 +596,15 @@ frappe.setup.utils = { $timezone.empty(); + if (!country) return; // add country specific timezones first - if (country) { - var timezone_list = data.country_info[country].timezones || []; - $timezone.add_options(timezone_list.sort()); - slide.get_field("currency").set_input(data.country_info[country].currency); - slide.get_field("currency").$input.trigger("change"); - } + const timezone_list = data.country_info[country].timezones || []; + $timezone.add_options(timezone_list.sort()); + slide.get_field("currency").set_input(data.country_info[country].currency); + slide.get_field("currency").$input.trigger("change"); // add all timezones at the end, so that user has the option to change it to any timezone - $timezone.add_options([""].concat(data.all_timezones)); - + $timezone.add_options(data.all_timezones); slide.get_field("timezone").set_input($timezone.val()); // temporarily set date format @@ -612,7 +622,7 @@ frappe.setup.utils = { if (number_format === "#.###") { number_format = "#.###,##"; } else if (number_format === "#,###") { - number_format = "#,###.##" + number_format = "#,###.##"; } frappe.boot.sysdefaults.number_format = number_format; diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index c729c1d78b..b5f0c5043c 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -54,9 +54,17 @@ def setup_complete(args): return {'status': 'ok'} args = parse_args(args) - stages = get_setup_stages(args) + is_background_task = frappe.conf.get('trigger_site_setup_in_background') + if is_background_task: + process_setup_stages.enqueue(stages=stages, user_input=args, is_background_task=True) + return {'status': 'registered'} + else: + return process_setup_stages(stages, args) + +@frappe.task() +def process_setup_stages(stages, user_input, is_background_task=False): try: frappe.flags.in_setup_wizard = True current_task = None @@ -68,11 +76,16 @@ def setup_complete(args): current_task = task task.get('fn')(task.get('args')) except Exception: - handle_setup_exception(args) - return {'status': 'fail', 'fail': current_task.get('fail_msg')} + handle_setup_exception(user_input) + if not is_background_task: + return {'status': 'fail', 'fail': current_task.get('fail_msg')} + frappe.publish_realtime('setup_task', + {'status': 'fail', "fail_msg": current_task.get('fail_msg')}, user=frappe.session.user) else: - run_setup_success(args) - return {'status': 'ok'} + run_setup_success(user_input) + if not is_background_task: + return {'status': 'ok'} + frappe.publish_realtime('setup_task', {"status": 'ok'}, user=frappe.session.user) finally: frappe.flags.in_setup_wizard = False @@ -138,7 +151,7 @@ def update_system_settings(args): system_settings = frappe.get_doc("System Settings", "System Settings") system_settings.update({ "country": args.get("country"), - "language": get_language_code(args.get("language")), + "language": get_language_code(args.get("language")) or 'en', "time_zone": args.get("timezone"), "float_precision": 3, 'date_format': frappe.db.get_value("Country", args.get("country"), "date_format"), diff --git a/frappe/desk/page/user_profile/user_profile_controller.js b/frappe/desk/page/user_profile/user_profile_controller.js index c1a89f316e..40b542d5c3 100644 --- a/frappe/desk/page/user_profile/user_profile_controller.js +++ b/frappe/desk/page/user_profile/user_profile_controller.js @@ -17,21 +17,15 @@ class UserProfile { show() { let route = frappe.get_route(); this.user_id = route[1] || frappe.session.user; - - //validate if user - if (route.length > 1) { - frappe.dom.freeze(__('Loading user profile') + '...'); - frappe.db.exists('User', this.user_id).then(exists => { - frappe.dom.unfreeze(); - if (exists) { - this.make_user_profile(); - } else { - frappe.msgprint(__('User does not exist')); - } - }); - } else { - frappe.set_route('user-profile', frappe.session.user); - } + frappe.dom.freeze(__('Loading user profile') + '...'); + frappe.db.exists('User', this.user_id).then(exists => { + frappe.dom.unfreeze(); + if (exists) { + this.make_user_profile(); + } else { + frappe.msgprint(__('User does not exist')); + } + }); } make_user_profile() { @@ -74,8 +68,7 @@ class UserProfile { primary_action_label: __('Go'), primary_action: ({ user }) => { dialog.hide(); - this.user_id = user; - this.make_user_profile(); + frappe.set_route('user-profile', user); } }); dialog.show(); diff --git a/frappe/desk/page/user_profile/user_profile_sidebar.html b/frappe/desk/page/user_profile/user_profile_sidebar.html index 4a35c6cf9c..9f8889fd03 100644 --- a/frappe/desk/page/user_profile/user_profile_sidebar.html +++ b/frappe/desk/page/user_profile/user_profile_sidebar.html @@ -51,10 +51,10 @@

{%=__("Edit Profile") %}

- {%=__("Leaderboard") %}

- \ No newline at end of file + diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 9a37d16d0a..97bceeb725 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -59,6 +59,20 @@ def get_report_doc(report_name): return doc +def get_report_result(report, filters): + if report.report_type == "Query Report": + res = report.execute_query_report(filters) + + elif report.report_type == "Script Report": + res = report.execute_script_report(filters) + + elif report.report_type == "Custom Report": + ref_report = get_report_doc(report.report_name) + res = get_report_result(ref_report, filters) + + return res + +@frappe.read_only() def generate_report_result(report, filters=None, user=None, custom_columns=None): user = user or frappe.session.user filters = filters or [] @@ -66,13 +80,7 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None) if filters and isinstance(filters, str): filters = json.loads(filters) - res = [] - - if report.report_type == "Query Report": - res = report.execute_query_report(filters) - - elif report.report_type == "Script Report": - res = report.execute_script_report(filters) + res = get_report_result(report, filters) or [] columns, result, message, chart, report_summary, skip_total_row = ljust_list(res, 6) columns = [get_column_as_dict(col) for col in columns] @@ -398,7 +406,7 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visi for column in data.columns: if column.get("hidden"): continue - result[0].append(column["label"]) + result[0].append(column.get("label")) column_width = cint(column.get('width', 0)) # to convert into scale accepted by openpyxl column_width /= 10 diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index 7081a84e7a..34728375cd 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -242,13 +242,16 @@ def make_links(columns, data): for row in data: doc_name = row.get('name') for col in columns: - if col.fieldtype == "Link" and col.options != "Currency": - if col.options and row.get(col.fieldname): + if not row.get(col.fieldname): + continue + + if col.fieldtype == "Link": + if col.options and col.options != "Currency": row[col.fieldname] = get_link_to_form(col.options, row[col.fieldname]) elif col.fieldtype == "Dynamic Link": - if col.options and row.get(col.fieldname) and row.get(col.options): + if col.options and row.get(col.options): row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname]) - elif col.fieldtype == "Currency" and row.get(col.fieldname): + elif col.fieldtype == "Currency": doc = frappe.get_doc(col.parent, doc_name) if doc_name and col.parent else None # Pass the Document to get the currency based on docfield option row[col.fieldname] = frappe.format_value(row[col.fieldname], col, doc=doc) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 277bf43eb6..54f0d2372d 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -109,6 +109,15 @@ frappe.ui.form.on("Email Account", { onload: function(frm) { frm.set_df_property("append_to", "only_select", true); frm.set_query("append_to", "frappe.email.doctype.email_account.email_account.get_append_to"); + frm.set_query("append_to", "imap_folder", function() { + return { + query: "frappe.email.doctype.email_account.email_account.get_append_to" + }; + }); + if (frm.doc.__islocal) { + frm.add_child("imap_folder", {"folder_name": "INBOX"}); + frm.refresh_field("imap_folder"); + } }, refresh: function(frm) { @@ -117,7 +126,7 @@ frappe.ui.form.on("Email Account", { frm.events.notify_if_unreplied(frm); frm.events.show_gmail_message_for_less_secure_apps(frm); - if(frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) { + if (frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) { delete frappe.route_flags.delete_user_from_locals; delete locals['User'][frappe.route_flags.linked_user]; } @@ -125,7 +134,7 @@ frappe.ui.form.on("Email Account", { show_gmail_message_for_less_secure_apps: function(frm) { frm.dashboard.clear_headline(); - if(frm.doc.service==="GMail") { + if (frm.doc.service==="GMail") { frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \ apps in Gmail settings. Read this for details'); @@ -137,8 +146,8 @@ frappe.ui.form.on("Email Account", { frm.events.update_domain(frm); }, - update_domain: function(frm){ - if (!frm.doc.email_id && !frm.doc.service){ + update_domain: function(frm) { + if (!frm.doc.email_id && !frm.doc.service) { return; } @@ -148,7 +157,7 @@ frappe.ui.form.on("Email Account", { args: { "email_id": frm.doc.email_id }, - callback: function (r) { + callback: function(r) { if (r.message) { frm.events.set_domain_fields(frm, r.message); } @@ -157,7 +166,7 @@ frappe.ui.form.on("Email Account", { }, set_domain_fields: function(frm, args) { - if(!args){ + if (!args) { args = frappe.route_flags.set_domain_values? frappe.route_options: {}; } @@ -172,10 +181,8 @@ frappe.ui.form.on("Email Account", { email_sync_option: function(frm) { // confirm if the ALL sync option is selected - if(frm.doc.email_sync_option == "ALL"){ - var msg = __("You are selecting Sync Option as ALL, It will resync all \ - read as well as unread message from server. This may also cause the duplication\ - of Communication (emails)."); + if (frm.doc.email_sync_option == "ALL") { + var msg = __("You are selecting Sync Option as ALL, It will resync all read as well as unread message from server. This may also cause the duplication of Communication (emails)."); frappe.confirm(msg, null, function() { frm.set_value("email_sync_option", "UNSEEN"); }); @@ -184,8 +191,7 @@ frappe.ui.form.on("Email Account", { warn_autoreply_on_incoming: function(frm) { if (frm.doc.enable_incoming && frm.doc.enable_auto_reply && frm.doc.__islocal) { - var msg = __("Enabling auto reply on an incoming email account will send automated replies \ - to all the synchronized emails. Do you wish to continue?"); + var msg = __("Enabling auto reply on an incoming email account will send automated replies to all the synchronized emails. Do you wish to continue?"); frappe.confirm(msg, null, function() { frm.set_value("enable_auto_reply", 0); frappe.show_alert({message: __("Disabled Auto Reply"), indicator: "blue"}); diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index e20f38c74a..65053bab3d 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -31,6 +31,8 @@ "attachment_limit", "email_sync_option", "initial_sync_count", + "section_break_25", + "imap_folder", "section_break_12", "append_emails_to_sent_folder", "append_to", @@ -204,7 +206,7 @@ "label": "Attachment Limit (MB)" }, { - "depends_on": "enable_incoming", + "depends_on": "eval: doc.enable_incoming && !doc.use_imap", "description": "Append as communication against this DocType (must have fields, \"Status\", \"Subject\")", "fieldname": "append_to", "fieldtype": "Link", @@ -562,15 +564,28 @@ "fieldname": "account_section", "fieldtype": "Section Break", "label": "Account" + }, + { + "depends_on": "eval: doc.use_imap && doc.enable_incoming", + "fieldname": "imap_folder", + "fieldtype": "Table", + "label": "IMAP Folder", + "options": "IMAP Folder" + }, + { + "fieldname": "section_break_25", + "fieldtype": "Section Break", + "label": "IMAP Details" } ], "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2021-09-21 16:44:25.728637", + "modified": "2021-11-30 09:03:25.728637", "modified_by": "Administrator", "module": "Email", "name": "Email Account", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index d90c56d90d..3f8d399c52 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -67,6 +67,10 @@ class EmailAccount(Document): else: self.login_id = None + # validate the imap settings + if self.enable_incoming and self.use_imap and len(self.imap_folder) <= 0: + frappe.throw(_("You need to set one IMAP folder for {0}").format(frappe.bold(self.email_id))) + duplicate_email_account = frappe.get_all("Email Account", filters={ "email_id": self.email_id, "name": ("!=", self.name) @@ -100,10 +104,11 @@ class EmailAccount(Document): for e in self.get_unreplied_notification_emails(): validate_email_address(e, True) - if self.enable_incoming and self.append_to: - valid_doctypes = [d[0] for d in get_append_to()] - if self.append_to not in valid_doctypes: - frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) + for folder in self.imap_folder: + if self.enable_incoming and folder.append_to: + valid_doctypes = [d[0] for d in get_append_to()] + if folder.append_to not in valid_doctypes: + frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) def validate_smtp_conn(self): if not self.smtp_server: @@ -177,13 +182,13 @@ class EmailAccount(Document): return None args = frappe._dict({ + "email_account_name": self.email_account_name, "email_account": self.name, "host": self.email_server, "use_ssl": self.use_ssl, "username": getattr(self, "login_id", None) or self.email_id, "use_imap": self.use_imap, "email_sync_rule": email_sync_rule, - "uid_validity": self.uidvalidity, "incoming_port": get_port(self), "initial_sync_count": self.initial_sync_count or 100 }) @@ -457,6 +462,14 @@ class EmailAccount(Document): """retrive and return inbound mails. """ + mails = [] + + def process_mail(messages): + for index, message in enumerate(messages.get("latest_messages", [])): + uid = messages['uid_list'][index] if messages.get('uid_list') else None + seen_status = 1 if messages.get('seen_status', {}).get(uid) == 'SEEN' else 0 + mails.append(InboundMail(message, self, uid, seen_status)) + if frappe.local.flags.in_test: return [InboundMail(msg, self) for msg in test_mails or []] @@ -466,17 +479,23 @@ class EmailAccount(Document): email_sync_rule = self.build_email_sync_rule() try: email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule) - messages = email_server.get_messages() or {} + if self.use_imap: + # process all given imap folder + for folder in self.imap_folder: + email_server.select_imap_folder(folder.folder_name) + email_server.settings['uid_validity'] = folder.uidvalidity + messages = email_server.get_messages(folder=folder.folder_name) or {} + process_mail(messages) + else: + # process the pop3 account + messages = email_server.get_messages() or {} + process_mail(messages) + # close connection to mailserver + email_server.logout() except Exception: frappe.log_error(title=_("Error while connecting to email account {0}").format(self.name)) return [] - mails = [] - for index, message in enumerate(messages.get("latest_messages", [])): - uid = messages['uid_list'][index] if messages.get('uid_list') else None - seen_status = 1 if messages.get('seen_status', {}).get(uid)=='SEEN' else 0 - mails.append(InboundMail(message, self, uid, seen_status)) - return mails def handle_bad_emails(self, uid, raw, reason): @@ -530,7 +549,11 @@ class EmailAccount(Document): def on_trash(self): """Clear communications where email account is linked""" - frappe.db.sql("update `tabCommunication` set email_account='' where email_account=%s", self.name) + Communication = frappe.qb.DocType("Communication") + frappe.qb.update(Communication) \ + .set(Communication.email_account, "") \ + .where(Communication.email_account == self.name).run() + remove_user_email_inbox(email_account=self.name) def after_rename(self, old, new, merge=False): @@ -547,23 +570,26 @@ class EmailAccount(Document): else: return self.email_sync_option or "UNSEEN" - def mark_emails_as_read_unread(self): + def mark_emails_as_read_unread(self, email_server=None, folder_name="INBOX"): """ mark Email Flag Queue of self.email_account mails as read""" - if not self.use_imap: return - flags = frappe.db.sql("""select name, communication, uid, action from - `tabEmail Flag Queue` where is_completed=0 and email_account={email_account} - """.format(email_account=frappe.db.escape(self.name)), as_dict=True) + EmailFlagQ = frappe.qb.DocType("Email Flag Queue") + flags = ( + frappe.qb.from_(EmailFlagQ) + .select(EmailFlagQ.name, EmailFlagQ.communication, EmailFlagQ.uid, EmailFlagQ.action) + .where(EmailFlagQ.is_completed == 0) + .where(EmailFlagQ.email_account == frappe.db.escape(self.name)) + ).run(as_dict=True) uid_list = { flag.get("uid", None): flag.get("action", "Read") for flag in flags } if flags and uid_list: - email_server = self.get_incoming_server() + if not email_server: + email_server = self.get_incoming_server() if not email_server: return - - email_server.update_flag(uid_list=uid_list) + email_server.update_flag(folder_name, uid_list=uid_list) # mark communication as read docnames = ",".join("'%s'"%flag.get("communication") for flag in flags \ @@ -576,16 +602,20 @@ class EmailAccount(Document): self.set_communication_seen_status(docnames, seen=0) docnames = ",".join([ "'%s'"%flag.get("name") for flag in flags ]) - frappe.db.sql(""" update `tabEmail Flag Queue` set is_completed=1 - where name in ({docnames})""".format(docnames=docnames)) + + EmailFlagQueue = frappe.qb.DocType("Email Flag Queue") + frappe.qb.update(EmailFlagQueue) \ + .set(EmailFlagQueue.is_completed, 1) \ + .where(EmailFlagQueue.name.isin(docnames)).run() def set_communication_seen_status(self, docnames, seen=0): """ mark Email Flag Queue of self.email_account mails as read""" if not docnames: return - - frappe.db.sql(""" update `tabCommunication` set seen={seen} - where name in ({docnames})""".format(docnames=docnames, seen=seen)) + Communication = frappe.qb.from_("Communication") + frappe.qb.update(Communication) \ + .set(Communication.seen == seen) \ + .where(Communication.name.isin(docnames)).run() def check_automatic_linking_email_account(self): if self.enable_automatic_linking: @@ -651,15 +681,19 @@ def test_internet(host="8.8.8.8", port=53, timeout=3): def notify_unreplied(): """Sends email notifications if there are unreplied Communications and `notify_if_unreplied` is set as true.""" - for email_account in frappe.get_all("Email Account", "name", filters={"enable_incoming": 1, "notify_if_unreplied": 1}): email_account = frappe.get_doc("Email Account", email_account.name) - if email_account.append_to: + if email_account.use_imap: + append_to = [folder.get("append_to") for folder in email_account.imap_folder] + else: + append_to = email_account.append_to + + if append_to: # get open communications younger than x mins, for given doctype for comm in frappe.get_all("Communication", "name", filters=[ {"sent_or_received": "Received"}, - {"reference_doctype": email_account.append_to}, + {"reference_doctype": ("in", append_to)}, {"unread_notification_sent": 0}, {"email_account":email_account.name}, {"creation": ("<", datetime.now() - timedelta(seconds = (email_account.unreplied_for_mins or 30) * 60))}, @@ -702,9 +736,6 @@ def pull_from_email_account(email_account): email_account = frappe.get_doc("Email Account", email_account) email_account.receive() - # mark Email Flag Queue mail as read - email_account.mark_emails_as_read_unread() - def get_max_email_uid(email_account): # get maximum uid of emails max_uid = 1 @@ -761,12 +792,12 @@ def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_ou update_user_email_settings = True if update_user_email_settings: - frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s, - enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", { - "email_account": email_account, - "enable_outgoing": enable_outgoing, - "awaiting_password": awaiting_password or 0 - }) + UserEmail = frappe.qb.DocType("User Email") + frappe.qb.update(UserEmail) \ + .set(UserEmail.awaiting_password, (awaiting_password or 0)) \ + .set(UserEmail.enable_outgoing, enable_outgoing) \ + .where(UserEmail.email_account == email_account).run() + else: users = " and ".join([frappe.bold(user.get("name")) for user in user_names]) frappe.msgprint(_("Enabled email inbox for user {0}").format(users)) @@ -800,4 +831,4 @@ def set_email_password(email_account, user, password): frappe.db.rollback() return False - return True \ No newline at end of file + return True diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py index 21dc4b84c4..6d26f9f070 100644 --- a/frappe/email/doctype/email_account/test_email_account.py +++ b/frappe/email/doctype/email_account/test_email_account.py @@ -25,6 +25,7 @@ class TestEmailAccount(unittest.TestCase): email_account = frappe.get_doc("Email Account", "_Test Email Account 1") email_account.db_set("enable_incoming", 1) email_account.db_set("enable_auto_reply", 1) + email_account.db_set("use_imap", 1) @classmethod def tearDownClass(cls): @@ -229,6 +230,22 @@ class TestEmailAccount(unittest.TestCase): email_account.handle_bad_emails(uid=-1, raw=mail_content, reason="Testing") self.assertTrue(frappe.db.get_value("Unhandled Email", {'message_id': message_id})) + def test_imap_folder(self): + # assert tests if imap_folder >= 1 and imap is checked + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") + + self.assertTrue(email_account.use_imap) + self.assertTrue(email_account.enable_incoming) + self.assertTrue(len(email_account.imap_folder) > 0) + + def test_imap_folder_missing(self): + # Test the Exception in validate() that verifies the imap_folder list + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") + email_account.imap_folder = [] + + with self.assertRaises(Exception): + email_account.validate() + class TestInboundMail(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/frappe/email/doctype/email_account/test_records.json b/frappe/email/doctype/email_account/test_records.json index 15ca2a886e..450895d7a6 100644 --- a/frappe/email/doctype/email_account/test_records.json +++ b/frappe/email/doctype/email_account/test_records.json @@ -4,7 +4,6 @@ "is_global": 1, "doctype": "Email Account", "domain":"example.com", - "append_to": "ToDo", "email_account_name": "_Test Email Account 1", "enable_outgoing": 1, "smtp_server": "test.example.com", @@ -20,6 +19,8 @@ "send_notification_to": "test_unreplied@example.com", "pop3_server": "pop.test.example.com", "no_remaining":"0", + "append_to": "ToDo", + "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}], "track_email_status": 1 }, { diff --git a/frappe/email/doctype/email_flag_queue/email_flag_queue.json b/frappe/email/doctype/email_flag_queue/email_flag_queue.json index 165e8f9ea9..14b1ec4f53 100644 --- a/frappe/email/doctype/email_flag_queue/email_flag_queue.json +++ b/frappe/email/doctype/email_flag_queue/email_flag_queue.json @@ -1,213 +1,67 @@ { - "allow_copy": 1, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-04-20 15:29:39.785172", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 0, + "actions": [], + "allow_copy": 1, + "creation": "2016-04-20 15:29:39.785172", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "is_completed", + "communication", + "action", + "email_account", + "uid" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "is_completed", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Completed", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "is_completed", + "fieldtype": "Check", + "label": "Is Completed", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "communication", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Communication", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "communication", + "fieldtype": "Data", + "label": "Communication" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "action", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Action", - "length": 0, - "no_copy": 0, - "options": "Read\nUnread", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "action", + "fieldtype": "Select", + "label": "Action", + "options": "Read\nUnread" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "email_account", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Email Account", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "email_account", + "fieldtype": "Data", + "hidden": 1, + "label": "Email Account" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "uid", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "UID", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "uid", + "fieldtype": "Data", + "hidden": 1, + "label": "UID" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 1, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2017-09-20 15:27:12.142079", - "modified_by": "Administrator", - "module": "Email", - "name": "Email Flag Queue", - "name_case": "", - "owner": "Administrator", + ], + "in_create": 1, + "links": [], + "modified": "2021-11-30 09:51:34.489932", + "modified_by": "Administrator", + "module": "Email", + "name": "Email Flag Queue", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 4489a68cac..d89a3d83be 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -18,7 +18,7 @@ from frappe import _, safe_encode, task from frappe.model.document import Document from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message from frappe.email.email_body import add_attachment, get_formatted_html, get_email -from frappe.utils import cint, split_emails, add_days, nowdate, cstr +from frappe.utils import cint, split_emails, add_days, nowdate, cstr, get_hook_method from frappe.email.doctype.email_account.email_account import EmailAccount @@ -121,9 +121,13 @@ class EmailQueue(Document): continue message = ctx.build_message(recipient.recipient) - if not frappe.flags.in_test: - ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message) - ctx.add_to_sent_list(recipient) + method = get_hook_method('override_email_send') + if method: + method(self, self.sender, recipient.recipient, message) + else: + if not frappe.flags.in_test: + ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message) + ctx.add_to_sent_list(recipient) if frappe.flags.in_test: frappe.flags.sent_mail = message @@ -283,9 +287,14 @@ class SendMailContext: if attachment.get('fcontent'): continue - fid = attachment.get("fid") - if fid: - _file = frappe.get_doc("File", fid) + file_filters = {} + if attachment.get('fid'): + file_filters['name'] = attachment.get('fid') + elif attachment.get('file_url'): + file_filters['file_url'] = attachment.get('file_url') + + if file_filters: + _file = frappe.get_doc("File", file_filters) fcontent = _file.get_content() attachment.update({ 'fname': _file.file_name, @@ -293,6 +302,7 @@ class SendMailContext: 'parent': message_obj }) attachment.pop("fid", None) + attachment.pop("file_url", None) add_attachment(**attachment) elif attachment.get("print_format_attachment") == 1: @@ -503,7 +513,7 @@ class QueueBuilder: if self._attachments: # store attachments with fid or print format details, to be attached on-demand later for att in self._attachments: - if att.get('fid'): + if att.get('fid') or att.get('file_url'): attachments.append(att) elif att.get("print_format_attachment") == 1: if not att.get('lang', None): diff --git a/frappe/email/doctype/email_template/email_template.json b/frappe/email/doctype/email_template/email_template.json index dc73acacc1..c6ec971da4 100644 --- a/frappe/email/doctype/email_template/email_template.json +++ b/frappe/email/doctype/email_template/email_template.json @@ -12,7 +12,6 @@ "use_html", "response_html", "response", - "owner", "section_break_4", "email_reply_help" ], @@ -32,14 +31,6 @@ "label": "Response", "mandatory_depends_on": "eval:!doc.use_html" }, - { - "default": "user", - "fieldname": "owner", - "fieldtype": "Link", - "hidden": 1, - "label": "Owner", - "options": "User" - }, { "fieldname": "section_break_4", "fieldtype": "Section Break" @@ -66,7 +57,7 @@ ], "icon": "fa fa-comment", "links": [], - "modified": "2020-11-30 14:12:50.321633", + "modified": "2022-01-04 14:12:50.321633", "modified_by": "Administrator", "module": "Email", "name": "Email Template", diff --git a/frappe/chat/doctype/chat_message/__init__.py b/frappe/email/doctype/imap_folder/__init__.py similarity index 100% rename from frappe/chat/doctype/chat_message/__init__.py rename to frappe/email/doctype/imap_folder/__init__.py diff --git a/frappe/email/doctype/imap_folder/imap_folder.json b/frappe/email/doctype/imap_folder/imap_folder.json new file mode 100644 index 0000000000..bab50dea39 --- /dev/null +++ b/frappe/email/doctype/imap_folder/imap_folder.json @@ -0,0 +1,53 @@ +{ + "actions": [], + "creation": "2021-09-21 11:38:13.521979", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "folder_name", + "append_to", + "uidvalidity", + "uidnext" + ], + "fields": [ + { + "fieldname": "folder_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Folder Name", + "reqd": 1 + }, + { + "fieldname": "append_to", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Append To", + "options": "DocType" + }, + { + "fieldname": "uidvalidity", + "fieldtype": "Data", + "hidden": 1, + "label": "UIDVALIDITY" + }, + { + "fieldname": "uidnext", + "fieldtype": "Data", + "hidden": 1, + "label": "UIDNEXT" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-21 11:53:00.811236", + "modified_by": "Administrator", + "module": "Email", + "name": "IMAP Folder", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/frappe/email/doctype/imap_folder/imap_folder.py b/frappe/email/doctype/imap_folder/imap_folder.py new file mode 100644 index 0000000000..b0bb36b677 --- /dev/null +++ b/frappe/email/doctype/imap_folder/imap_folder.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class IMAPFolder(Document): + pass diff --git a/frappe/email/doctype/newsletter/newsletter.js b/frappe/email/doctype/newsletter/newsletter.js index 3277d8e9ee..55805ad485 100644 --- a/frappe/email/doctype/newsletter/newsletter.js +++ b/frappe/email/doctype/newsletter/newsletter.js @@ -4,69 +4,137 @@ frappe.ui.form.on('Newsletter', { refresh(frm) { let doc = frm.doc; - if (!doc.__islocal && !cint(doc.email_sent) && !doc.__unsaved - && in_list(frappe.boot.user.can_write, doc.doctype)) { - frm.add_custom_button(__('Send Now'), function() { - frappe.confirm(__("Do you really want to send this email newsletter?"), function() { - frm.call('send_emails').then(() => { - frm.refresh(); - }); + let can_write = in_list(frappe.boot.user.can_write, doc.doctype); + if (!frm.is_new() && !frm.is_dirty() && !doc.email_sent && can_write) { + frm.add_custom_button(__('Send a test email'), () => { + frm.events.send_test_email(frm); + }, __('Preview')); + + frm.add_custom_button(__('Check broken links'), () => { + frm.dashboard.set_headline(__('Checking broken links...')); + frm.call('find_broken_links').then(r => { + frm.dashboard.set_headline(''); + let links = r.message; + if (links && links.length) { + let html = ''; + frm.dashboard.set_headline(__("Following links are broken in the email content: {0}", [html])); + } else { + frm.dashboard.set_headline(__("No broken links found in the email content")); + setTimeout(() => { + frm.dashboard.set_headline(''); + }, 3000); + } }); - }, "fa fa-play", "btn-success"); + }, __('Preview')); + + frm.add_custom_button(__('Send now'), () => { + if (frm.doc.schedule_send) { + frappe.confirm(__("This newsletter was scheduled to send on a later date. Are you sure you want to send it now?"), function () { + frm.call('send_emails').then(() => frm.refresh()); + }); + return; + } + frappe.confirm(__("Are you sure you want to send this newsletter now?"), function () { + frm.call('send_emails').then(() => frm.refresh()); + }); + }, __('Send')); + + frm.add_custom_button(__('Schedule sending'), () => { + frm.events.schedule_send_dialog(frm); + }, __('Send')); } frm.events.setup_dashboard(frm); + frm.events.setup_sending_status(frm); - if (doc.__islocal && !doc.send_from) { + if (frm.is_new() && !doc.sender_email) { let { fullname, email } = frappe.user_info(doc.owner); - frm.set_value('send_from', `${fullname} <${email}>`); + frm.set_value('sender_email', email); + frm.set_value('sender_name', fullname); } + + frm.trigger('update_schedule_message'); }, - onload_post_render(frm) { - frm.trigger('setup_schedule_send'); - }, - - setup_schedule_send(frm) { - let today = new Date(); - - // setting datepicker options to set min date & min time - today.setHours(today.getHours() + 1 ); - frm.get_field('schedule_send').$input.datepicker({ - maxMinutes: 0, - minDate: today, - timeFormat: 'hh:00:00', - onSelect: function (fd, d, picker) { - if (!d) return; - var date = d.toDateString(); - if (date === today.toDateString()) { - picker.update({ - minHours: (today.getHours() + 1) - }); - } else { - picker.update({ - minHours: 0 - }); - } - frm.get_field('schedule_send').$input.trigger('change'); + schedule_send_dialog(frm) { + let hours = frappe.utils.range(24); + let time_slots = hours.map(hour => { + return `${(hour + '').padStart(2, '0')}:00`; + }); + let d = new frappe.ui.Dialog({ + title: __('Schedule Newsletter'), + fields: [ + { + label: __('Date'), + fieldname: 'date', + fieldtype: 'Date', + options: { + minDate: new Date() + } + }, + { + label: __('Time'), + fieldname: 'time', + fieldtype: 'Select', + options: time_slots, + }, + ], + primary_action_label: __('Schedule'), + primary_action({ date, time }) { + frm.set_value('schedule_sending', 1); + frm.set_value('schedule_send', `${date} ${time}:00`); + d.hide(); + frm.save(); + }, + secondary_action_label: __('Cancel Scheduling'), + secondary_action() { + frm.set_value('schedule_sending', 0); + frm.set_value('schedule_send', ''); + d.hide(); + frm.save(); } }); + if (frm.doc.schedule_sending) { + let parts = frm.doc.schedule_send.split(' '); + if (parts.length === 2) { + let [date, time] = parts; + d.set_value('date', date); + d.set_value('time', time.slice(0, 5)); + } + } + d.show(); + }, - - const $tp = frm.get_field('schedule_send').datepicker.timepicker; - $tp.$minutes.parent().css('display', 'none'); - $tp.$minutesText.css('display', 'none'); - $tp.$minutesText.prev().css('display', 'none'); - $tp.$seconds.parent().css('display', 'none'); + send_test_email(frm) { + let d = new frappe.ui.Dialog({ + title: __('Send Test Email'), + fields: [ + { + label: __('Email'), + fieldname: 'email', + fieldtype: 'Data', + options: 'Email', + } + ], + primary_action_label: __('Send'), + primary_action({ email }) { + d.get_primary_btn().text(__('Sending...')).prop('disabled', true); + frm.call('send_test_email', { email }) + .then(() => { + d.get_primary_btn().text(__('Send again')).prop('disabled', false); + }); + } + }); + d.show(); }, setup_dashboard(frm) { - if(!frm.doc.__islocal && cint(frm.doc.email_sent) + if (!frm.doc.__islocal && cint(frm.doc.email_sent) && frm.doc.__onload && frm.doc.__onload.status_count) { var stat = frm.doc.__onload.status_count; var total = frm.doc.scheduled_to_send; - if(total) { - $.each(stat, function(k, v) { + if (total) { + $.each(stat, function (k, v) { stat[k] = flt(v * 100 / total, 2) + '%'; }); @@ -94,5 +162,58 @@ frappe.ui.form.on('Newsletter', { ]); } } + }, + + setup_sending_status(frm) { + frm.call('get_sending_status').then(r => { + if (r.message) { + frm.events.update_sending_progress(frm, r.message.sent, r.message.total); + } + if (r.message.sent >= r.message.total) { + return; + } + if (frm.sending_status) return; + + frm.sending_status = setInterval(() => { + if (frm.doc.email_sent && frm.$wrapper.is(':visible')) { + frm.call('get_sending_status').then(r => { + if (r.message) { + let { sent, total } = r.message; + frm.events.update_sending_progress(frm, sent, total); + + if (sent >= total) { + clearInterval(frm.sending_status); + frm.sending_status = null; + return; + } + } + }); + } + }, 5000); + }); + }, + + update_sending_progress(frm, sent, total) { + if (sent >= total) { + frm.dashboard.hide_progress(); + return; + } + frm.dashboard.show_progress(__('Sending emails'), sent * 100 / total, __("{0} of {1} sent", [sent, total])); + }, + + on_hide(frm) { + if (frm.sending_status) { + clearInterval(frm.sending_status); + frm.sending_status = null; + } + }, + + update_schedule_message(frm) { + if (!frm.doc.email_sent && frm.doc.schedule_send) { + let datetime = frappe.datetime.global_date_format(frm.doc.schedule_send); + frm.dashboard.set_headline_alert(__('This newsletter is scheduled to be sent on {0}', [datetime.bold()])); + } else { + frm.dashboard.clear_headline(); + } } }); diff --git a/frappe/email/doctype/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json index dcd19ed33c..baabd4991e 100644 --- a/frappe/email/doctype/newsletter/newsletter.json +++ b/frappe/email/doctype/newsletter/newsletter.json @@ -7,48 +7,59 @@ "document_type": "Other", "engine": "InnoDB", "field_order": [ + "status_section", + "email_sent_at", + "column_break_3", + "total_recipients", + "column_break_12", + "email_sent", + "from_section", + "sender_name", + "column_break_5", + "sender_email", + "column_break_7", "send_from", - "schedule_sending", - "schedule_send", "recipients", "email_group", - "email_sent", - "newsletter_content", + "subject_section", "subject", + "newsletter_content", "content_type", "message", "message_md", "message_html", - "section_break_13", + "attachments", "send_unsubscribe_link", - "send_attachments", - "column_break_9", - "published", "send_webview_link", - "route", - "test_the_newsletter", - "test_email_id", - "test_send", - "scheduled_to_send" + "schedule_settings_section", + "scheduled_to_send", + "schedule_sending", + "schedule_send", + "publish_as_a_web_page_section", + "published", + "route" ], "fields": [ { "fieldname": "email_group", "fieldtype": "Table", "in_standard_filter": 1, - "label": "Email Group", - "options": "Newsletter Email Group" + "label": "Audience", + "options": "Newsletter Email Group", + "reqd": 1 }, { "fieldname": "send_from", "fieldtype": "Data", "ignore_xss_filter": 1, - "label": "Sender" + "label": "Sender", + "read_only": 1 }, { "default": "0", "fieldname": "email_sent", "fieldtype": "Check", + "hidden": 1, "label": "Email Sent", "no_copy": 1, "read_only": 1 @@ -87,32 +98,12 @@ "label": "Published" }, { + "depends_on": "published", "fieldname": "route", "fieldtype": "Data", - "hidden": 1, "label": "Route", "read_only": 1 }, - { - "collapsible": 1, - "fieldname": "test_the_newsletter", - "fieldtype": "Section Break", - "label": "Testing" - }, - { - "description": "A Lead with this Email Address should exist", - "fieldname": "test_email_id", - "fieldtype": "Data", - "label": "Test Email Address", - "options": "Email" - }, - { - "depends_on": "eval: doc.test_email_id", - "fieldname": "test_send", - "fieldtype": "Button", - "label": "Test", - "options": "test_send" - }, { "fieldname": "scheduled_to_send", "fieldtype": "Int", @@ -122,21 +113,16 @@ { "fieldname": "recipients", "fieldtype": "Section Break", - "label": "Recipients" + "label": "To" }, { "depends_on": "eval: doc.schedule_sending", "fieldname": "schedule_send", "fieldtype": "Datetime", - "label": "Schedule Send", + "label": "Send Email At", + "read_only": 1, "read_only_depends_on": "eval: doc.email_sent" }, - { - "default": "0", - "fieldname": "send_attachments", - "fieldtype": "Check", - "label": "Send Attachments" - }, { "fieldname": "content_type", "fieldtype": "Select", @@ -161,23 +147,87 @@ "default": "0", "fieldname": "schedule_sending", "fieldtype": "Check", - "label": "Schedule Sending", + "label": "Schedule sending at a later time", "read_only_depends_on": "eval: doc.email_sent" }, - { - "fieldname": "column_break_9", - "fieldtype": "Column Break" - }, { "default": "0", - "depends_on": "published", "fieldname": "send_webview_link", "fieldtype": "Check", "label": "Send Web View Link" }, { - "fieldname": "section_break_13", - "fieldtype": "Section Break" + "fieldname": "from_section", + "fieldtype": "Section Break", + "label": "From" + }, + { + "fieldname": "sender_name", + "fieldtype": "Data", + "label": "Sender Name" + }, + { + "fieldname": "sender_email", + "fieldtype": "Data", + "label": "Sender Email", + "options": "Email", + "reqd": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "fieldname": "subject_section", + "fieldtype": "Section Break", + "label": "Subject" + }, + { + "fieldname": "publish_as_a_web_page_section", + "fieldtype": "Section Break", + "label": "Publish as a web page" + }, + { + "depends_on": "schedule_sending", + "fieldname": "schedule_settings_section", + "fieldtype": "Section Break", + "label": "Scheduled Sending" + }, + { + "fieldname": "attachments", + "fieldtype": "Table", + "label": "Attachments", + "options": "Newsletter Attachment" + }, + { + "fieldname": "email_sent_at", + "fieldtype": "Datetime", + "label": "Email Sent At", + "read_only": 1 + }, + { + "fieldname": "total_recipients", + "fieldtype": "Int", + "label": "Total Recipients", + "read_only": 1 + }, + { + "depends_on": "email_sent", + "fieldname": "status_section", + "fieldtype": "Section Break", + "label": "Status" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" } ], "has_web_view": 1, @@ -187,7 +237,7 @@ "is_published_field": "published", "links": [], "max_attachments": 3, - "modified": "2021-02-22 14:33:56.095380", + "modified": "2021-12-06 20:09:37.963141", "modified_by": "Administrator", "module": "Email", "name": "Newsletter", diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py old mode 100755 new mode 100644 index a118240488..aa6fa2c40a --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -15,13 +15,11 @@ from .exceptions import NewsletterAlreadySentError, NoRecipientFoundError, Newsl class Newsletter(WebsiteGenerator): - def onload(self): - self.setup_newsletter_status() - def validate(self): self.route = f"newsletters/{self.name}" self.validate_sender_address() self.validate_recipient_address() + self.validate_publishing() @property def newsletter_recipients(self) -> List[str]: @@ -30,29 +28,55 @@ class Newsletter(WebsiteGenerator): return self._recipients @frappe.whitelist() - def test_send(self): - test_emails = frappe.utils.split_emails(self.test_email_id) - self.queue_all(test_emails=test_emails) - frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id)) + def get_sending_status(self): + count_by_status = frappe.get_all("Email Queue", + filters={"reference_doctype": self.doctype, "reference_name": self.name}, + fields=["status", "count(name) as count"], + group_by="status", + order_by="status" + ) + sent = 0 + total = 0 + for row in count_by_status: + if row.status == "Sent": + sent = row.count + total += row.count + + return {'sent': sent, 'total': total} + + @frappe.whitelist() + def send_test_email(self, email): + test_emails = frappe.utils.validate_email_address(email, throw=True) + self.send_newsletter(emails=test_emails) + frappe.msgprint(_("Test email sent to {0}").format(email), alert=True) + + @frappe.whitelist() + def find_broken_links(self): + from bs4 import BeautifulSoup + import requests + + html = self.get_message() + soup = BeautifulSoup(html, "html.parser") + links = soup.find_all("a") + images = soup.find_all("img") + broken_links = [] + for el in links + images: + url = el.attrs.get("href") or el.attrs.get("src") + try: + response = requests.head(url, verify=False, timeout=5) + if response.status_code >= 400: + broken_links.append(url) + except: + broken_links.append(url) + return broken_links @frappe.whitelist() def send_emails(self): - """send emails to leads and customers""" + """queue sending emails to recipients""" + self.schedule_sending = False + self.schedule_send = None self.queue_all() - frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients))) - - def setup_newsletter_status(self): - """Setup analytical status for current Newsletter. Can be accessible from desk. - """ - if self.email_sent: - status_count = frappe.get_all("Email Queue", - filters={"reference_doctype": self.doctype, "reference_name": self.name}, - fields=["status", "count(name)"], - group_by="status", - order_by="status", - as_list=True, - ) - self.get("__onload").status_count = dict(status_count) + frappe.msgprint(_("Email queued to {0} recipients").format(self.total_recipients)) def validate_send(self): """Validate if Newsletter can be sent. @@ -75,8 +99,9 @@ class Newsletter(WebsiteGenerator): def validate_sender_address(self): """Validate self.send_from is a valid email address or not. """ - if self.send_from: - frappe.utils.validate_email_address(self.send_from, throw=True) + if self.sender_email: + frappe.utils.validate_email_address(self.sender_email, throw=True) + self.send_from = f"{self.sender_name} <{self.sender_email}>" if self.sender_name else self.sender_email def validate_recipient_address(self): """Validate if self.newsletter_recipients are all valid email addresses or not. @@ -84,6 +109,10 @@ class Newsletter(WebsiteGenerator): for recipient in self.newsletter_recipients: frappe.utils.validate_email_address(recipient, throw=True) + def validate_publishing(self): + if self.send_webview_link and not self.published: + frappe.throw(_("Newsletter must be published to send webview link in email")) + def get_linked_email_queue(self) -> List[str]: """Get list of email queue linked to this newsletter. """ @@ -116,54 +145,32 @@ class Newsletter(WebsiteGenerator): x for x in self.newsletter_recipients if x not in self.get_success_recipients() ] - def queue_all(self, test_emails: List[str] = None): - """Queue Newsletter to all the recipients generated from the `Email Group` - table - - Args: - test_email (List[str], optional): Send test Newsletter to the passed set of emails. - Defaults to None. + def queue_all(self): + """Queue Newsletter to all the recipients generated from the `Email Group` table """ - if test_emails: - for test_email in test_emails: - frappe.utils.validate_email_address(test_email, throw=True) - else: - self.validate() - self.validate_send() + self.validate() + self.validate_send() - newsletter_recipients = test_emails or self.get_pending_recipients() - self.send_newsletter(emails=newsletter_recipients) + recipients = self.get_pending_recipients() + self.send_newsletter(emails=recipients) - if not test_emails: - self.email_sent = True - self.schedule_send = frappe.utils.now_datetime() - self.scheduled_to_send = len(newsletter_recipients) - self.save() + self.email_sent = True + self.email_sent_at = frappe.utils.now() + self.total_recipients = len(recipients) + self.save() def get_newsletter_attachments(self) -> List[Dict[str, str]]: """Get list of attachments on current Newsletter """ - attachments = [] - - if self.send_attachments: - files = frappe.get_all( - "File", - filters={"attached_to_doctype": "Newsletter", "attached_to_name": self.name}, - order_by="creation desc", - pluck="name", - ) - attachments.extend({"fid": file} for file in files) - - return attachments + return [{"file_url": row.attachment} for row in self.attachments] def send_newsletter(self, emails: List[str]): """Trigger email generation for `emails` and add it in Email Queue. """ - # TODO: get rid of this maybe? - message = self.get_message() attachments = self.get_newsletter_attachments() sender = self.send_from or frappe.utils.get_formatted_email(self.owner) - args = {"message": message, "name": self.name} + args = self.as_dict() + args["message"] = self.get_message() is_auto_commit_set = bool(frappe.db.auto_commit_on_many_writes) frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test @@ -172,7 +179,6 @@ class Newsletter(WebsiteGenerator): subject=self.subject, sender=sender, recipients=emails, - message=message, attachments=attachments, template="newsletter", add_unsubscribe_link=self.send_unsubscribe_link, @@ -188,12 +194,13 @@ class Newsletter(WebsiteGenerator): frappe.db.auto_commit_on_many_writes = is_auto_commit_set def get_message(self) -> str: - if self.content_type == "HTML": - return frappe.render_template(self.message_html, {"doc": self.as_dict()}) + message = self.message if self.content_type == "Markdown": - return frappe.utils.markdown(self.message_md) - # fallback to Rich Text - return self.message + message = frappe.utils.md_to_html(self.message_md) + if self.content_type == "HTML": + message = self.message_html + + return frappe.render_template(message, {"doc": self.as_dict()}) def get_recipients(self) -> List[str]: """Get recipients from Email Group""" @@ -225,21 +232,6 @@ class Newsletter(WebsiteGenerator): }, ) - def get_context(self, context): - newsletters = get_newsletter_list("Newsletter", None, None, 0) - if newsletters: - newsletter_list = [d.name for d in newsletters] - if self.name not in newsletter_list: - frappe.redirect_to_message( - _("Permission Error"), _("You are not permitted to view the newsletter.") - ) - frappe.local.flags.redirect_location = frappe.local.response.location - raise frappe.Redirect - else: - context.attachments = self.get_attachments() - context.no_cache = 1 - context.show_sidebar = True - @frappe.whitelist(allow_guest=True) def confirmed_unsubscribe(email, group): @@ -322,35 +314,14 @@ def confirm_subscription(email, email_group=_("Website")): def get_list_context(context=None): context.update({ - "show_sidebar": True, "show_search": True, - 'no_breadcrumbs': True, - "title": _("Newsletter"), - "get_list": get_newsletter_list, + "no_breadcrumbs": True, + "title": _("Newsletters"), + "filters": {"published": 1}, "row_template": "email/doctype/newsletter/templates/newsletter_row.html", }) -def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="modified"): - email_group_list = frappe.db.sql('''SELECT eg.name - FROM `tabEmail Group` eg, `tabEmail Group Member` egm - WHERE egm.unsubscribed=0 - AND eg.name=egm.email_group - AND egm.email = %s''', frappe.session.user) - email_group_list = [d[0] for d in email_group_list] - - if email_group_list: - return frappe.db.sql('''SELECT n.name, n.subject, n.message, n.modified - FROM `tabNewsletter` n, `tabNewsletter Email Group` neg - WHERE n.name = neg.parent - AND n.email_sent=1 - AND n.published=1 - AND neg.email_group in ({0}) - ORDER BY n.modified DESC LIMIT {1} OFFSET {2} - '''.format(','.join(['%s'] * len(email_group_list)), - limit_page_length, limit_start), email_group_list, as_dict=1) - - def send_scheduled_email(): """Send scheduled newsletter to the recipients.""" scheduled_newsletter = frappe.get_all( diff --git a/frappe/email/doctype/newsletter/templates/newsletter.html b/frappe/email/doctype/newsletter/templates/newsletter.html index 733c7df6af..1244f4c49a 100644 --- a/frappe/email/doctype/newsletter/templates/newsletter.html +++ b/frappe/email/doctype/newsletter/templates/newsletter.html @@ -1,6 +1,6 @@ {% extends "templates/web.html" %} -{% block title %} {{ _("Newsletter") }} {% endblock %} +{% block title %} {{ doc.subject }} {% endblock %} {% block page_content %} diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 90aa545941..167b4955fa 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -197,6 +197,7 @@ export default { show_image_cropper: false, crop_image_with_index: -1, trigger_upload: false, + close_dialog: false, hide_dialog_footer: false, allow_take_photo: false, allow_web_link: true, @@ -218,6 +219,12 @@ export default { } }); } + if (this.restrictions.max_file_size == null) { + frappe.call('frappe.core.doctype.file.file.get_max_file_size') + .then(res => { + this.restrictions.max_file_size = Number(res.message); + }); + } }, watch: { files(newvalue, oldvalue) { @@ -289,6 +296,8 @@ export default { progress: 0, total: 0, failed: false, + request_succeeded: false, + error_message: null, uploading: false, private: !is_image } @@ -329,9 +338,17 @@ export default { if (!is_correct_type) { console.warn('File skipped because of invalid file type', file); + frappe.show_alert({ + message: __('File "{0}" was skipped because of invalid file type', [file.name]), + indicator: 'orange' + }); } if (!valid_file_size) { console.warn('File skipped because of invalid file size', file.size, file); + frappe.show_alert({ + message: __('File "{0}" was skipped because size exceeds {1} MB', [file.name, max_file_size / (1024 * 1024)]), + indicator: 'orange' + }); } return is_correct_type && valid_file_size; @@ -357,9 +374,10 @@ export default { let selected_file = this.$refs.file_browser.selected_node; if (!selected_file.value) { frappe.msgprint(__('Click on a file to select it.')); + this.close_dialog = true; return Promise.reject(); } - + this.close_dialog = true; return this.upload_file({ file_url: selected_file.file_url }); @@ -368,9 +386,11 @@ export default { let file_url = this.$refs.web_link.url; if (!file_url) { frappe.msgprint(__('Invalid URL')); + this.close_dialog = true; return Promise.reject(); } file_url = decodeURI(file_url) + this.close_dialog = true; return this.upload_file({ file_url }); @@ -383,6 +403,7 @@ export default { this.on_success && this.on_success(file); }) ); + this.close_dialog = true; return Promise.all(promises); }, upload_file(file, i) { @@ -410,6 +431,7 @@ export default { xhr.onreadystatechange = () => { if (xhr.readyState == XMLHttpRequest.DONE) { if (xhr.status === 200) { + file.request_succeeded = true; let r = null; let file_doc = null; try { @@ -426,15 +448,24 @@ export default { if (this.on_success) { this.on_success(file_doc, r); } + + if (i == this.files.length - 1 && this.files.every(file => file.request_succeeded)) { + this.close_dialog = true; + } + } else if (xhr.status === 403) { + file.failed = true; let response = JSON.parse(xhr.responseText); - frappe.msgprint({ - title: __('Not permitted'), - indicator: 'red', - message: response._error_message - }); + file.error_message = `Not permitted. ${response._error_message || ''}`; + + } else if (xhr.status === 413) { + file.failed = true; + file.error_message = 'Size exceeds the maximum allowed file size.'; + } else { file.failed = true; + file.error_message = xhr.status === 0 ? 'XMLHttpRequest Error' : `${xhr.status} : ${xhr.statusText}`; + let error = null; try { error = JSON.parse(xhr.responseText); diff --git a/frappe/public/js/frappe/file_uploader/index.js b/frappe/public/js/frappe/file_uploader/index.js index 87bc1c8ec8..ec90b19a1a 100644 --- a/frappe/public/js/frappe/file_uploader/index.js +++ b/frappe/public/js/frappe/file_uploader/index.js @@ -67,6 +67,12 @@ export default class FileUploader { } }); + this.uploader.$watch('close_dialog', (close_dialog) => { + if (close_dialog) { + this.dialog && this.dialog.hide(); + } + }); + this.uploader.$watch('hide_dialog_footer', (hide_dialog_footer) => { if (hide_dialog_footer) { this.dialog && this.dialog.footer.addClass('hide'); @@ -84,10 +90,8 @@ export default class FileUploader { upload_files() { this.dialog && this.dialog.get_primary_btn().prop('disabled', true); - return this.uploader.upload_files() - .then(() => { - this.dialog && this.dialog.hide(); - }); + this.dialog && this.dialog.get_secondary_btn().prop('disabled', true); + return this.uploader.upload_files(); } make_dialog() { diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 6a14637f33..7af0705e78 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -167,10 +167,12 @@ frappe.ui.form.Control = class BaseControl { } this.inside_change_event = true; - var set = function(value) { + function set(value) { me.inside_change_event = false; return frappe.run_serially([ + () => me._validated = true, () => me.set_model_value(value), + () => delete me._validated, () => { me.set_mandatory && me.set_mandatory(value); @@ -184,7 +186,6 @@ frappe.ui.form.Control = class BaseControl { } ]); }; - value = this.validate(value); if (value && value.then) { // got a promise diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index 803c2cf070..f4c9849528 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -122,12 +122,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp ); this.$scan_btn = this.$wrapper.find('.link-btn'); - - this.$input.on("focus", () => { - setTimeout(() => { - this.$scan_btn.toggle(true); - }, 500); - }); + this.$scan_btn.toggle(true); const me = this; this.$scan_btn.on('click', 'a', () => { @@ -141,12 +136,6 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp } }); }); - - this.$input.on("blur", () => { - setTimeout(() => { - this.$scan_btn.toggle(false); - }, 500); - }); } bind_change_event() { diff --git a/frappe/public/js/frappe/form/controls/date.js b/frappe/public/js/frappe/form/controls/date.js index 9ad81c7e46..78eb3832cc 100644 --- a/frappe/public/js/frappe/form/controls/date.js +++ b/frappe/public/js/frappe/form/controls/date.js @@ -53,8 +53,6 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat let date_format = sysdefaults && sysdefaults.date_format ? sysdefaults.date_format : 'yyyy-mm-dd'; - let now_date = new Date(); - this.today_text = __("Today"); this.date_format = frappe.defaultDateFormat; this.datepicker_options = { @@ -62,8 +60,9 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat autoClose: true, todayButton: true, dateFormat: date_format, - startDate: now_date, + startDate: this.get_start_date(), keyboardNav: false, + firstDay: frappe.datetime.get_first_day_of_the_week_index(), onSelect: () => { this.$input.trigger('change'); }, @@ -73,9 +72,15 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat .text(this.today_text); this.update_datepicker_position(); - } + }, + ...(this.get_df_options()) }; } + + get_start_date() { + return new Date(this.get_now_date()); + } + set_datepicker() { this.$input.datepicker(this.datepicker_options); this.datepicker = this.$input.data('datepicker'); @@ -112,7 +117,7 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat this.datepicker.update('position', position); } get_now_date() { - return frappe.datetime.now_date(true); + return frappe.datetime.convert_to_system_tz(frappe.datetime.now_date(true)); } set_t_for_today() { var me = this; @@ -150,4 +155,19 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat } return value; } + get_df_options() { + let options = {}; + let df_options = this.df.options || ''; + if (typeof df_options === 'string') { + try { + options = JSON.parse(df_options); + } catch (error) { + console.warn(`Invalid JSON in options of "${this.df.fieldname}"`); + } + } + else if (typeof df_options === 'object') { + options = df_options; + } + return options; + } }; diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index f7a2798a99..d1a06a6ac6 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -1,4 +1,22 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.ControlDate { + set_formatted_input(value) { + if (this.timepicker_only) return; + if (!this.datepicker) return; + if (!value) { + this.datepicker.clear(); + return; + } else if (value === "Today") { + value = this.get_now_date(); + } + value = this.format_for_input(value); + this.$input && this.$input.val(value); + this.datepicker.selectDate(frappe.datetime.user_to_obj(value)); + } + + get_start_date() { + let value = frappe.datetime.convert_to_user_tz(this.value); + return frappe.datetime.str_to_obj(value); + } set_date_options() { super.set_date_options(); this.today_text = __("Now"); @@ -14,10 +32,31 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co get_now_date() { return frappe.datetime.now_datetime(true); } + parse(value) { + if (value) { + value = frappe.datetime.user_to_str(value, false); + + if (!frappe.datetime.is_system_time_zone()) { + value = frappe.datetime.convert_to_system_tz(value, true); + } + + return value; + } + } + format_for_input(value) { + if (!value) return ""; + + + return frappe.datetime.str_to_user(value, false); + } set_description() { - const { description } = this.df; - const { time_zone } = frappe.sys_defaults; - if (!this.df.hide_timezone && !frappe.datetime.is_timezone_same()) { + const description = this.df.description; + const time_zone = this.get_user_time_zone(); + + if (!this.df.hide_timezone) { + // Always show the timezone when rendering the Datetime field since the datetime value will + // always be in system_time_zone rather then local time. + if (!description) { this.df.description = time_zone; } else if (!description.includes(time_zone)) { @@ -26,6 +65,9 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co } super.set_description(); } + get_user_time_zone() { + return frappe.boot.time_zone ? frappe.boot.time_zone.user : frappe.sys_defaults.time_zone; + } set_datepicker() { super.set_datepicker(); if (this.datepicker.opts.timeFormat.indexOf('s') == -1) { @@ -39,6 +81,9 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co get_model_value() { let value = super.get_model_value(); + if (!value && !this.doc) { + value = this.last_value; + } return frappe.datetime.get_datetime_as_string(value); } }; diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js index 080a1cbb48..280eac3941 100644 --- a/frappe/public/js/frappe/form/controls/geolocation.js +++ b/frappe/public/js/frappe/form/controls/geolocation.js @@ -3,6 +3,11 @@ frappe.provide('frappe.utils.utils'); frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.form.ControlData { static horizontal = false + async make() { + await frappe.require(this.required_libs); + super.make(); + } + make_wrapper() { // Create the elements for map area super.make_wrapper(); @@ -196,4 +201,17 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f this.editableLayers.removeLayer(l); }); } + + get required_libs() { + return [ + "assets/frappe/js/lib/leaflet/easy-button.css", + "assets/frappe/js/lib/leaflet/L.Control.Locate.css", + "assets/frappe/js/lib/leaflet/leaflet.draw.css", + "assets/frappe/js/lib/leaflet/leaflet.css", + "assets/frappe/js/lib/leaflet/leaflet.js", + "assets/frappe/js/lib/leaflet/easy-button.js", + "assets/frappe/js/lib/leaflet/leaflet.draw.js", + "assets/frappe/js/lib/leaflet/L.Control.Locate.js", + ]; + } }; diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index e7339372b3..ed355cf8b4 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -444,7 +444,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } validate(value) { // validate the value just entered - if(this.df.options=="[Select]" || this.df.ignore_link_validation) { + if ( + this._validated + || this.df.options=="[Select]" + || this.df.ignore_link_validation + ) { return value; } @@ -452,40 +456,37 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat this.docname, value); } validate_link_and_fetch(df, options, docname, value) { - if (!value) return; + if (!options) return; - return new Promise((resolve) => { - const fetch_map = this.fetch_map; + let field_value = ""; + const fetch_map = this.fetch_map; + const columns_to_fetch = Object.values(fetch_map); - // if default and no fetch, no need to validate - if ($.isEmptyObject(fetch_map) && df.__default_value === value) { - return resolve(value); + // if default and no fetch, no need to validate + if (!columns_to_fetch.length && df.__default_value === value) { + return value; + } + + return frappe.xcall("frappe.client.validate_link", { + doctype: options, + docname: value, + fields: columns_to_fetch, + }).then((response) => { + if (!docname || !columns_to_fetch.length) return response.name; + + for (const [target_field, source_field] of Object.entries(fetch_map)) { + if (value) field_value = response[source_field]; + + frappe.model.set_value( + df.parent, + docname, + target_field, + field_value, + df.fieldtype, + ); } - frappe.db.get_value( - options, - value, - ["name", ...Object.values(fetch_map)], - (response) => { - if (!response.name) { - return resolve(""); - } - - if (docname) { - for (const [target_field, source_field] of Object.entries(fetch_map)) { - frappe.model.set_value( - df.parent, - docname, - target_field, - response[source_field], - df.fieldtype, - ); - } - } - - return resolve(response.name); - } - ) + return response.name; }); } diff --git a/frappe/public/js/frappe/form/controls/markdown_editor.js b/frappe/public/js/frappe/form/controls/markdown_editor.js index e768dcee08..d9ba2df261 100644 --- a/frappe/public/js/frappe/form/controls/markdown_editor.js +++ b/frappe/public/js/frappe/form/controls/markdown_editor.js @@ -2,14 +2,16 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp static editor_class = 'markdown' make_ace_editor() { super.make_ace_editor(); + if (this.markdown_container) return; - this.ace_editor_target.wrap(`
`); - this.markdown_container = this.$input_wrapper.find(`.${this.constructor.editor_class}-container`); + let editor_class = this.constructor.editor_class; + this.ace_editor_target.wrap(`
`); + this.markdown_container = this.$input_wrapper.find(`.${editor_class}-container`); this.editor.getSession().setUseWrapMode(true); this.showing_preview = false; - this.preview_toggle_btn = $(``) + this.preview_toggle_btn = $(``) .click(e => { if (!this.showing_preview) { this.update_preview(); @@ -25,7 +27,7 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp }); this.markdown_container.prepend(this.preview_toggle_btn); - this.markdown_preview = $(`
`).hide(); + this.markdown_preview = $(`
`).hide(); this.markdown_container.append(this.markdown_preview); } diff --git a/frappe/public/js/frappe/form/controls/multiselect_list.js b/frappe/public/js/frappe/form/controls/multiselect_list.js index 8c79071762..5b25b75279 100644 --- a/frappe/public/js/frappe/form/controls/multiselect_list.js +++ b/frappe/public/js/frappe/form/controls/multiselect_list.js @@ -109,6 +109,7 @@ frappe.ui.form.ControlMultiSelectList = class ControlMultiSelectList extends fra let value = decodeURIComponent($selectable_item.data().value); if ($selectable_item.hasClass('selected')) { + this.values = this.values.slice(); this.values.push(value); } else { this.values = this.values.filter(val => val !== value); diff --git a/frappe/public/js/frappe/form/controls/rating.js b/frappe/public/js/frappe/form/controls/rating.js index 1198d33995..981168457a 100644 --- a/frappe/public/js/frappe/form/controls/rating.js +++ b/frappe/public/js/frappe/form/controls/rating.js @@ -2,11 +2,14 @@ frappe.ui.form.ControlRating = class ControlRating extends frappe.ui.form.Contro make_input() { super.make_input(); let stars = ''; - [1, 2, 3, 4, 5].forEach(i => { - stars += ` - + let number_of_stars = this.df.options || 5; + Array.from({length: cint(number_of_stars)}, (_, i) => i + 1).forEach(i => { + stars += ` + + `; }); + const star_template = `
${stars} @@ -15,50 +18,73 @@ frappe.ui.form.ControlRating = class ControlRating extends frappe.ui.form.Contro $(this.input_area).html(star_template); - $(this.input_area).find('svg').hover((ev) => { - const el = $(ev.currentTarget); - let star_value = el.data('rating'); - el.parent().children('svg').each( function(e) { - if (e < star_value) { - $(this).addClass('star-hover'); - } else { - $(this).removeClass('star-hover'); - } - }); - }, (ev) => { + let me = this; + $(this.input_area).find('svg').on('mousemove', function(ev) { + me.update_rating(ev); + }).on('mouseout', function(ev) { const el = $(ev.currentTarget); el.parent().children('svg').each( function() { - $(this).removeClass('star-hover'); + $(this).find('.left-half, .right-half').removeClass('star-hover'); }); }); $(this.input_area).find('svg').click((ev) => { - const el = $(ev.currentTarget); - let star_value = el.data('rating'); - el.parent().children('svg').each( function(e) { - if (e < star_value) { - $(this).addClass('star-click'); - } else { - $(this).removeClass('star-click'); - } - }); + this.update_rating(ev, true); + }); + } + + update_rating(ev, click) { + const el = $(ev.currentTarget); + let star_value = el.data('rating'); + let left_half = false; + let cls = 'star-click'; + if (!click) cls = 'star-hover'; + + if ((ev.pageX - el.offset().left) < el.width() / 2) { + left_half = true; + star_value--; + } + el.parent().children('svg').each( function(e) { + if (e < star_value) { + $(this).find('.left-half, .right-half').addClass(cls); + } else if (e == star_value && left_half) { + $(this).find('.left-half').addClass(cls); + $(this).find('.right-half').removeClass(cls); + if (click) star_value += 0.5; + } else { + $(this).find('.left-half, .right-half').removeClass(cls); + } + }); + if (click) { + let out_of_ratings = this.df.options || 5; + star_value = star_value/out_of_ratings; + this.validate_and_set_in_model(star_value, ev); if (this.doctype && this.docname) { this.set_input(star_value); } - }); + } } + get_value() { - return cint(this.value, null); + return this.value; } set_formatted_input(value) { + let out_of_ratings = this.df.options || 5; + value = value * out_of_ratings; let el = $(this.input_area).find('svg'); el.children('svg').prevObject.each( function(e) { if (e < value) { - $(this).addClass('star-click'); + $(this).find('.left-half, .right-half').addClass('star-click'); + + let is_half = e == Math.floor(value) && value % 1 == 0.5; + is_half && $(this).find('.right-half').removeClass('star-click'); } else { - $(this).removeClass('star-click'); + $(this).find('.left-half, .right-half').removeClass('star-click'); } }); } + validate(fraction) { + return parseFloat(fraction); + } }; diff --git a/frappe/public/js/frappe/form/controls/time.js b/frappe/public/js/frappe/form/controls/time.js index a7b6645681..f7fcc4c618 100644 --- a/frappe/public/js/frappe/form/controls/time.js +++ b/frappe/public/js/frappe/form/controls/time.js @@ -71,7 +71,7 @@ frappe.ui.form.ControlTime = class ControlTime extends frappe.ui.form.ControlDat set_description() { const { description } = this.df; const { time_zone } = frappe.sys_defaults; - if (!frappe.datetime.is_timezone_same()) { + if (!frappe.datetime.is_system_time_zone()) { if (!description) { this.df.description = time_zone; } else if (!description.includes(time_zone)) { diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index 9d5e7cbe09..df4dbf09e7 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -14,6 +14,7 @@ frappe.ui.form.Dashboard = class FormDashboard { this.progress_area = this.make_section({ css_class: 'progress-area', hidden: 1, + collapsible: 1, is_dashboard_section: 1, }); @@ -21,6 +22,7 @@ frappe.ui.form.Dashboard = class FormDashboard { label: __("Overview"), css_class: 'form-heatmap', hidden: 1, + collapsible: 1, is_dashboard_section: 1, body_html: `
@@ -32,6 +34,7 @@ frappe.ui.form.Dashboard = class FormDashboard { label: __("Graph"), css_class: 'form-graph', hidden: 1, + collapsible: 1, is_dashboard_section: 1 }); @@ -40,6 +43,7 @@ frappe.ui.form.Dashboard = class FormDashboard { label: __("Stats"), css_class: 'form-stats', hidden: 1, + collapsible: 1, is_dashboard_section: 1, body_html: this.stats_area_row }); @@ -50,6 +54,7 @@ frappe.ui.form.Dashboard = class FormDashboard { label: __("Connections"), css_class: 'form-links', hidden: 1, + collapsible: 1, is_dashboard_section: 1, body_html: this.transactions_area }); @@ -84,9 +89,10 @@ frappe.ui.form.Dashboard = class FormDashboard { hidden, body_html, make_card: true, + collapsible: 1, is_dashboard_section: 1 }; - return new Section(this.frm.layout.wrapper, options).body; + return new Section(this.parent, options).body; } add_progress(title, percent, message) { @@ -203,7 +209,7 @@ frappe.ui.form.Dashboard = class FormDashboard { after_refresh() { // show / hide new buttons (if allowed) this.links_area.body.find('.btn-new').each((i, el) => { - if (this.frm.can_create($(this).attr('data-doctype'))) { + if (this.frm.can_create($(el).attr('data-doctype'))) { $(el).removeClass('hidden'); } }); diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index b9ec9a5438..f278d1b64b 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -172,9 +172,11 @@ class FormTimeline extends BaseTimeline { get_communication_timeline_contents() { let communication_timeline_contents = []; + let icon_set = {Email: "mail", Phone: "call", Meeting: "calendar", Other: "dot-horizontal"}; (this.doc_info.communications|| []).forEach(communication => { + let medium = communication.communication_medium; communication_timeline_contents.push({ - icon: 'mail', + icon: icon_set[medium], icon_size: 'sm', creation: communication.creation, is_card: true, diff --git a/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js b/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js index 751d48031c..4b675c29fc 100644 --- a/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js +++ b/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js @@ -116,7 +116,7 @@ function get_version_timeline_content(version_doc, frm) { frm.perm); if (field_display_status === 'Read' || field_display_status === 'Write') { - return frappe.meta.get_label(frm.doctype, p[0]); + return __(frappe.meta.get_label(frm.doctype, p[0])); } } }); diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 75d68b12db..57e3f576a1 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -75,13 +75,17 @@ frappe.ui.form.Form = class FrappeForm { this.page = this.wrapper.page; this.layout_main = this.page.main.get(0); + this.$wrapper.on("hide", () => { + this.script_manager.trigger("on_hide"); + }); + this.toolbar = new frappe.ui.form.Toolbar({ frm: this, page: this.page }); // navigate records keyboard shortcuts - this.add_nav_keyboard_shortcuts(); + this.add_form_keyboard_shortcuts(); // 2 column layout this.setup_std_layout(); @@ -112,7 +116,8 @@ frappe.ui.form.Form = class FrappeForm { this.setup_done = true; } - add_nav_keyboard_shortcuts() { + add_form_keyboard_shortcuts() { + // Navigate to next record frappe.ui.keys.add_shortcut({ shortcut: 'shift+ctrl+>', action: () => this.navigate_records(0), @@ -122,6 +127,7 @@ frappe.ui.form.Form = class FrappeForm { condition: () => !this.is_new() }); + // Navigate to previous record frappe.ui.keys.add_shortcut({ shortcut: 'shift+ctrl+<', action: () => this.navigate_records(1), @@ -130,11 +136,61 @@ frappe.ui.form.Form = class FrappeForm { ignore_inputs: true, condition: () => !this.is_new() }); + + let grid_shortcut_keys = [ + { + 'shortcut': 'Up Arrow', + 'description': __('Move cursor to above row') + }, + { + 'shortcut': 'Down Arrow', + 'description': __('Move cursor to below row') + }, + { + 'shortcut': 'tab', + 'description': __('Move cursor to next column') + }, + { + 'shortcut': 'shift+tab', + 'description': __('Move cursor to previous column') + }, + { + 'shortcut': 'Ctrl+up', + 'description': __('Add a row above the current row') + }, + { + 'shortcut': 'Ctrl+down', + 'description': __('Add a row below the current row') + }, + { + 'shortcut': 'Ctrl+shift+up', + 'description': __('Add a row at the top') + }, + { + 'shortcut': 'Ctrl+shift+down', + 'description': __('Add a row at the bottom') + }, + { + 'shortcut': 'shift+alt+down', + 'description': __('To duplcate current row') + } + ]; + + grid_shortcut_keys.forEach(row => { + frappe.ui.keys.add_shortcut({ + shortcut: row.shortcut, + page: this, + description: __(row.description), + ignore_inputs: true, + condition: () => !this.is_new() + }); + }); + } setup_std_layout() { this.form_wrapper = $('
').appendTo(this.layout_main); - this.body = $('
').appendTo(this.form_wrapper); + this.body = $('
').appendTo(this.form_wrapper); // only tray this.meta.section_style='Simple'; // always simple! @@ -155,9 +211,24 @@ frappe.ui.form.Form = class FrappeForm { this.fields = this.layout.fields_list; let dashboard_parent = $('
'); + let dashboard_added = false; + + if (this.layout.tabs.length) { + this.layout.tabs.every(tab => { + if (tab.df.show_dashboard) { + tab.wrapper.prepend(dashboard_parent); + dashboard_added = true; + return false; + } + return true; + }); + if (!dashboard_added) { + this.layout.tabs[0].wrapper.prepend(dashboard_parent); + } + } else { + this.layout.wrapper.find('.form-page').prepend(dashboard_parent); + } - let main_page = this.layout.tabs.length ? this.layout.tabs[0].wrapper : this.layout.wrapper; - main_page.prepend(dashboard_parent); this.dashboard = new frappe.ui.form.Dashboard(dashboard_parent, this); this.tour = new frappe.ui.form.FormTour({ diff --git a/frappe/public/js/frappe/form/form_tour.js b/frappe/public/js/frappe/form/form_tour.js index 0694aa634a..7fefb59ac6 100644 --- a/frappe/public/js/frappe/form/form_tour.js +++ b/frappe/public/js/frappe/form/form_tour.js @@ -18,6 +18,7 @@ frappe.ui.form.FormTour = class FormTour { // if last step is to save, then attach a listener to save button if (step.options.is_save_step) { $(step.options.element).one('click', () => this.driver.reset()); + this.driver.overlay.refresh(); } // focus on input @@ -42,14 +43,27 @@ frappe.ui.form.FormTour = class FormTour { this.tour = { steps: frappe.tour[this.frm.doctype] }; } } - + if (on_finish) this.on_finish = on_finish; this.init_driver(); + if (this.tour.include_name_field) + this.include_name_field(); this.build_steps(); this.update_driver_steps(); } + include_name_field() { + const name_step = { + "description": __("Enter a name for this {0}", [this.frm.doctype]), + "fieldname": "__newname", + "title": __("Document Name"), + "position": "right", + "is_table_field": 0 + }; + this.tour.steps.unshift(name_step); + } + build_steps() { this.driver_steps = []; this.tour.steps.forEach((step) => { @@ -65,9 +79,10 @@ frappe.ui.form.FormTour = class FormTour { const driver_step = this.get_step(step, on_next); this.driver_steps.push(driver_step); - + if (step.fieldtype == 'Table') this.handle_table_step(step); if (step.is_table_field) this.handle_child_table_step(step); + if (step.fieldtype == 'Attach Image') this.handle_attach_image_steps(step); }); if (this.tour.save_on_complete) { @@ -139,7 +154,7 @@ frappe.ui.form.FormTour = class FormTour { const is_next_field_in_curr_table = next_step.parent_field == curr_step.field; if (!is_next_field_in_curr_table) return; - + const rows = this.frm.doc[curr_step.fieldname]; const table_has_rows = rows && rows.length > 0; if (table_has_rows) { @@ -262,4 +277,33 @@ frappe.ui.form.FormTour = class FormTour { this.driver_steps.push(save_step); frappe.ui.form.on(this.frm.doctype, 'after_save', () => this.on_finish && this.on_finish()); } -}; \ No newline at end of file + + handle_attach_image_steps() { + $('.btn-attach').one('click', () => { + setTimeout(() => { + const modal_element = $(".file-uploader").closest(".modal-content"); + const attach_dialog_step = { + element: modal_element[0], + allowClose: false, + overlayClickNext: false, + popover: { + title: __("Select an Image"), + description: "", + position: "left", + doneBtnText: __("Next") + } + }; + + this.driver_steps.splice(this.driver.currentStep + 1, 0, attach_dialog_step); + this.update_driver_steps(); // need to define again, since driver.js only considers steps which are inside DOM + this.driver.moveNext(); + this.driver.overlay.refresh(); + + modal_element.closest('.modal').on('hidden.bs.modal', () => { + this.driver.moveNext(); + }); + + }, 500); + }); + } +}; diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index 0a3c032a40..fd3fcb1bc7 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -63,12 +63,16 @@ frappe.form.formatters = { ); return frappe.form.formatters._right(flt(value, precision) + "%", options); }, - Rating: function(value) { - const rating_html = `${[1, 2, 3, 4, 5].map(i => - ` - - ` - ).join('')}`; + Rating: function(value, docfield) { + let rating_html = ''; + let number_of_stars = docfield.options || 5; + value = value * number_of_stars; + Array.from({length: cint(number_of_stars)}, (_, i) => i + 1).forEach(i => { + rating_html += ` + + + `; + }); return `
${rating_html}
`; @@ -167,12 +171,8 @@ frappe.form.formatters = { }, Datetime: function(value) { if(value) { - var m = moment(frappe.datetime.convert_to_user_tz(value)); - if(frappe.boot.sysdefaults.time_zone) { - m = m.tz(frappe.boot.sysdefaults.time_zone); - } - return m.format(frappe.boot.sysdefaults.date_format.toUpperCase() - + ' ' + frappe.boot.sysdefaults.time_format); + return moment(frappe.datetime.convert_to_user_tz(value)) + .format(frappe.boot.sysdefaults.date_format.toUpperCase() + ' ' + frappe.boot.sysdefaults.time_format || 'HH:mm:ss'); } else { return ""; } diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 1d302f5e1f..8b615f3c59 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -616,11 +616,14 @@ export default class Grid { }); } - add_new_row(idx, callback, show, copy_doc, go_to_last_page = false) { + add_new_row(idx, callback, show, copy_doc, go_to_last_page = false, go_to_first_page = false) { if (this.is_editable()) { if (go_to_last_page) { this.grid_pagination.go_to_last_page_to_add_row(); + } else if (go_to_first_page) { + this.grid_pagination.go_to_page(1); } + if (this.frm) { var d = frappe.model.add_child(this.frm.doc, this.df.options, this.df.fieldname, idx); if (copy_doc) { @@ -684,7 +687,7 @@ export default class Grid { } set_focus_on_row(idx) { - if (!idx) { + if (!idx && idx !== 0) { idx = this.grid_rows.length - 1; } diff --git a/frappe/public/js/frappe/form/grid_pagination.js b/frappe/public/js/frappe/form/grid_pagination.js index 35daafe89d..76a5f7b50b 100644 --- a/frappe/public/js/frappe/form/grid_pagination.js +++ b/frappe/public/js/frappe/form/grid_pagination.js @@ -46,8 +46,55 @@ export default class GridPagination { this.last_page_button.on('click', () => { this.go_to_page(this.total_pages); }); + + this.$page_number.on('keyup', (e) => { + e.currentTarget.style.width = ((e.currentTarget.value.length + 1) * 8) + 'px'; + }); + + this.$page_number.on('keydown', (e) => { + e = (e) ? e : window.event; + var charCode = (e.which) ? e.which : e.keyCode; + let arrow = { up: 38, down: 40 }; + + switch (charCode) { + case arrow.up: + this.inc_dec_number(true); + break; + case arrow.down: + this.inc_dec_number(false); + break; + } + + // only allow numbers from 0-9 and up, down, left, right arrow keys + if (charCode > 31 && (charCode < 48 || charCode > 57) && + ![37, 38, 39, 40].includes(charCode)) { + return false; + } + return true; + }); + + this.$page_number.on('focusout', (e) => { + if (this.page_index == e.currentTarget.value) return; + this.page_index = e.currentTarget.value; + + if (this.page_index < 1) { + this.page_index = 1; + } else if (this.page_index > this.total_pages) { + this.page_index = this.total_pages; + } + + this.go_to_page(); + }); } + inc_dec_number(increment) { + let new_value = parseInt(this.$page_number.val()); + increment ? new_value++ : new_value--; + + if (new_value < 1 || new_value > this.total_pages) return; + + this.$page_number.val(new_value); + } update_page_numbers() { let total_pages = Math.ceil(this.grid.data.length/this.page_length); @@ -65,7 +112,7 @@ export default class GridPagination { get_pagination_html() { let page_text_html = `
- ${__(this.page_index)} + ${__('of')} ${__(this.total_pages)}
`; @@ -104,7 +151,8 @@ export default class GridPagination { let $rows = $(this.grid.parent).find(".rows").empty(); this.grid.render_result_rows($rows, true); if (this.$page_number) { - this.$page_number.text(index); + this.$page_number.val(index); + this.$page_number.css('width', ((index.toString().length + 1) * 8) + 'px'); } this.update_page_numbers(); diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 311a5b7a1e..a40f428969 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -196,7 +196,7 @@ export default class GridRow { // REDESIGN-TODO: Make translation contextual, this No is Number var txt = (this.doc ? this.doc.idx : __("No.")); this.row_index = $( - `
+ `
${this.row_check_html}
`) .appendTo(this.row) @@ -291,6 +291,11 @@ export default class GridRow { this.grid_settings_dialog.hide(); }); + this.grid_settings_dialog.set_secondary_action_label(__("Reset to default")); + this.grid_settings_dialog.set_secondary_action(() => { + this.reset_user_settings_for_grid(); + this.grid_settings_dialog.hide(); + }); } setup_columns_for_dialog() { @@ -368,8 +373,13 @@ export default class GridRow { prepare_columns_for_dialog(selected_fields) { let fields = []; + const blocked_fields = frappe.model.no_value_type; + const always_allow = ["Button"]; + + const show_field = (f) => always_allow.includes(f) || !blocked_fields.includes(f); + this.docfields.forEach(column => { - if (!column.hidden && !in_list(frappe.model.no_value_type, column.fieldtype)) { + if (!column.hidden && show_field(column.fieldtype)) { fields.push({ label: column.label, value: column.fieldname, @@ -510,6 +520,14 @@ export default class GridRow { }); } + reset_user_settings_for_grid() { + frappe.model.user_settings.save(this.frm.doctype, 'GridView', null) + .then((r) => { + frappe.model.user_settings[this.frm.doctype] = r.message || r; + this.grid.reset_grid(); + }); + } + setup_columns() { this.focus_set = false; @@ -705,6 +723,7 @@ export default class GridRow { set_arrow_keys(field) { var me = this; + let ignore_fieldtypes = ['Text', 'Small Text', 'Code', 'Text Editor', 'HTML Editor']; if (field.$input) { field.$input.on('keydown', function(e) { var { TAB, UP: UP_ARROW, DOWN: DOWN_ARROW } = frappe.ui.keyCode; @@ -716,8 +735,20 @@ export default class GridRow { var fieldname = $(this).attr('data-fieldname'); var fieldtype = $(this).attr('data-fieldtype'); + let ctrl_key = e.metaKey || e.ctrlKey; + if (!in_list(ignore_fieldtypes, fieldtype) + && ctrl_key && e.which !== TAB) { + me.add_new_row_using_keys(e); + return; + } + + if (e.shiftKey && e.altKey && DOWN_ARROW === e.which) { + me.duplicate_row_using_keys(); + return; + } + var move_up_down = function(base) { - if (in_list(['Text', 'Small Text', 'Code', 'Text Editor', 'HTML Editor'], fieldtype) && !e.altKey) { + if (in_list(ignore_fieldtypes, fieldtype) && !e.altKey) { return false; } if (field.autocomplete_open) { @@ -772,6 +803,40 @@ export default class GridRow { } } + duplicate_row_using_keys() { + setTimeout(() => { + this.insert(false, true, true); + this.grid.grid_rows[this.doc.idx].toggle_editable_row(); + this.grid.set_focus_on_row(this.doc.idx); + }, 100); + } + + add_new_row_using_keys(e) { + let idx = ''; + + let ctrl_key = e.metaKey || e.ctrlKey; + let is_down_arrow_key_press = (e.which === 40); + + // Add new row at the end or start of the table + if (ctrl_key && e.shiftKey) { + idx = is_down_arrow_key_press ? null : 1; + this.grid.add_new_row(idx, null, is_down_arrow_key_press, + false, is_down_arrow_key_press, !is_down_arrow_key_press); + idx = is_down_arrow_key_press ? (cint(this.grid.grid_rows.length) - 1) : 0; + + } else if (ctrl_key) { + idx = is_down_arrow_key_press ? this.doc.idx : (this.doc.idx - 1); + this.insert(false, is_down_arrow_key_press); + } + + if (idx !== '') { + setTimeout(() => { + this.grid.grid_rows[idx].toggle_editable_row(); + this.grid.set_focus_on_row(idx); + }, 100); + } + } + get_open_form() { return frappe.ui.form.get_open_grid_form(); } diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 7710c82ee7..0de6b1db0d 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -245,7 +245,7 @@ frappe.ui.form.Layout = class Layout { } make_section(df) { - this.section = new Section(this.current_tab ? this.current_tab.wrapper : this.page, df, this.card_layout); + this.section = new Section(this.current_tab ? this.current_tab.wrapper : this.page, df, this.card_layout, this); // append to layout fields if (df) { diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index 37b7e08a80..bc0286e62d 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -12,7 +12,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { init() { this.page_length = 20; - this.start = 0; + this.child_page_length = 20; this.fields = this.get_fields(); this.make(); @@ -29,7 +29,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { get_result_fields() { const show_next_page = () => { - this.start += 20; + this.page_length += 20; this.get_results(); }; return [ @@ -58,7 +58,15 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { get_child_selection_fields() { const fields = []; if (this.allow_child_item_selection && this.child_fieldname) { + const show_more_child_results = () => { + this.child_page_length += 20; + this.show_child_results(); + }; fields.push({ fieldtype: "HTML", fieldname: "child_selection_area" }); + fields.push({ + fieldtype: "Button", fieldname: "more_child_btn", hidden: 1, + label: __("More"), click: show_more_child_results.bind(this) + }); } return fields; } @@ -124,23 +132,27 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { setup_results() { this.$parent = $(this.dialog.body); - this.$wrapper = this.dialog.fields_dict.results_area.$wrapper.append(`
`); this.$results = this.$wrapper.find('.results'); this.$results.append(this.make_list_row()); } + show_child_results() { + this.get_child_result().then(r => { + this.child_results = r.message || []; + this.render_child_datatable(); + + this.$wrapper.addClass('hidden'); + this.$child_wrapper.removeClass('hidden'); + this.dialog.fields_dict.more_btn.$wrapper.hide(); + }); + } + toggle_child_selection() { if (this.dialog.fields_dict['allow_child_item_selection'].get_value()) { - this.get_child_result().then(r => { - this.child_results = r.message || []; - this.render_child_datatable(); - - this.$wrapper.addClass('hidden'); - this.$child_wrapper.removeClass('hidden'); - this.dialog.fields_dict.more_btn.$wrapper.hide(); - }); + this.show_child_results(); } else { this.child_results = []; this.get_results(); @@ -157,6 +169,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.child_datatable.rowmanager.checkMap = []; this.child_datatable.refresh(this.get_child_datatable_rows()); this.$child_wrapper.find('.dt-scrollable').css('height', '300px'); + this.$child_wrapper.find('.dt-scrollable').css('overflow-y', 'scroll'); }, 500); } } @@ -167,14 +180,21 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { } get_child_datatable_rows() { - return this.child_results.map(d => Object.values(d).slice(1)); // slice name field + if (this.child_results.length > this.child_page_length) { + this.dialog.fields_dict.more_child_btn.toggle(true); + } else { + this.dialog.fields_dict.more_child_btn.toggle(false); + } + return this.child_results + .slice(0, this.child_page_length) + .map(d => Object.values(d).slice(1)); // slice name field } setup_child_datatable() { const header_columns = this.get_child_datatable_columns(); const rows = this.get_child_datatable_rows(); this.$child_wrapper = this.dialog.fields_dict.child_selection_area.$wrapper; - this.$child_wrapper.addClass('mt-3'); + this.$child_wrapper.addClass('my-3'); this.child_datatable = new frappe.DataTable(this.$child_wrapper.get(0), { columns: header_columns, @@ -325,7 +345,9 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { let parent_names = this.child_datatable.rowmanager.checkMap.reduce((parent_names, checked, index) => { if (checked == 1) { const parent_name = this.child_results[index].parent; - parent_names.push(parent_name); + if (!parent_names.includes(parent_name)) { + parent_names.push(parent_name); + } } return parent_names; }, []); @@ -410,7 +432,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.empty_list(); } more_btn.hide(); - $(".modal-dialog .list-item--head").css("z-index", 0); + $(".modal-dialog .list-item--head").css("z-index", 1); if (results.length === 0) return; if (more) more_btn.show(); @@ -423,7 +445,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { me.$results.append(me.make_list_row(result)); }); - this.$results.find(".list-item--head").css("z-index", 0); + this.$results.find(".list-item--head").css("z-index", 1); if (frappe.flags.auto_scroll) { this.$results.animate({ scrollTop: me.$results.prop('scrollHeight') }, 500); @@ -484,8 +506,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { txt: this.dialog.fields_dict["search_term"].get_value(), filters: filters, filter_fields: filter_fields, - start: this.start, - page_length: this.page_length + 1, + page_length: this.page_length + 5, query: this.get_query ? this.get_query().query : '', as_dict: 1 }; @@ -499,9 +520,6 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { args: args, }); const more = res.values.length && res.values.length > this.page_length ? 1 : 0; - if (more) { - res.values.pop(); - } return [res, more]; } @@ -510,6 +528,10 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { const args = this.get_args_for_search(); const [res, more] = await this.perform_search(args); + if (more) { + res.values = res.values.splice(0, this.page_length); + } + this.results = []; if (res.values.length) { res.values.forEach(result => { @@ -563,6 +585,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { filters: filters, fields: ['name', 'parent', ...this.child_columns], parent: this.doctype, + limit_page_length: this.child_page_length + 5, order_by: 'parent' } }); diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index 2cf2ac38a9..e412b1dec8 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -267,7 +267,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm { render_edit_in_full_page_link() { var me = this; this.dialog.add_custom_action( - `${frappe.utils.icon('edit', 'xs')} ${__("Edit in full page")}`, + `${__("Edit in full page")}`, () => me.open_doc(true) ); } diff --git a/frappe/public/js/frappe/form/section.js b/frappe/public/js/frappe/form/section.js index e0120f6afc..b0ec491ce6 100644 --- a/frappe/public/js/frappe/form/section.js +++ b/frappe/public/js/frappe/form/section.js @@ -1,5 +1,6 @@ export default class Section { - constructor(parent, df, card_layout) { + constructor(parent, df, card_layout, layout) { + this.layout = layout; this.card_layout = card_layout; this.parent = parent; this.df = df || {}; @@ -25,6 +26,7 @@ export default class Section { ${this.df.is_dashboard_section ? "form-dashboard-section" : "form-section"} ${ make_card ? "card-section" : "" }"> `).appendTo(this.parent); + this.layout && this.layout.sections.push(this); if (this.df) { if (this.df.label) { diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index 03e20ee6f5..aa1101c64e 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -392,9 +392,9 @@ frappe.views.BaseList = class BaseList { this.start = 0; this.page_length = $this.data().value; - this.refresh(); } else if ($this.is(".btn-more")) { this.start = this.start + this.page_length; + this.page_length = 20; } this.refresh(); }); @@ -475,6 +475,7 @@ frappe.views.BaseList = class BaseList { this.render(); this.after_render(); this.freeze(false); + this.reset_defaults(); if (this.settings.refresh) { this.settings.refresh(this); } @@ -492,6 +493,13 @@ frappe.views.BaseList = class BaseList { } else { this.data = this.data.concat(data); } + + this.data = this.data.uniqBy((d) => d.name); + } + + reset_defaults() { + this.page_length = this.page_length + this.start; + this.start = 0; } freeze() { diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index ee6e6d753c..94ec9d4e67 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -24,51 +24,84 @@ export default class BulkOperations { return; } - if (valid_docs.length > 0) { - const dialog = new frappe.ui.Dialog({ - title: __('Print Documents'), - fields: [ - { - 'fieldtype': 'Select', - 'label': __('Letter Head'), - 'fieldname': 'letter_sel', - 'default': __('No Letterhead'), - options: this.get_letterhead_options() - }, - { - 'fieldtype': 'Select', - 'label': __('Print Format'), - 'fieldname': 'print_sel', - options: frappe.meta.get_print_formats(this.doctype) - } - ] - }); - - dialog.set_primary_action(__('Print'), args => { - if (!args) return; - const default_print_format = frappe.get_meta(this.doctype).default_print_format; - const with_letterhead = args.letter_sel == __("No Letterhead") ? 0 : 1; - const print_format = args.print_sel ? args.print_sel : default_print_format; - const json_string = JSON.stringify(valid_docs); - const letterhead = args.letter_sel; - const w = window.open('/api/method/frappe.utils.print_format.download_multi_pdf?' + - 'doctype=' + encodeURIComponent(this.doctype) + - '&name=' + encodeURIComponent(json_string) + - '&format=' + encodeURIComponent(print_format) + - '&no_letterhead=' + (with_letterhead ? '0' : '1') + - '&letterhead=' + encodeURIComponent(letterhead) - ); - - if (!w) { - frappe.msgprint(__('Please enable pop-ups')); - return; - } - }); - - dialog.show(); - } else { + if (valid_docs.length === 0) { frappe.msgprint(__('Select atleast 1 record for printing')); + return; } + + const dialog = new frappe.ui.Dialog({ + title: __('Print Documents'), + fields: [{ + fieldtype: 'Select', + label: __('Letter Head'), + fieldname: 'letter_sel', + default: __('No Letterhead'), + options: this.get_letterhead_options() + }, + { + fieldtype: 'Select', + label: __('Print Format'), + fieldname: 'print_sel', + options: frappe.meta.get_print_formats(this.doctype) + }, + { + fieldtype: 'Select', + label: __('Page Size'), + fieldname: 'page_size', + options: frappe.meta.get_print_sizes(), + default: print_settings.pdf_page_size + }, + { + fieldtype: 'Float', + label: __('Page Height (in mm)'), + fieldname: 'page_height', + depends_on: 'eval:doc.page_size == "Custom"', + default: print_settings.pdf_page_height + }, + { + fieldtype: 'Float', + label: __('Page Width (in mm)'), + fieldname: 'page_width', + depends_on: 'eval:doc.page_size == "Custom"', + default: print_settings.pdf_page_width + }] + }); + + dialog.set_primary_action(__('Print'), args => { + if (!args) return; + const default_print_format = frappe.get_meta(this.doctype).default_print_format; + const with_letterhead = args.letter_sel == __("No Letterhead") ? 0 : 1; + const print_format = args.print_sel ? args.print_sel : default_print_format; + const json_string = JSON.stringify(valid_docs); + const letterhead = args.letter_sel; + + let pdf_options; + if (args.page_size === "Custom") { + if (args.page_height === 0 || args.page_width === 0) { + frappe.throw(__('Page height and width cannot be zero')); + } + pdf_options = JSON.stringify({ "page-height": args.page_height, "page-width": args.page_width }); + } else { + pdf_options = JSON.stringify({ "page-size": args.page_size }); + } + + const w = window.open( + '/api/method/frappe.utils.print_format.download_multi_pdf?' + + 'doctype=' + encodeURIComponent(this.doctype) + + '&name=' + encodeURIComponent(json_string) + + '&format=' + encodeURIComponent(print_format) + + '&no_letterhead=' + (with_letterhead ? '0' : '1') + + '&letterhead=' + encodeURIComponent(letterhead) + + '&options=' + encodeURIComponent(pdf_options) + ); + + if (!w) { + frappe.msgprint(__('Please enable pop-ups')); + return; + } + }); + + dialog.show(); } get_letterhead_options () { diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 07c8acef27..22f8377a57 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -307,10 +307,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } update_checkbox(target) { + if (!this.$checkbox_actions) return; + let $check_all_checkbox = this.$checkbox_actions.find(".list-check-all"); if ($check_all_checkbox.prop("checked") && target && !target.prop("checked")) { - $check_all_checkbox.prop("checked", false); + $check_all_checkbox.prop("checked", false); } $check_all_checkbox.prop("checked", this.$checks.length === this.data.length); @@ -758,6 +760,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { : value; } + if (df.fieldtype === "Rating") { + let out_of_ratings = df.options || 5; + _value = _value * out_of_ratings; + } + if (df.fieldtype === "Image") { html = df.options ? `` @@ -1315,7 +1322,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { return; } frappe.realtime.on("list_update", (data) => { - if (this.filter_area.is_being_edited()) { + if (this.avoid_realtime_update()) { return; } @@ -1377,6 +1384,19 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { }); } + avoid_realtime_update() { + if (this.filter_area.is_being_edited()) { + return true; + } + // this is set when a bulk operation is called from a list view which might update the list view + // this is to avoid the list view from refreshing a lot of times + // the list view is updated once after the bulk operation is complete + if (this.disable_list_update) { + return true; + } + return false; + } + set_rows_as_checked() { $.each(this.$checks, (i, el) => { let docname = $(el).attr("data-name"); @@ -1431,6 +1451,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { return this.data.filter((d) => docnames.includes(d.name)); } + clear_checked_items() { + this.$checks && this.$checks.prop("checked", false); + this.on_row_checked(); + } + save_view_user_settings(obj) { return frappe.model.user_settings.save( this.doctype, @@ -1475,6 +1500,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { read_only: 1, }, ], + primary_action_label: __("Copy to clipboard"), + primary_action: () => { + frappe.utils.copy_to_clipboard(this.get_share_url()); + d.hide(); + }, }); d.show(); } @@ -1483,7 +1513,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { const doctype = this.doctype; const items = []; - if (frappe.model.can_import(doctype)) { + if (frappe.model.can_import(doctype, null, this.meta)) { items.push({ label: __("Import"), action: () => @@ -1653,11 +1683,17 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { const bulk_assignment = () => { return { label: __("Assign To"), - action: () => + action: () => { + this.disable_list_update = true; bulk_operations.assign( this.get_checked_items(true), - this.refresh - ), + () => { + this.disable_list_update = false; + this.clear_checked_items(); + this.refresh(); + } + ); + }, standard: true, }; }; @@ -1665,11 +1701,17 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { const bulk_assignment_rule = () => { return { label: __("Apply Assignment Rule"), - action: () => + action: () => { + this.disable_list_update = true; bulk_operations.apply_assignment_rule( this.get_checked_items(true), - this.refresh - ), + () => { + this.disable_list_update = false; + this.clear_checked_items(); + this.refresh(); + } + ); + }, standard: true, }; }; @@ -1677,11 +1719,17 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { const bulk_add_tags = () => { return { label: __("Add Tags"), - action: () => + action: () => { + this.disable_list_update = true; bulk_operations.add_tags( this.get_checked_items(true), - this.refresh - ), + () => { + this.disable_list_update = false; + this.clear_checked_items(); + this.refresh(); + } + ); + }, standard: true, }; }; @@ -1703,7 +1751,14 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { ); frappe.confirm( __("Delete {0} items permanently?", [docnames.length]), - () => bulk_operations.delete(docnames, this.refresh) + () => { + this.disable_list_update = true; + bulk_operations.delete(docnames, () => { + this.disable_list_update = false; + this.clear_checked_items(); + this.refresh(); + }); + } ); }, standard: true, @@ -1718,13 +1773,18 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if (docnames.length > 0) { frappe.confirm( __("Cancel {0} documents?", [docnames.length]), - () => + () => { + this.disable_list_update = true; bulk_operations.submit_or_cancel( docnames, "cancel", - this.refresh - ) - ); + () => { + this.disable_list_update = false; + this.clear_checked_items(); + this.refresh(); + } + ); + }); } }, standard: true, @@ -1739,12 +1799,18 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if (docnames.length > 0) { frappe.confirm( __("Submit {0} documents?", [docnames.length]), - () => + () => { + this.disable_list_update = true; bulk_operations.submit_or_cancel( docnames, "submit", - this.refresh - ) + () => { + this.disable_list_update = false; + this.clear_checked_items(); + this.refresh(); + } + ); + } ); } }, @@ -1767,12 +1833,15 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } }); - const docnames = this.get_checked_items(true); - + this.disable_list_update = true; bulk_operations.edit( - docnames, + this.get_checked_items(true), field_mappings, - this.refresh + () => { + this.disable_list_update = false; + this.clear_checked_items(); + this.refresh(); + } ); }, standard: true, @@ -1908,12 +1977,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if (!doctype) return; frappe.provide("frappe.views.trees"); - // refresh tree view - if (frappe.views.trees[doctype]) { - frappe.views.trees[doctype].tree.refresh(); - return; - } - // refresh list view const page_name = frappe.get_route_str(); const list_view = frappe.views.list_view[page_name]; diff --git a/frappe/public/js/frappe/model/create_new.js b/frappe/public/js/frappe/model/create_new.js index 1b09a451eb..d10af1932e 100644 --- a/frappe/public/js/frappe/model/create_new.js +++ b/frappe/public/js/frappe/model/create_new.js @@ -178,7 +178,7 @@ $.extend(frappe.model, { user_default = user_defaults[0]; } } - + if (!user_default) { user_default = frappe.defaults.get_user_default(df.fieldname); } else if ( @@ -351,6 +351,8 @@ $.extend(frappe.model, { newdoc.creation = ""; newdoc.modified_by = user; newdoc.modified = ""; + newdoc.lft = null; + newdoc.rgt = null; return newdoc; }, diff --git a/frappe/public/js/frappe/model/indicator.js b/frappe/public/js/frappe/model/indicator.js index 575ab35b29..2e6bfff81d 100644 --- a/frappe/public/js/frappe/model/indicator.js +++ b/frappe/public/js/frappe/model/indicator.js @@ -10,6 +10,8 @@ frappe.has_indicator = function(doctype) { } else if(frappe.meta.has_field(doctype, 'enabled') || frappe.meta.has_field(doctype, 'disabled')) { return true; + } else if (frappe.meta.has_field(doctype, 'status') && frappe.get_meta(doctype).states.length) { + return true; } return false; } @@ -21,6 +23,7 @@ frappe.get_indicator = function(doc, doctype) { if(!doctype) doctype = doc.doctype; + let meta = frappe.get_meta(doctype); var workflow = frappe.workflow.workflows[doctype]; var without_workflow = workflow ? workflow['override_status'] : true; @@ -61,6 +64,13 @@ frappe.get_indicator = function(doc, doctype) { return [__("Cancelled"), "red", "docstatus,=,2"]; } + // based on document state + if (doc.status && meta && meta.states && meta.states.find(d => d.title === doc.status)) { + let state = meta.states.find(d => d.title === doc.status); + let color_class = frappe.scrub(state.color, '-'); + return [__(doc.status), color_class, "status,=," + doc.status]; + } + if(settings.get_indicator) { var indicator = settings.get_indicator(doc); if(indicator) return indicator; diff --git a/frappe/public/js/frappe/model/meta.js b/frappe/public/js/frappe/model/meta.js index 6ee9084adc..3c9ddc4d96 100644 --- a/frappe/public/js/frappe/model/meta.js +++ b/frappe/public/js/frappe/model/meta.js @@ -192,6 +192,15 @@ $.extend(frappe.meta, { } }, + get_print_sizes: function() { + return [ + "A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", + "B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B9", "B10", + "C5E", "Comm10E", "DLE", "Executive", "Folio", "Ledger", "Legal", + "Letter", "Tabloid", "Custom" + ]; + }, + get_print_formats: function(doctype) { var print_format_list = ["Standard"]; var default_print_format = locals.DocType[doctype].default_print_format; diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index 9e394a7433..041905408a 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -317,11 +317,13 @@ $.extend(frappe.model, { return doc && doc.__last_sync_on && ((new Date() - doc.__last_sync_on)) < 5000; }, - can_import: function(doctype, frm) { - // system manager can always import - if(frappe.user_roles.includes("System Manager")) return true; + can_import: function(doctype, frm, meta=null) { + if (meta && !meta.allow_import) return false; - if(frm) return frm.perm[0].import===1; + // system manager can always import + if (frappe.user_roles.includes("System Manager")) return true; + + if (frm) return frm.perm[0].import===1; return frappe.boot.user.can_import.indexOf(doctype)!==-1; }, diff --git a/frappe/public/js/frappe/model/perm.js b/frappe/public/js/frappe/model/perm.js index ed2ec8a783..0eabfdd337 100644 --- a/frappe/public/js/frappe/model/perm.js +++ b/frappe/public/js/frappe/model/perm.js @@ -15,7 +15,7 @@ Object.assign(window, { }); $.extend(frappe.perm, { - rights: ["read", "write", "create", "delete", "submit", "cancel", "amend", + rights: ["select", "read", "write", "create", "delete", "submit", "cancel", "amend", "report", "import", "export", "print", "email", "share", "set_user_permissions"], doctype_perm: {}, diff --git a/frappe/public/js/frappe/module_editor.js b/frappe/public/js/frappe/module_editor.js index 5e2ca4bc83..ff0cfc2426 100644 --- a/frappe/public/js/frappe/module_editor.js +++ b/frappe/public/js/frappe/module_editor.js @@ -1,38 +1,54 @@ frappe.ModuleEditor = class ModuleEditor { constructor(frm, wrapper) { - this.wrapper = $('
').appendTo(wrapper); this.frm = frm; - this.make(); - } - make() { - var me = this; - this.frm.doc.__onload.all_modules.forEach(function(m) { - $(repl('
\ -
', {module: m})).appendTo(me.wrapper); - }); - this.bind(); - } - refresh() { - var me = this; - this.wrapper.find(".block-module-check").prop("checked", true); - $.each(this.frm.doc.block_modules, function(i, d) { - me.wrapper.find(".block-module-check[data-module='"+ d.module +"']").prop("checked", false); + this.wrapper = wrapper; + const block_modules = this.frm.doc.block_modules.map(row => row.module); + this.multicheck = frappe.ui.form.make_control({ + parent: wrapper, + df: { + fieldname: "block_modules", + fieldtype: "MultiCheck", + select_all: true, + columns: 3, + get_data: () => { + return this.frm.doc.__onload.all_modules.map(module => { + return { + label: __(module), + value: module, + checked: !block_modules.includes(module), + }; + }); + }, + on_change: () => { + this.set_modules_in_table(); + this.frm.dirty(); + } + }, + render_input: true }); } - bind() { - var me = this; - this.wrapper.on("change", ".block-module-check", function() { - var module = $(this).attr('data-module'); - if ($(this).prop("checked")) { - // remove from block_modules - me.frm.doc.block_modules = $.map(me.frm.doc.block_modules || [], function(d) { - if (d.module != module) { - return d; - } - }); - } else { - me.frm.add_child("block_modules", {"module": module}); + + show() { + const block_modules = this.frm.doc.block_modules.map(row => row.module); + const all_modules = this.frm.doc.__onload.all_modules; + this.multicheck.selected_options = all_modules.filter(m => !block_modules.includes(m)); + this.multicheck.refresh_input(); + } + + set_modules_in_table() { + let block_modules = this.frm.doc.block_modules || []; + let unchecked_options = this.multicheck.get_unchecked_options(); + + block_modules.map(module_doc => { + if (!unchecked_options.includes(module_doc.module)) { + frappe.model.clear_doc(module_doc.doctype, module_doc.name); + } + }); + + unchecked_options.map(module => { + if (!block_modules.find(d => d.module === module)) { + let module_doc = frappe.model.add_child(this.frm.doc, "Block Module", "block_modules"); + module_doc.module = module; } }); } diff --git a/frappe/public/js/frappe/provide.js b/frappe/public/js/frappe/provide.js index d4d0fdffb8..5180a6761f 100644 --- a/frappe/public/js/frappe/provide.js +++ b/frappe/public/js/frappe/provide.js @@ -31,6 +31,7 @@ frappe.provide('frappe.utils'); frappe.provide('frappe.model'); frappe.provide('frappe.user'); frappe.provide('frappe.session'); +frappe.provide("frappe._messages"); frappe.provide('locals.DocType'); // for listviews diff --git a/frappe/public/js/frappe/request.js b/frappe/public/js/frappe/request.js index adc3bb5626..eff0391338 100644 --- a/frappe/public/js/frappe/request.js +++ b/frappe/public/js/frappe/request.js @@ -481,6 +481,24 @@ frappe.request.report_error = function(xhr, request_opts) { exc = ""; } + const copy_markdown_to_clipboard = () => { + const code_block = snippet => '```\n' + snippet + '\n```'; + const traceback_info = [ + '### App Versions', + code_block(JSON.stringify(frappe.boot.versions, null, "\t")), + '### Route', + code_block(frappe.get_route_str()), + '### Trackeback', + code_block(exc), + '### Request Data', + code_block(JSON.stringify(request_opts, null, "\t")), + '### Response Data', + code_block(JSON.stringify(data, null, '\t')), + ].join("\n"); + frappe.utils.copy_to_clipboard(traceback_info); + }; + + var show_communication = function() { var error_report_message = [ '
Please type some additional information that could help us reproduce this issue:
', @@ -532,6 +550,11 @@ frappe.request.report_error = function(xhr, request_opts) { frappe.msgprint(__('Support Email Address Not Specified')); } frappe.error_dialog.hide(); + }, + secondary_action_label: __('Copy error to clipboard'), + secondary_action: () => { + copy_markdown_to_clipboard(); + frappe.error_dialog.hide(); } }); frappe.error_dialog.wrapper.classList.add('msgprint-dialog'); diff --git a/frappe/public/js/frappe/router_history.js b/frappe/public/js/frappe/router_history.js index c64c3fc9f2..14b936f5e8 100644 --- a/frappe/public/js/frappe/router_history.js +++ b/frappe/public/js/frappe/router_history.js @@ -4,14 +4,15 @@ const routes_to_skip = ['Form', 'social', 'setup-wizard', 'recorder']; const save_routes = frappe.utils.debounce(() => { if (frappe.session.user === 'Guest') return; const routes = frappe.route_history_queue; + if (!routes.length) return; + frappe.route_history_queue = []; - - frappe.xcall('frappe.deferred_insert.deferred_insert', { - 'doctype': 'Route History', - 'records': routes + + frappe.xcall('frappe.desk.doctype.route_history.route_history.deferred_insert', { + 'routes': routes }).catch(() => { frappe.route_history_queue.concat(routes); - }); + }); }, 10000); @@ -19,7 +20,6 @@ frappe.router.on('change', () => { const route = frappe.get_route(); if (is_route_useful(route)) { frappe.route_history_queue.push({ - 'user': frappe.session.user, 'creation': frappe.datetime.now_datetime(), 'route': frappe.get_route_str() }); diff --git a/frappe/public/js/frappe/translate.js b/frappe/public/js/frappe/translate.js index 825a3b9219..ca407329aa 100644 --- a/frappe/public/js/frappe/translate.js +++ b/frappe/public/js/frappe/translate.js @@ -2,11 +2,7 @@ // MIT License. See license.txt // for translation -frappe._messages = {}; frappe._ = function(txt, replace, context = null) { - if ($.isEmptyObject(frappe._messages) && frappe.boot) { - $.extend(frappe._messages, frappe.boot.__messages); - } if (!txt) return txt; if (typeof txt != "string") return txt; diff --git a/frappe/public/js/frappe/ui/dialog.js b/frappe/public/js/frappe/ui/dialog.js index b1a22c8929..e2e51ce501 100644 --- a/frappe/public/js/frappe/ui/dialog.js +++ b/frappe/public/js/frappe/ui/dialog.js @@ -198,6 +198,11 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { this.$wrapper.removeClass('modal-minimize'); + if (this.minimizable && this.is_minimized) { + $(".modal-backdrop").toggle(); + this.is_minimized = false; + } + // clear any message this.clear_message(); diff --git a/frappe/public/js/frappe/ui/filters/field_select.js b/frappe/public/js/frappe/ui/filters/field_select.js index fdedc1d8aa..0bdb9085f0 100644 --- a/frappe/public/js/frappe/ui/filters/field_select.js +++ b/frappe/public/js/frappe/ui/filters/field_select.js @@ -112,8 +112,11 @@ frappe.ui.FieldSelect = class FieldSelect { // main table var main_table_fields = std_filters.concat(frappe.meta.docfield_list[me.doctype]); $.each(frappe.utils.sort(main_table_fields, "label", "string"), function(i, df) { + let doctype = frappe.get_meta(me.doctype).istable && me.parent_doctype ? + me.parent_doctype : me.doctype; + // show fields where user has read access and if report hide flag is not set - if(frappe.perm.has_perm(me.doctype, df.permlevel, "read")) + if (frappe.perm.has_perm(doctype, df.permlevel, "read")) me.add_field_option(df); }); @@ -129,8 +132,11 @@ frappe.ui.FieldSelect = class FieldSelect { } $.each(frappe.utils.sort(child_table_fields, "label", "string"), function(i, df) { + let doctype = frappe.get_meta(me.doctype).istable && me.parent_doctype ? + me.parent_doctype : me.doctype; + // show fields where user has read access and if report hide flag is not set - if(frappe.perm.has_perm(me.doctype, df.permlevel, "read")) + if (frappe.perm.has_perm(doctype, df.permlevel, "read")) me.add_field_option(df); }); } diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js index 2151e66236..f5726d3a29 100644 --- a/frappe/public/js/frappe/ui/filters/filter.js +++ b/frappe/public/js/frappe/ui/filters/filter.js @@ -80,6 +80,7 @@ frappe.ui.Filter = class { this.fieldselect = new frappe.ui.FieldSelect({ parent: this.filter_edit_area.find('.fieldname-select-area'), doctype: this.parent_doctype, + parent_doctype: this._parent_doctype, filter_fields: this.filter_fields, input_class: 'input-xs', select: (doctype, fieldname) => { diff --git a/frappe/public/js/frappe/ui/filters/filter_list.js b/frappe/public/js/frappe/ui/filters/filter_list.js index 72312d7f13..18499a3b7e 100644 --- a/frappe/public/js/frappe/ui/filters/filter_list.js +++ b/frappe/public/js/frappe/ui/filters/filter_list.js @@ -201,6 +201,7 @@ frappe.ui.FilterGroup = class { parent: this.wrapper, parent_doctype: this.doctype, doctype: doctype, + _parent_doctype: this.parent_doctype, fieldname: fieldname, condition: condition, value: value, diff --git a/frappe/public/js/frappe/ui/keyboard.js b/frappe/public/js/frappe/ui/keyboard.js index b6c54a3361..e28a8f680d 100644 --- a/frappe/public/js/frappe/ui/keyboard.js +++ b/frappe/public/js/frappe/ui/keyboard.js @@ -72,6 +72,9 @@ frappe.ui.keys.show_keyboard_shortcut_dialog = () => { let current_page_shortcuts = standard_shortcuts.filter( shortcut => shortcut.page && shortcut.page === window.cur_page.page.page); + let grid_shortcuts = standard_shortcuts.filter( + shortcut => shortcut.page && shortcut.page === window.cur_page.page.frm); + function generate_shortcuts_html(shortcuts, heading) { if (!shortcuts.length) { return ''; @@ -100,6 +103,7 @@ frappe.ui.keys.show_keyboard_shortcut_dialog = () => { let global_shortcuts_html = generate_shortcuts_html(global_shortcuts, __('Global Shortcuts')); let current_page_shortcuts_html = generate_shortcuts_html(current_page_shortcuts, __('Page Shortcuts')); + let grid_shortcuts_html = generate_shortcuts_html(grid_shortcuts, __('Grid Shortcuts')); let dialog = new frappe.ui.Dialog({ title: __('Keyboard Shortcuts'), @@ -110,6 +114,7 @@ frappe.ui.keys.show_keyboard_shortcut_dialog = () => { dialog.$body.append(global_shortcuts_html); dialog.$body.append(current_page_shortcuts_html); + dialog.$body.append(grid_shortcuts_html); dialog.$body.append(`
${__('Press Alt Key to trigger additional shortcuts in Menu and Sidebar')} diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index c299edb7db..91a2390cdb 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -714,6 +714,10 @@ frappe.ui.Page = class Page { ${opts.icon ? frappe.utils.icon(opts.icon): ''} ${label} `); + // Add actions as menu item in Mobile View (similar to "add_custom_button" in forms.js) + let menu_item = this.add_menu_item(label, click, false); + menu_item.parent().addClass("hidden-xl"); + button.appendTo(this.custom_actions); button.on('click', click); this.custom_actions.removeClass('hide'); diff --git a/frappe/public/js/frappe/ui/slides.js b/frappe/public/js/frappe/ui/slides.js index f9ac26844c..f79f54b786 100644 --- a/frappe/public/js/frappe/ui/slides.js +++ b/frappe/public/js/frappe/ui/slides.js @@ -21,7 +21,7 @@ frappe.ui.Slide = class Slide { this.$body = $(`
-

${this.title}

+

${__(this.title)}

@@ -40,7 +40,7 @@ frappe.ui.Slide = class Slide { if (this.image_src) this.$content.append( $(``)); - if (this.help) this.$content.append($(`

${this.help}

`)); + if (this.help) this.$content.append($(`

${__(this.help)}

`)); this.reqd_fields = []; @@ -263,7 +263,7 @@ frappe.ui.Slides = class Slides { .appendTo(this.container); this.render_progress_dots(); - this.make_prev_next_buttons(); + this.make_prev_next_complete_buttons(); if (this.before_load) this.before_load(this.$footer); // can be on demand @@ -289,6 +289,7 @@ frappe.ui.Slides = class Slides { } } else { if (this.made_slide_ids.includes(id + "")) { + this.slide_dict[id].done = false; this.slide_dict[id].destroy(); this.slide_dict[id].make(); } @@ -298,6 +299,7 @@ frappe.ui.Slides = class Slides { refresh(id) { this.render_progress_dots(); + this.make_prev_next_complete_buttons(); this.show_hide_prev_next(id); this.$body.find('.form-control').first().focus(); } @@ -338,13 +340,16 @@ frappe.ui.Slides = class Slides { if (!this.unidirectional) this.bind_progress_dots(); } - make_prev_next_buttons() { + make_prev_next_complete_buttons() { + this.$footer.empty(); + $(`
- +
- + +
`).appendTo(this.$footer); @@ -361,6 +366,8 @@ frappe.ui.Slides = class Slides { this.show_slide(this.current_id + 1); } }); + + this.$complete_btn = this.$footer.find('.complete-btn').attr('tabIndex', 0); } bind_progress_dots() { diff --git a/frappe/public/js/frappe/ui/theme_switcher.js b/frappe/public/js/frappe/ui/theme_switcher.js index 4524472415..2c1d93a2ec 100644 --- a/frappe/public/js/frappe/ui/theme_switcher.js +++ b/frappe/public/js/frappe/ui/theme_switcher.js @@ -42,7 +42,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { } refresh() { - this.current_theme = document.documentElement.getAttribute("data-theme") || "light"; + this.current_theme = document.documentElement.getAttribute("data-theme-mode") || "light"; this.fetch_themes().then(() => { this.render(); }); @@ -54,10 +54,17 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { { name: "light", label: __("Frappe Light"), + info: __("Light Theme") }, { name: "dark", label: __("Timeless Night"), + info: __("Dark Theme") + }, + { + name: "automatic", + label: __("Automatic"), + info: __("Uses system's theme to switch between light and dark mode") } ]; @@ -74,11 +81,15 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { } get_preview_html(theme) { + const is_auto_theme = theme.name === "automatic"; const preview = $(`
-
+
-
${frappe.utils.icon('tick', 'xs')}
+
+ ${frappe.utils.icon('tick', 'xs')} +
@@ -112,13 +123,14 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { toggle_theme(theme) { this.current_theme = theme.toLowerCase(); - document.documentElement.setAttribute("data-theme", this.current_theme); + document.documentElement.setAttribute("data-theme-mode", this.current_theme); frappe.show_alert("Theme Changed", 3); frappe.xcall("frappe.core.doctype.user.user.switch_theme", { theme: toTitle(theme) }); } + show() { this.dialog.show(); } @@ -127,3 +139,22 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { this.dialog.hide(); } }; + +frappe.ui.add_system_theme_switch_listener = () => { + frappe.ui.dark_theme_media_query.addEventListener('change', () => { + frappe.ui.set_theme(); + }); +}; + +frappe.ui.dark_theme_media_query = window.matchMedia("(prefers-color-scheme: dark)"); + +frappe.ui.set_theme = (theme) => { + const root = document.documentElement; + let theme_mode = root.getAttribute("data-theme-mode"); + if (!theme) { + if (theme_mode === "automatic") { + theme = frappe.ui.dark_theme_media_query.matches ? 'dark' : 'light'; + } + } + root.setAttribute("data-theme", theme || theme_mode); +}; \ No newline at end of file diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index 6d1d7228e3..9ff8fe96f3 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -74,7 +74,7 @@ frappe.search.AwesomeBar = class AwesomeBar { awesomplete.list = me.deduplicate(me.options); - }, 500)); + }, 100)); var open_recent = function() { if (!this.autocomplete_open) { @@ -305,7 +305,7 @@ frappe.search.AwesomeBar = class AwesomeBar { index: 80, default: "Calculator", onclick: function() { - frappe.msgprint(formatted_value, "Result"); + frappe.msgprint(formatted_value, __("Result")); } }); } catch(e) { @@ -317,10 +317,10 @@ frappe.search.AwesomeBar = class AwesomeBar { make_random(txt) { if(txt.toLowerCase().includes('random')) { this.options.push({ - label: "Generate Random Password", + label: __("Generate Random Password"), value: frappe.utils.get_random(16), onclick: function() { - frappe.msgprint(frappe.utils.get_random(16), "Result"); + frappe.msgprint(frappe.utils.get_random(16), __("Result")); } }) } diff --git a/frappe/public/js/frappe/ui/toolbar/search_utils.js b/frappe/public/js/frappe/ui/toolbar/search_utils.js index fdc2efefde..9700276568 100644 --- a/frappe/public/js/frappe/ui/toolbar/search_utils.js +++ b/frappe/public/js/frappe/ui/toolbar/search_utils.js @@ -63,10 +63,11 @@ frappe.search.utils = { } } else if (['List', 'Tree', 'Workspaces', 'query-report'].includes(match[1][0]) && (match[1].length > 1)) { var type = match[1][0], label = type; - if(type==='Workspaces') label = 'Workspace'; - else if(type==='query-report' || match[1][2] ==='Report') label = 'Report'; - out.label = __(match[1][1]).bold() + " " + __(label); - out.value = __(match[1][1]) + " " + __(label); + if (type==='Workspaces') label = 'Workspace'; + else if (type==='query-report' || match[1][2] ==='Report') label = 'Report'; + out.label = __(`{0} ${label}`, [__(match[1][1]).bold()]); + out.value = __(`{0} ${label}`, [__(match[1][1])]); + } else if (match[0]) { out.label = match[0].bold(); out.value = match[0]; @@ -155,13 +156,17 @@ frappe.search.utils = { // check to skip extra list in the text // eg. Price List List should be only Price List let skip_list = type === 'List' && target.endsWith('List'); - let label = me.bolden_match_part(__(target), keywords); - label += skip_list ? '' : ` ${__(type)}`; + let label_without_type = me.bolden_match_part(__(target), keywords); + if (skip_list) { + var label = label_without_type; + } else { + label = __(`{0} ${skip_list ? '' : type}`, [label_without_type]); + } return { type: type, label: label, - value: __(target + " " + type), + value: __(`{0} ${type}`, [target]), index: level + order, match: target, route: route, diff --git a/frappe/public/js/frappe/ui/tree.js b/frappe/public/js/frappe/ui/tree.js index 4b11b092eb..c32d92aa32 100644 --- a/frappe/public/js/frappe/ui/tree.js +++ b/frappe/public/js/frappe/ui/tree.js @@ -299,7 +299,6 @@ frappe.ui.Tree = class { .appendTo($toolbar); $link.on('click', () => { obj.click(node); - this.refresh(); }); }); diff --git a/frappe/public/js/frappe/utils/address_and_contact.js b/frappe/public/js/frappe/utils/address_and_contact.js index 3b05e5f0bb..61339d4e24 100644 --- a/frappe/public/js/frappe/utils/address_and_contact.js +++ b/frappe/public/js/frappe/utils/address_and_contact.js @@ -29,7 +29,7 @@ $.extend(frappe.contacts, { } }, get_last_doc: function(frm) { - const reverse_routes = frappe.route_history.reverse(); + const reverse_routes = frappe.route_history.slice().reverse(); const last_route = reverse_routes.find(route => { return route[0] === 'Form' && route[1] !== frm.doctype }) diff --git a/frappe/public/js/frappe/utils/datatable.js b/frappe/public/js/frappe/utils/datatable.js new file mode 100644 index 0000000000..ec82d256f1 --- /dev/null +++ b/frappe/public/js/frappe/utils/datatable.js @@ -0,0 +1,22 @@ +frappe.provide("frappe.utils.datatable"); + +frappe.utils.datatable.get_translations = function () { + let translations = {}; + translations[frappe.boot.lang] = { + "Sort Ascending": __("Sort Ascending"), + "Sort Descending": __("Sort Descending"), + "Reset sorting": __("Reset sorting"), + "Remove column": __("Remove column"), + "No Data": __("No Data"), + "{count} cells copied": { + "1": __("{count} cell copied"), + "default": __("{count} cells copied") + }, + "{count} rows selected": { + "1": __("{count} row selected"), + "default": __("{count} rows selected") + } + }; + + return translations; +}; diff --git a/frappe/public/js/frappe/utils/datetime.js b/frappe/public/js/frappe/utils/datetime.js index 99d47a6deb..196bdf68a3 100644 --- a/frappe/public/js/frappe/utils/datetime.js +++ b/frappe/public/js/frappe/utils/datetime.js @@ -13,33 +13,48 @@ frappe.provide("frappe.datetime"); $.extend(frappe.datetime, { convert_to_user_tz: function(date, format) { // format defaults to true - if(frappe.sys_defaults.time_zone) { - var date_obj = moment.tz(date, frappe.sys_defaults.time_zone).local(); + // Converts the datetime string to system time zone first since the database only stores datetime in + // system time zone and then convert the string to user time zone(from User doctype). + let date_obj = null; + if (frappe.boot.time_zone && frappe.boot.time_zone.system && frappe.boot.time_zone.user) { + date_obj = moment.tz(date, frappe.boot.time_zone.system) + .clone() + .tz(frappe.boot.time_zone.user); } else { - var date_obj = moment(date); + date_obj = moment(date); } - return (format===false) ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); + return format === false ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); }, convert_to_system_tz: function(date, format) { // format defaults to true - - if(frappe.sys_defaults.time_zone) { - var date_obj = moment(date).tz(frappe.sys_defaults.time_zone); + // Converts the datetime string to user time zone (from User doctype) first since this fn is called in datetime which accepts datetime + // in user time zone then convert the string to user time zone. + // This is done so that only one timezone is present in database and we do not end up storing local timezone since it changes + // as per the location of user. + let date_obj = null; + if (frappe.boot.time_zone && frappe.boot.time_zone.system && frappe.boot.time_zone.user) { + date_obj = moment.tz(date, frappe.boot.time_zone.user) + .clone() + .tz(frappe.boot.time_zone.system); } else { - var date_obj = moment(date); + date_obj = moment(date); } - return (format===false) ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); + return format===false ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); + }, + + is_system_time_zone: function() { + if (frappe.boot.time_zone && frappe.boot.time_zone.system && frappe.boot.time_zone.user) { + return moment().tz(frappe.boot.time_zone.system).utcOffset() === moment().tz(frappe.boot.time_zone.user).utcOffset(); + } + + return true; }, is_timezone_same: function() { - if(frappe.sys_defaults.time_zone) { - return moment().tz(frappe.sys_defaults.time_zone).utcOffset() === moment().utcOffset(); - } else { - return true; - } + return frappe.datetime.is_system_time_zone(); }, str_to_obj: function(d) { @@ -98,11 +113,11 @@ $.extend(frappe.datetime, { return moment().endOf("quarter").format(); }, - year_start: function(){ + year_start: function() { return moment().startOf("year").format(); }, - year_end: function(){ + year_end: function() { return moment().endOf("year").format(); }, @@ -119,19 +134,25 @@ $.extend(frappe.datetime, { }, str_to_user: function(val, only_time = false) { - if(!val) return ""; + if (!val) return ""; + const user_date_fmt = frappe.datetime.get_user_date_fmt().toUpperCase(); + const user_time_fmt = frappe.datetime.get_user_time_fmt(); + let user_format = user_time_fmt; - var user_time_fmt = frappe.datetime.get_user_time_fmt(); - if(only_time) { - return moment(val, frappe.defaultTimeFormat) - .format(user_time_fmt); - } - - var user_date_fmt = frappe.datetime.get_user_date_fmt().toUpperCase(); - if(typeof val !== "string" || val.indexOf(" ")===-1) { - return moment(val).format(user_date_fmt); + if (only_time) { + let date_obj = moment(val, frappe.defaultTimeFormat); + return date_obj.format(user_format); } else { - return moment(val, "YYYY-MM-DD HH:mm:ss").format(user_date_fmt + " " + user_time_fmt); + let date_obj = moment.tz(val, frappe.boot.time_zone.system); + if (typeof val !== "string" || val.indexOf(" ") === -1) { + user_format = user_date_fmt; + } else { + user_format = user_date_fmt + " " + user_time_fmt; + } + return date_obj + .clone() + .tz(frappe.boot.time_zone.user) + .format(user_format); } }, @@ -186,23 +207,22 @@ $.extend(frappe.datetime, { }, _date: function(format, as_obj = false) { - const time_zone = frappe.sys_defaults && frappe.sys_defaults.time_zone; - let date; - if (time_zone) { - date = moment.tz(time_zone); - } else { - date = moment(); - } - if (as_obj) { - return frappe.datetime.moment_to_date_obj(date); - } else { - return date.format(format); - } + /** + * Whenever we are getting now_date/datetime, always make sure dates are fetched using user time zone. + * This is to make sure that time is as per user time zone set in User doctype, If a user had to change the timezone, + * we will end up having multiple timezone by not honouring timezone in User doctype. + * This will make sure that at any point we know which timezone the user if following and not have random timezone + * when the timezone of the local machine changes. + */ + let time_zone = frappe.boot.time_zone ? frappe.boot.time_zone.user || frappe.boot.time_zone.system : frappe.sys_defaults.time_zone; + let date = moment.tz(time_zone); + + return as_obj ? frappe.datetime.moment_to_date_obj(date) : date.format(format); }, - moment_to_date_obj: function(moment) { + moment_to_date_obj: function(moment_obj) { const date_obj = new Date(); - const date_array = moment.toArray(); + const date_array = moment_obj.toArray(); date_obj.setFullYear(date_array[0]); date_obj.setMonth(date_array[1]); date_obj.setDate(date_array[2]); @@ -234,6 +254,11 @@ $.extend(frappe.datetime, { ], true).isValid(); }, + get_first_day_of_the_week_index() { + const first_day_of_the_week = frappe.sys_defaults.first_day_of_the_week || "Sunday"; + return moment.weekdays().indexOf(first_day_of_the_week); + } + }); // Proxy for dateutil and get_today diff --git a/frappe/public/js/frappe/utils/diffview.js b/frappe/public/js/frappe/utils/diffview.js new file mode 100644 index 0000000000..ebface7f05 --- /dev/null +++ b/frappe/public/js/frappe/utils/diffview.js @@ -0,0 +1,100 @@ +frappe.provide("frappe.ui"); + +frappe.ui.DiffView = class DiffView { + constructor(doctype, fieldname, docname) { + this.dialog = null; + this.handler = null; + this.doctype = doctype; + this.fieldname = fieldname; + this.docname = docname; + + this.dialog = this.make_dialog(); + this.set_empty_state(); + this.dialog.show(); + } + + make_dialog() { + const get_query = () => ({ + query: "frappe.utils.diff.version_query", + filters: { docname: this.docname, ref_doctype: this.doctype }, + }); + const onchange = () => this.compute_diff(); + let dialog = new frappe.ui.Dialog({ + title: __("Compare Versions"), + fields: [ + { + label: __("From version"), + fieldtype: "Link", + fieldname: "from_version", + options: "Version", + reqd: 1, + get_query, + onchange, + }, + { + fieldtype: "Column Break", + fieldname: "cb", + }, + { + label: __("To version"), + fieldtype: "Link", + fieldname: "to_version", + options: "Version", + reqd: 1, + get_query, + onchange, + }, + { + fieldtype: "Section Break", + fieldname: "sb", + }, + { + label: __("Diff"), + fieldtype: "HTML", + fieldname: "diff", + }, + ], + size: "large", + }); + return dialog; + } + + compute_diff() { + const from_version = this.dialog.get_value("from_version"); + const to_version = this.dialog.get_value("to_version"); + const fieldname = this.fieldname; + + if (from_version && to_version) { + frappe + .xcall("frappe.utils.diff.get_version_diff", { + from_version, + to_version, + fieldname, + }) + .then((data) => { + this.dialog.set_value("diff", this.prettify_diff(data)); + }); + } else { + this.set_empty_state(); + } + } + + prettify_diff(diff) { + let html = ``; + + diff.forEach((line) => { + let line_class = ""; + if (line.startsWith("+")) { + line_class = "insert"; + } else if (line.startsWith("-")) { + line_class = "delete"; + } + html += `
${line}
`; + }); + return `
${html}
`; + } + + set_empty_state() { + this.dialog.set_value("diff", __("Select two versions to view the diff.")); + } +}; diff --git a/frappe/public/js/frappe/utils/number_format.js b/frappe/public/js/frappe/utils/number_format.js index 32e3669caf..b0d66ccec5 100644 --- a/frappe/public/js/frappe/utils/number_format.js +++ b/frappe/public/js/frappe/utils/number_format.js @@ -129,7 +129,7 @@ function format_currency(v, currency, decimals) { } if (symbol) - return symbol + " " + format_number(v, format, decimals); + return __(symbol) + " " + format_number(v, format, decimals); else return format_number(v, format, decimals); } diff --git a/frappe/public/js/frappe/utils/pretty_date.js b/frappe/public/js/frappe/utils/pretty_date.js index 3ebe2c1ae2..a5279682ce 100644 --- a/frappe/public/js/frappe/utils/pretty_date.js +++ b/frappe/public/js/frappe/utils/pretty_date.js @@ -6,7 +6,7 @@ function prettyDate(date, mini) { date = new Date((date || "").replace(/-/g, "/").replace(/[TZ]/g, " ").replace(/\.[0-9]*/, "")); } - let diff = (((new Date()).getTime() - date.getTime()) / 1000); + let diff = (((new Date(frappe.datetime.now_datetime())).getTime() - date.getTime()) / 1000); let day_diff = Math.floor(diff / 86400); if (isNaN(day_diff) || day_diff < 0) return ''; diff --git a/frappe/public/js/frappe/utils/user.js b/frappe/public/js/frappe/utils/user.js index aeeb83f630..f22611b515 100644 --- a/frappe/public/js/frappe/utils/user.js +++ b/frappe/public/js/frappe/utils/user.js @@ -11,9 +11,7 @@ frappe.user_info = function(uid) { } if(!(frappe.boot.user_info && frappe.boot.user_info[uid])) { - var user_info = { - fullname: frappe.utils.capitalize(uid.split("@")[0]) || "Unknown" - }; + var user_info = {fullname: uid || "Unknown"}; } else { var user_info = frappe.boot.user_info[uid]; } @@ -157,4 +155,4 @@ $(document).bind('mousemove', function() { if(frappe.session_alive_timeout) clearTimeout(frappe.session_alive_timeout); frappe.session_alive_timeout = setTimeout('frappe.session_alive=false;', 30000); -}); \ No newline at end of file +}); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index db21adf886..c7739c82a1 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -28,7 +28,8 @@ Object.defineProperty(Object.prototype, "setDefault", { value: function(key, default_value) { if (!(key in this)) this[key] = default_value; return this[key]; - } + }, + writable: true }); // Pluralize @@ -316,7 +317,7 @@ Object.assign(frappe.utils, { } }, get_scroll_position: function(element, additional_offset) { - let header_offset = $(".navbar").height() + $(".page-head:visible").height(); + let header_offset = $(".navbar").height() + $(".page-head:visible").height() || $(".navbar").height(); let scroll_top = $(element).offset().top - header_offset - cint(additional_offset); return scroll_top; }, @@ -957,17 +958,24 @@ Object.assign(frappe.utils, { return decoded; }, copy_to_clipboard(string) { - let input = $(""); - $("body").append(input); - input.val(string).select(); + const show_success_alert = () => { + frappe.show_alert({ + indicator: 'green', + message: __('Copied to clipboard.') + }); + }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(string).then(show_success_alert); + } else { + let input = $(" +
{{ _("Ctrl+Enter to add comment") }}
+
+ + + +
+
+
+ {% endif %} + +
+
- {% for comment in comment_list %} -
- {% include "templates/includes/comments/comment.html" %} +
+
+
- {% endfor %} -
-
- -{% if not is_communication %} -
- - -
- {{ _("Add Comment") }} -
-{% endif %} + diff --git a/frappe/templates/includes/comments/comments.py b/frappe/templates/includes/comments/comments.py index 3bba388ac2..99afb580d8 100644 --- a/frappe/templates/includes/comments/comments.py +++ b/frappe/templates/includes/comments/comments.py @@ -3,11 +3,14 @@ import frappe import re from frappe.website.utils import clear_cache +from frappe.rate_limiter import rate_limit from frappe.utils import add_to_date, now +from frappe.website.doctype.blog_settings.blog_settings import get_comment_limit from frappe import _ @frappe.whitelist(allow_guest=True) +@rate_limit(key='reference_name', limit=get_comment_limit, seconds=60*60) def add_comment(comment, comment_email, comment_by, reference_doctype, reference_name, route): doc = frappe.get_doc(reference_doctype, reference_name) @@ -25,16 +28,6 @@ def add_comment(comment, comment_email, comment_by, reference_doctype, reference frappe.msgprint(_('Comments cannot have links or email addresses')) return False - comments_count = frappe.db.count("Comment", { - "comment_type": "Comment", - "comment_email": comment_email, - "creation": (">", add_to_date(now(), hours=-1)) - }) - - if comments_count > 20: - frappe.msgprint(_('Hourly comment limit reached for: {0}').format(frappe.bold(comment_email))) - return False - comment = doc.add_comment( text=comment, comment_email=comment_email, @@ -51,14 +44,17 @@ def add_comment(comment, comment_email, comment_by, reference_doctype, reference comment.name, _("View Comment"))) - # notify creator - frappe.sendmail( - recipients=frappe.db.get_value('User', doc.owner, 'email') or doc.owner, - subject=_('New Comment on {0}: {1}').format(doc.doctype, doc.name), - message=content, - reference_doctype=doc.doctype, - reference_name=doc.name - ) + if doc.doctype == "Blog Post" and not doc.enable_email_notification: + pass + else: + # notify creator + frappe.sendmail( + recipients=frappe.db.get_value('User', doc.owner, 'email') or doc.owner, + subject=_('New Comment on {0}: {1}').format(doc.doctype, doc.name), + message=content, + reference_doctype=doc.doctype, + reference_name=doc.name + ) # revert with template if all clear (no backlinks) template = frappe.get_template("templates/includes/comments/comment.html") diff --git a/frappe/templates/includes/feedback/feedback.html b/frappe/templates/includes/feedback/feedback.html index f180fa5e42..7925d4dccd 100644 --- a/frappe/templates/includes/feedback/feedback.html +++ b/frappe/templates/includes/feedback/feedback.html @@ -1,160 +1,43 @@ -
- + \ No newline at end of file diff --git a/frappe/templates/includes/feedback/feedback.py b/frappe/templates/includes/feedback/feedback.py index e1a6f8e881..279ff05e6d 100644 --- a/frappe/templates/includes/feedback/feedback.py +++ b/frappe/templates/includes/feedback/feedback.py @@ -10,27 +10,10 @@ from frappe.website.doctype.blog_settings.blog_settings import get_feedback_limi @frappe.whitelist(allow_guest=True) @rate_limit(key='reference_name', limit=get_feedback_limit, seconds=60*60) -def add_feedback(reference_doctype, reference_name, rating, feedback): - doc = frappe.get_doc(reference_doctype, reference_name) - if doc.disable_feedback == 1: - return - - doc = frappe.new_doc('Feedback') - doc.reference_doctype = reference_doctype - doc.reference_name = reference_name - doc.rating = rating - doc.feedback = feedback - doc.ip_address = frappe.local.request_ip - doc.save(ignore_permissions=True) - - subject = _('New Feedback on {0}: {1}').format(reference_doctype, reference_name) - send_mail(doc, subject) - return doc - -@frappe.whitelist() -def update_feedback(reference_doctype, reference_name, rating, feedback): - doc = frappe.get_doc(reference_doctype, reference_name) - if doc.disable_feedback == 1: +def give_feedback(reference_doctype, reference_name, like): + like = frappe.parse_json(like) + ref_doc = frappe.get_doc(reference_doctype, reference_name) + if ref_doc.disable_feedback == 1: return filters = { @@ -39,22 +22,26 @@ def update_feedback(reference_doctype, reference_name, rating, feedback): "reference_name": reference_name } d = frappe.get_all('Feedback', filters=filters, limit=1) - doc = frappe.get_doc('Feedback', d[0].name) - doc.rating = rating - doc.feedback = feedback + if d: + doc = frappe.get_doc('Feedback', d[0].name) + else: + doc = doc = frappe.new_doc('Feedback') + doc.reference_doctype = reference_doctype + doc.reference_name = reference_name + doc.ip_address = frappe.local.request_ip + doc.like = like doc.save(ignore_permissions=True) - subject = _('Feedback updated on {0}: {1}').format(reference_doctype, reference_name) - send_mail(doc, subject) + subject = _('Feedback on {0}: {1}').format(reference_doctype, reference_name) + ref_doc.enable_email_notification and send_mail(doc, subject) return doc def send_mail(feedback, subject): doc = frappe.get_doc(feedback.reference_doctype, feedback.reference_name) - - message = ("

{0} ({1})

".format(feedback.feedback, feedback.rating) - + "

{2}

".format(frappe.utils.get_request_site_address(), - feedback.name, - _("View Feedback"))) + if feedback.like: + message = "

Hey,

You have received a ❤️ heart on your blog post {0}

".format(feedback.reference_name) + else: + return # notify creator frappe.sendmail( diff --git a/frappe/templates/includes/navbar/navbar.html b/frappe/templates/includes/navbar/navbar.html index 1fb4ae9fb0..41ad55bbd6 100644 --- a/frappe/templates/includes/navbar/navbar.html +++ b/frappe/templates/includes/navbar/navbar.html @@ -15,7 +15,11 @@ aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> - + + + + +