diff --git a/.github/helper/install.sh b/.github/helper/install.sh index f6f0cad31a..93189d2b1f 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -17,6 +17,7 @@ if [ "$TYPE" == "server" ]; then fi if [ "$DB" == "mariadb" ];then + 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'"; @@ -58,4 +59,4 @@ cd ../.. bench start & bench --site test_site reinstall --yes if [ "$TYPE" == "server" ]; then bench --site test_site_producer reinstall --yes; fi -bench build --app frappe \ No newline at end of file +bench build --app frappe diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 0dd4cd51d8..3ac5cfa349 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -9,7 +9,7 @@ concurrency: jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest name: Patch Test diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 57a7fa304d..0b187fc44c 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -13,7 +13,7 @@ concurrency: jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: fail-fast: false @@ -121,9 +121,10 @@ jobs: ORCHESTRATOR_URL: http://test-orchestrator.frappe.io - name: Upload coverage data + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: codecov/codecov-action@v2 with: name: MariaDB fail_ci_if_error: true files: /home/runner/frappe-bench/sites/coverage.xml - verbose: true + verbose: true \ No newline at end of file diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 57ac9c6c60..a5630121a4 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -12,7 +12,7 @@ concurrency: jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: fail-fast: false @@ -124,6 +124,7 @@ jobs: ORCHESTRATOR_URL: http://test-orchestrator.frappe.io - name: Upload coverage data + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: codecov/codecov-action@v2 with: name: Postgres diff --git a/.github/workflows/translation_linter.yml b/.github/workflows/translation_linter.yml deleted file mode 100644 index 4becaebd6b..0000000000 --- a/.github/workflows/translation_linter.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Frappe Linter -on: - pull_request: - branches: - - develop - - version-12-hotfix - - version-11-hotfix -jobs: - check_translation: - name: Translation Syntax Check - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@v2 - - name: Setup python3 - uses: actions/setup-python@v1 - with: - python-version: 3.6 - - name: Validating Translation Syntax - run: | - git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q - files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) - python $GITHUB_WORKSPACE/.github/helper/translation.py $files diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 2a55546ec4..0727b06043 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -12,7 +12,7 @@ concurrency: jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/codecov.yml b/codecov.yml index eb81252b61..41b22001a5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,9 +1,13 @@ codecov: require_ci_to_pass: yes + +coverage: status: project: default: + target: auto threshold: 0.5% + comment: - layout: "diff, flags, files" + layout: "diff" require_changes: true diff --git a/cypress/integration/api.js b/cypress/integration/api.js index 7a5b1611b0..e8c39e6e25 100644 --- a/cypress/integration/api.js +++ b/cypress/integration/api.js @@ -31,8 +31,13 @@ context('API Resources', () => { }); it('Removes the Comments', () => { - cy.get_list('Comment').then(body => body.data.forEach(comment => { - cy.remove_doc('Comment', comment.name); - })); + cy.get_list('Comment').then(body => { + let comment_names = []; + body.data.map(comment => comment_names.push(comment.name)); + comment_names = [...new Set(comment_names)]; // remove duplicates + comment_names.forEach((comment_name) => { + cy.remove_doc('Comment', comment_name); + }); + }); }); }); diff --git a/cypress/integration/control_float.js b/cypress/integration/control_float.js new file mode 100644 index 0000000000..670d1fe73e --- /dev/null +++ b/cypress/integration/control_float.js @@ -0,0 +1,93 @@ +context("Control Float", () => { + before(() => { + cy.login(); + cy.visit("/app/website"); + }); + + function get_dialog_with_float() { + return cy.dialog({ + title: "Float Check", + fields: [ + { + fieldname: "float_number", + fieldtype: "Float", + Label: "Float" + } + ] + }); + } + + it("check value changes", () => { + get_dialog_with_float().as("dialog"); + + let data = get_data(); + data.forEach(x => { + cy.window() + .its("frappe") + .then(frappe => { + frappe.boot.sysdefaults.number_format = x.number_format; + }); + x.values.forEach(d => { + cy.get_field("float_number", "Float").clear(); + cy.fill_field("float_number", d.input, "Float").blur(); + cy.get_field("float_number", "Float").should( + "have.value", + d.blur_expected + ); + + cy.get_field("float_number", "Float").focus(); + cy.get_field("float_number", "Float").blur(); + cy.get_field("float_number", "Float").focus(); + cy.get_field("float_number", "Float").should( + "have.value", + d.focus_expected + ); + }); + }); + }); + + function get_data() { + return [ + { + number_format: "#.###,##", + values: [ + { + input: "364.87,334", + blur_expected: "36.487,334", + focus_expected: "36487.334" + }, + { + input: "36487,334", + blur_expected: "36.487,334", + focus_expected: "36487.334" + }, + { + input: "100", + blur_expected: "100,000", + focus_expected: "100" + } + ] + }, + { + number_format: "#,###.##", + values: [ + { + input: "364,87.334", + blur_expected: "36,487.334", + focus_expected: "36487.334" + }, + { + input: "36487.334", + blur_expected: "36,487.334", + focus_expected: "36487.334" + }, + { + input: "100", + blur_expected: "100.000", + focus_expected: "100" + } + ] + } + ]; + } +}); diff --git a/cypress/integration/datetime_field_form_validation.js b/cypress/integration/datetime_field_form_validation.js index 66fdde6863..ef47a0fbf7 100644 --- a/cypress/integration/datetime_field_form_validation.js +++ b/cypress/integration/datetime_field_form_validation.js @@ -1,19 +1,19 @@ -context('Datetime Field Validation', () => { - before(() => { - cy.login(); - cy.visit('/app/communication'); - cy.window().its('frappe').then(frappe => { - frappe.call("frappe.tests.ui_test_helpers.create_communication_records"); - }); - }); +// TODO: Enable this again +// currently this is flaky possibly because of different timezone in CI - // validating datetime field value when value is set from backend and get validated on form load. - it('datetime field form validation', () => { - cy.visit('/app/communication'); - cy.get('a[title="Test Form Communication 1"]').invoke('attr', 'data-name') - .then((name) => { - cy.visit(`/app/communication/${name}`); - cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red'); - }); - }); -}); \ No newline at end of file +// context('Datetime Field Validation', () => { +// before(() => { +// cy.login(); +// cy.visit('/app/communication'); +// }); + +// it('datetime field form validation', () => { +// // validating datetime field value when value is set from backend and get validated on form load. +// cy.window().its('frappe').then(frappe => { +// return frappe.xcall("frappe.tests.ui_test_helpers.create_communication_record"); +// }).then(doc => { +// cy.visit(`/app/communication/${doc.name}`); +// cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red'); +// }); +// }); +// }); \ No newline at end of file diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index 633d1335ab..298bb20432 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -7,11 +7,11 @@ context('List View', () => { }); }); it('enables "Actions" button', () => { - const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Add Tags', 'Print', 'Delete']; + const actions = ['Approve', 'Reject', 'Edit', 'Export', 'Assign To', 'Apply Assignment Rule', 'Add Tags', 'Print', 'Delete']; cy.go_to_list('ToDo'); cy.get('.list-row-container:contains("Pending") .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').should('have.length', 8).each((el, index) => { + cy.get('.dropdown-menu li:visible .dropdown-item').should('have.length', 9).each((el, index) => { cy.wrap(el).contains(actions[index]); }).then((elements) => { cy.intercept({ diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js new file mode 100644 index 0000000000..a45fba8d32 --- /dev/null +++ b/cypress/integration/multi_select_dialog.js @@ -0,0 +1,58 @@ +context('MultiSelectDialog', () => { + before(() => { + cy.login(); + cy.visit('/app'); + }); + + function open_multi_select_dialog() { + cy.window().its('frappe').then(frappe => { + new frappe.ui.form.MultiSelectDialog({ + doctype: "Assignment Rule", + target: {}, + setters: { + document_type: null, + priority: null + }, + add_filters_group: 1, + allow_child_item_selection: 1, + child_fieldname: "assignment_days", + child_columns: ["day"] + }); + }); + } + + it('multi select dialog api works', () => { + open_multi_select_dialog(); + cy.get_open_dialog().should('contain', 'Select Assignment Rules'); + }); + + it('checks for filters', () => { + ['search_term', 'document_type', 'priority'].forEach(fieldname => { + cy.get_open_dialog().get(`.frappe-control[data-fieldname="${fieldname}"]`).should('exist'); + }); + + // add_filters_group: 1 should add a filter group + cy.get_open_dialog().get(`.frappe-control[data-fieldname="filter_area"]`).should('exist'); + + }); + + it('checks for child item selection', () => { + cy.get_open_dialog() + .get(`.dt-row-header`).should('not.exist'); + + cy.get_open_dialog() + .get(`.frappe-control[data-fieldname="allow_child_item_selection"]`) + .should('exist') + .click(); + + 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'); + + cy.get_open_dialog() + .get(`.dt-row-header`).should('contain', 'Day'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/sidebar.js b/cypress/integration/sidebar.js index e05f1877bf..cd771430c6 100644 --- a/cypress/integration/sidebar.js +++ b/cypress/integration/sidebar.js @@ -6,12 +6,12 @@ context('Sidebar', () => { }); it('Test for checking "Assigned To" counter value, adding filter and adding & removing an assignment', () => { - cy.click_sidebar_button(0); + cy.click_sidebar_button("Assigned To"); //To check if no filter is available in "Assigned To" dropdown cy.get('.empty-state').should('contain', 'No filters found'); - cy.click_sidebar_button(1); + cy.click_sidebar_button("Created By"); //To check if "Created By" dropdown contains filter cy.get('.group-by-item > .dropdown-item').should('contain', 'Me'); @@ -22,7 +22,7 @@ context('Sidebar', () => { cy.get_field('assign_to_me', 'Check').click(); cy.get('.modal-footer > .standard-actions > .btn-primary').click(); cy.visit('/app/doctype'); - cy.click_sidebar_button(0); + cy.click_sidebar_button("Assigned To"); //To check if filter is added in "Assigned To" dropdown after assignment cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').should('contain', '1'); @@ -38,20 +38,19 @@ context('Sidebar', () => { cy.get('.fieldname-select-area > .awesomplete > .form-control').should('have.value', 'Assigned To'); cy.get('.condition').should('have.value', 'like'); cy.get('.filter-field > .form-group > .input-with-feedback').should('have.value', '%Administrator%'); + cy.click_filter_button(); //To remove the applied filter - cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); - cy.click_filter_button(); - cy.get('.filter-selector > .btn').should('contain', 'Filter'); + cy.clear_filters(); //To remove the assignment cy.visit('/app/doctype'); cy.click_listview_row_item(0); cy.get('.assignments > .avatar-group > .avatar > .avatar-frame').click(); cy.get('.remove-btn').click({force: true}); - cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close').click(); + cy.hide_dialog(); cy.visit('/app/doctype'); - cy.click_sidebar_button(0); + cy.click_sidebar_button("Assigned To"); cy.get('.empty-state').should('contain', 'No filters found'); }); }); \ No newline at end of file diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index 7a8f3a159b..6387485220 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -4,11 +4,11 @@ context('Timeline', () => { before(() => { cy.visit('/login'); cy.login(); - cy.visit('/app/todo'); }); it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => { //Adding new ToDo + cy.visit('/app/todo'); cy.click_listview_primary_button('Add ToDo'); cy.findByRole('button', {name: 'Edit in full page'}).click(); cy.get('[data-fieldname="description"] .ql-editor').eq(0).type('Test ToDo', {force: true}); @@ -28,15 +28,15 @@ context('Timeline', () => { cy.get('.timeline-content').should('contain', 'Testing Timeline'); //Editing comment - cy.click_timeline_action_btn(0); + cy.click_timeline_action_btn("Edit"); cy.get('.timeline-content [data-fieldname="comment"] .ql-editor').first().type(' 123'); - cy.click_timeline_action_btn(0); + cy.click_timeline_action_btn("Save"); //To check if the edited comment text is visible in timeline content cy.get('.timeline-content').should('contain', 'Testing Timeline 123'); //Discarding comment - cy.click_timeline_action_btn(0); + cy.click_timeline_action_btn("Edit"); cy.findByRole('button', {name: 'Dismiss'}).click(); //To check if after discarding the timeline content is same as previous @@ -81,7 +81,7 @@ context('Timeline', () => { 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 > .grey-link').eq(7).click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .dropdown-item').contains("Delete").click(); cy.click_modal_primary_button('Yes', {force: true, delay: 700}); //Deleting the custom doctype diff --git a/cypress/support/commands.js b/cypress/support/commands.js index c941652487..47c37a56a0 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -187,7 +187,7 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => { if (fieldtype === 'Select') { cy.get('@input').select(value); } else { - cy.get('@input').type(value, {waitForAnimations: false, force: true}); + cy.get('@input').type(value, {waitForAnimations: false, force: true, delay: 100}); } return cy.get('@input'); }); @@ -252,7 +252,8 @@ Cypress.Commands.add('new_form', doctype => { }); Cypress.Commands.add('go_to_list', doctype => { - cy.visit(`/app/list/${doctype}/list`); + let dt_in_route = doctype.toLowerCase().replace(/ /g, '-'); + cy.visit(`/app/${dt_in_route}`); }); Cypress.Commands.add('clear_cache', () => { @@ -316,7 +317,11 @@ Cypress.Commands.add('add_filter', () => { }); Cypress.Commands.add('clear_filters', () => { - cy.get('.filter-section .filter-button').click(); + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.model.utils.user_settings.save' + }).as('filter-saved'); + cy.get('.filter-section .filter-button').click({force: true}); cy.wait(300); cy.get('.filter-popover').should('exist'); cy.get('.filter-popover').find('.clear-filters').click(); @@ -324,16 +329,15 @@ Cypress.Commands.add('clear_filters', () => { cy.window().its('cur_list').then(cur_list => { cur_list && cur_list.filter_area && cur_list.filter_area.clear(); }); - - + cy.wait('@filter-saved'); }); Cypress.Commands.add('click_modal_primary_button', (btn_name) => { cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).trigger('click', {force: true}); }); -Cypress.Commands.add('click_sidebar_button', (btn_no) => { - cy.get('.list-group-by-fields > .group-by-field > .btn').eq(btn_no).click(); +Cypress.Commands.add('click_sidebar_button', (btn_name) => { + cy.get('.list-group-by-fields .list-link > a').contains(btn_name).click({force: true}); }); Cypress.Commands.add('click_listview_row_item', (row_no) => { @@ -348,6 +352,6 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => { cy.get('.primary-action').contains(btn_name).click({force: true}); }); -Cypress.Commands.add('click_timeline_action_btn', (btn_no) => { - cy.get('.timeline-content > .timeline-message-box > .justify-between > .actions > .btn').eq(btn_no).first().click(); +Cypress.Commands.add('click_timeline_action_btn', (btn_name) => { + cy.get('.timeline-content > .timeline-message-box > .justify-between > .actions > .btn').contains(btn_name).click(); }); \ No newline at end of file diff --git a/frappe/__init__.py b/frappe/__init__.py index 7c6005a350..38904c68d0 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -618,8 +618,6 @@ def read_only(): try: retval = fn(*args, **get_newargs(fn, kwargs)) - except: - raise finally: if local and hasattr(local, 'primary_db'): local.db.close() @@ -629,6 +627,29 @@ def read_only(): return wrapper_fn return innfn +def write_only(): + # if replica connection exists, we have to replace it momentarily with the primary connection + def innfn(fn): + def wrapper_fn(*args, **kwargs): + primary_db = getattr(local, "primary_db", None) + replica_db = getattr(local, "replica_db", None) + in_read_only = getattr(local, "db", None) != primary_db + + # switch to primary connection + if in_read_only and primary_db: + local.db = local.primary_db + + try: + retval = fn(*args, **get_newargs(fn, kwargs)) + finally: + # switch back to replica connection + if in_read_only and replica_db: + local.db = replica_db + + return retval + return wrapper_fn + return innfn + def only_for(roles, message=False): """Raise `frappe.PermissionError` if the user does not have any of the given **Roles**. diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index 5f0619d170..5d0ed18d5f 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -65,7 +65,7 @@ class Address(Document): def has_link(self, doctype, name): for link in self.links: - if link.link_doctype==doctype and link.link_name== name: + if link.link_doctype == doctype and link.link_name == name: return True def has_common_link(self, doc): diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index a1aa8408bf..dfb9ff2973 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -47,14 +47,14 @@ class Contact(Document): def get_link_for(self, link_doctype): '''Return the link name, if exists for the given link DocType''' for link in self.links: - if link.link_doctype==link_doctype: + if link.link_doctype == link_doctype: return link.link_name return None def has_link(self, doctype, name): for link in self.links: - if link.link_doctype==doctype and link.link_name== name: + if link.link_doctype == doctype and link.link_name == name: return True def has_common_link(self, doc): diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py index 0f5776ce2f..d93da02d25 100644 --- a/frappe/core/doctype/access_log/access_log.py +++ b/frappe/core/doctype/access_log/access_log.py @@ -9,6 +9,7 @@ class AccessLog(Document): @frappe.whitelist() +@frappe.write_only() def make_access_log(doctype=None, document=None, method=None, file_type=None, report_name=None, filters=None, page=None, columns=None): diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 2e5254b622..79a90933e7 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -36,8 +36,11 @@ class UserType(Document): if not self.user_doctypes: return - modules = frappe.get_all('DocType', fields=['distinct module as module'], - filters={'name': ('in', [d.document_type for d in self.user_doctypes])}) + modules = frappe.get_all("DocType", + fields=["module"], + filters={"name": ("in", [d.document_type for d in self.user_doctypes])}, + distinct=True, + ) self.set('user_type_modules', []) for row in modules: diff --git a/frappe/core/doctype/version/version.css b/frappe/core/doctype/version/version.css deleted file mode 100644 index 769b352585..0000000000 --- a/frappe/core/doctype/version/version.css +++ /dev/null @@ -1,21 +0,0 @@ -.version-info { - overflow: auto; -} - -.version-info pre { - border: 0px; - margin: 0px; - background-color: inherit; -} - -.version-info .table { - background-color: inherit; -} - -.version-info .success { - background-color: #dff0d8 !important; -} - -.version-info .danger { - background-color: #f2dede !important; -} diff --git a/frappe/data/sample_site_config.json b/frappe/data/sample_site_config.json deleted file mode 100644 index 715cd7b9fa..0000000000 --- a/frappe/data/sample_site_config.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "db_name": "testdb", - "db_password": "password", - "mute_emails": true, - - "limits": { - "emails": 1500, - "space": 0.157, - "expiry": "2016-07-25", - "users": 1 - }, - - "developer_mode": 1, - "auto_cache_clear": true, - "disable_website_cache": true, - "max_file_size": 1000000, - - "mail_server": "localhost", - "mail_login": null, - "mail_password": null, - "mail_port": 25, - "use_ssl": 0, - "auto_email_id": "hello@example.com", - - "google_analytics_id": "google_analytics_id", - "google_analytics_anonymize_ip": 1, - - "google_login": { - "client_id": "google_client_id", - "client_secret": "google_client_secret" - }, - "github_login": { - "client_id": "github_client_id", - "client_secret": "github_client_secret" - }, - "facebook_login": { - "client_id": "facebook_client_id", - "client_secret": "facebook_client_secret" - }, - - "celery_broker": "redis://localhost", - "celery_result_backend": null, - "scheduler_interval": 300, - "celery_queue_per_site": true -} diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index d4a119804b..71acefe17c 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -256,11 +256,11 @@ class MariaDBDatabase(Database): index_name=index_name )) - def add_index(self, doctype, fields, index_name=None): + def add_index(self, doctype: str, fields: List, index_name: str = None): """Creates an index with given fields if not already created. Index name will be `fieldname1_fieldname2_index`""" index_name = index_name or self.get_index_name(fields) - table_name = 'tab' + doctype + table_name = get_table_name(doctype) if not self.has_index(table_name, index_name): self.commit() self.sql("""ALTER TABLE `%s` diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 00e60fb8d2..264d3bbf14 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -258,14 +258,14 @@ class PostgresDatabase(Database): return self.sql("""SELECT 1 FROM pg_indexes WHERE tablename='{table_name}' and indexname='{index_name}' limit 1""".format(table_name=table_name, index_name=index_name)) - def add_index(self, doctype, fields, index_name=None): + def add_index(self, doctype: str, fields: List, index_name: str = None): """Creates an index with given fields if not already created. Index name will be `fieldname1_fieldname2_index`""" + table_name = get_table_name(doctype) index_name = index_name or self.get_index_name(fields) - table_name = 'tab' + doctype + fields_str = '", "'.join(re.sub(r"\(.*\)", "", field) for field in fields) - self.commit() - self.sql("""CREATE INDEX IF NOT EXISTS "{}" ON `{}`("{}")""".format(index_name, table_name, '", "'.join(fields))) + self.sql_ddl(f'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}` ("{fields_str}")') def add_unique(self, doctype, fields, constraint_name=None): if isinstance(fields, str): diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js index 48dd2ba108..2d097f01ad 100644 --- a/frappe/desk/doctype/system_console/system_console.js +++ b/frappe/desk/doctype/system_console/system_console.js @@ -20,5 +20,46 @@ frappe.ui.form.on('System Console', { $btn.text(__('Execute')); }); }); + }, + + show_processlist: function(frm) { + if (frm.doc.show_processlist) { + // keep refreshing every 5 seconds + frm.events.refresh_processlist(frm); + frm.processlist_interval = setInterval(() => frm.events.refresh_processlist(frm), 5000); + } else { + if (frm.processlist_interval) { + + // end it + clearInterval(frm.processlist_interval); + } + } + }, + + refresh_processlist: function(frm) { + let timestamp = new Date(); + frappe.call('frappe.desk.doctype.system_console.system_console.show_processlist').then(r => { + let rows = ''; + for (let row of r.message) { + rows += ` + ${row.Id} + ${row.Time} + ${row.State} + ${row.Info} + ${row.Progress} + ` + } + frm.get_field('processlist').html(` +

Requested on: ${timestamp}

+ + + + ${rows}`); + }); } }); diff --git a/frappe/desk/doctype/system_console/system_console.json b/frappe/desk/doctype/system_console/system_console.json index 14e36e6fd3..753e672cdc 100644 --- a/frappe/desk/doctype/system_console/system_console.json +++ b/frappe/desk/doctype/system_console/system_console.json @@ -17,9 +17,13 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "execute_section", "console", "commit", - "output" + "output", + "database_processes_section", + "show_processlist", + "processlist" ], "fields": [ { @@ -40,13 +44,34 @@ "fieldname": "commit", "fieldtype": "Check", "label": "Commit" + }, + { + "fieldname": "execute_section", + "fieldtype": "Section Break", + "label": "Execute" + }, + { + "fieldname": "database_processes_section", + "fieldtype": "Section Break", + "label": "Database Processes" + }, + { + "default": "0", + "fieldname": "show_processlist", + "fieldtype": "Check", + "label": "Show Processlist" + }, + { + "fieldname": "processlist", + "fieldtype": "HTML", + "label": "processlist" } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-08-21 14:44:35.296877", + "modified": "2021-09-09 13:10:14.237113", "modified_by": "Administrator", "module": "Desk", "name": "System Console", @@ -65,4 +90,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py index f7f31cc3ba..8382dc8638 100644 --- a/frappe/desk/doctype/system_console/system_console.py +++ b/frappe/desk/doctype/system_console/system_console.py @@ -33,4 +33,9 @@ class SystemConsole(Document): def execute_code(doc): console = frappe.get_doc(json.loads(doc)) console.run() - return console.as_dict() \ No newline at end of file + return console.as_dict() + +@frappe.whitelist() +def show_processlist(): + frappe.only_for('System Manager') + return frappe.db.sql('show full processlist', as_dict=1) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index c86efbcefd..d276a9707f 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from typing import Dict, List, Union import frappe, json import frappe.utils import frappe.share @@ -105,9 +106,10 @@ def get_docinfo(doc=None, doctype=None, name=None): "assignment_logs": get_comments(doc.doctype, doc.name, 'assignment'), "permissions": get_doc_permissions(doc), "shared": frappe.share.get_users(doc.doctype, doc.name), - "info_logs": get_comments(doc.doctype, doc.name, 'Info'), + "info_logs": get_comments(doc.doctype, doc.name, comment_type=['Info', 'Edit', 'Label']), "share_logs": get_comments(doc.doctype, doc.name, 'share'), "like_logs": get_comments(doc.doctype, doc.name, 'Like'), + "workflow_logs": get_comments(doc.doctype, doc.name, comment_type="Workflow"), "views": get_view_logs(doc.doctype, doc.name), "energy_point_logs": get_point_logs(doc.doctype, doc.name), "additional_timeline_content": get_additional_timeline_content(doc.doctype, doc.name), @@ -138,10 +140,11 @@ def get_communications(doctype, name, start=0, limit=20): return _get_communications(doctype, name, start, limit) -def get_comments(doctype, name, comment_type='Comment'): - comment_types = [comment_type] +def get_comments(doctype: str, name: str, comment_type : Union[str, List[str]] = "Comment") -> List[frappe._dict]: + if isinstance(comment_type, list): + comment_types = comment_type - if comment_type == 'share': + elif comment_type == 'share': comment_types = ['Shared', 'Unshared'] elif comment_type == 'assignment': @@ -150,15 +153,21 @@ def get_comments(doctype, name, comment_type='Comment'): elif comment_type == 'attachment': comment_types = ['Attachment', 'Attachment Removed'] - comments = frappe.get_all('Comment', fields = ['name', 'creation', 'content', 'owner', 'comment_type'], filters=dict( - reference_doctype = doctype, - reference_name = name, - comment_type = ['in', comment_types] - )) + else: + comment_types = [comment_type] + + comments = frappe.get_all("Comment", + fields=["name", "creation", "content", "owner", "comment_type"], + filters={ + "reference_doctype": doctype, + "reference_name": name, + "comment_type": ['in', comment_types], + } + ) # convert to markdown (legacy ?) - if comment_type == 'Comment': - for c in comments: + for c in comments: + if c.comment_type == "Comment": c.content = frappe.utils.markdown(c.content) return comments diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index 704e5d8ed6..f40c135653 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -69,13 +69,11 @@ def make_tree_args(**kwarg): doctype = kwarg['doctype'] parent_field = 'parent_' + doctype.lower().replace(' ', '_') - name_field = kwarg.get('name_field', doctype.lower().replace(' ', '_') + '_name') if kwarg['is_root'] == 'false': kwarg['is_root'] = False if kwarg['is_root'] == 'true': kwarg['is_root'] = True kwarg.update({ - name_field: kwarg[name_field], parent_field: kwarg.get("parent") or kwarg.get(parent_field) }) diff --git a/frappe/hooks.py b/frappe/hooks.py index f3d25d6bf4..3cfdebc12e 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -164,7 +164,8 @@ doc_events = { "after_rename": "frappe.desk.notifications.clear_doctype_notifications", "on_cancel": [ "frappe.desk.notifications.clear_doctype_notifications", - "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions" + "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions", + "frappe.event_streaming.doctype.event_update_log.event_update_log.notify_consumers" ], "on_trash": [ "frappe.desk.notifications.clear_doctype_notifications", diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index ae159c1a69..fd74a8cfe4 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE """build query for doclistview and return results""" +from typing import List import frappe.defaults import frappe.share from frappe import _ @@ -33,7 +34,7 @@ class DatabaseQuery(object): join='left join', distinct=False, start=None, page_length=None, limit=None, ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False, update=None, add_total_row=None, user_settings=None, reference_doctype=None, - return_query=False, strict=True, pluck=None, ignore_ddl=False): + return_query=False, strict=True, pluck=None, ignore_ddl=False) -> List: if not ignore_permissions and \ not frappe.has_permission(self.doctype, "select", user=user) and \ not frappe.has_permission(self.doctype, "read", user=user): diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index 786f8f97ab..adc5e2363c 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -36,21 +36,23 @@ frappe.ui.form.on("Print Format", { else if (frm.doc.custom_format && !frm.doc.raw_printing) { frm.set_df_property("html", "reqd", 1); } - frappe.db.get_value('DocType', frm.doc.doc_type, 'default_print_format', (r) => { - if (r.default_print_format != frm.doc.name) { - frm.add_custom_button(__("Set as Default"), function () { - frappe.call({ - method: "frappe.printing.doctype.print_format.print_format.make_default", - args: { - name: frm.doc.name - }, - callback: function() { - frm.refresh(); - } + if (frappe.perm.has_perm('DocType', 0, 'read', frm.doc.doc_type)) { + frappe.db.get_value('DocType', frm.doc.doc_type, 'default_print_format', (r) => { + if (r.default_print_format != frm.doc.name) { + frm.add_custom_button(__("Set as Default"), function () { + frappe.call({ + method: "frappe.printing.doctype.print_format.print_format.make_default", + args: { + name: frm.doc.name + }, + callback: function() { + frm.refresh(); + } + }); }); - }); - } - }); + } + }); + } } }, custom_format: function (frm) { diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index ca2a340661..da34dfda96 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -174,7 +174,7 @@ frappe.ui.form.PrintView = class { }); } - if (frappe.user.has_role('System Manager')) { + if (frappe.perm.has_perm('Print Format', 0, 'create')) { this.page.add_menu_item(__('Customize'), () => this.edit_print_format() ); diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index b2f1428967..b878f713e9 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -35,10 +35,13 @@ - + + + + @@ -680,7 +683,7 @@ - + diff --git a/frappe/public/js/frappe/form/controls/float.js b/frappe/public/js/frappe/form/controls/float.js index 89f8f23cc5..e00f74238c 100644 --- a/frappe/public/js/frappe/form/controls/float.js +++ b/frappe/public/js/frappe/form/controls/float.js @@ -1,4 +1,17 @@ frappe.ui.form.ControlFloat = class ControlFloat extends frappe.ui.form.ControlInt { + + make_input() { + super.make_input(); + const change_handler = e => { + if (this.change) this.change(e); + else { + let value = this.get_input_value(); + this.parse_validate_and_set_in_model(value, e); + } + }; + // convert to number format on focusout since focus converts it to flt. + this.$input.on("focusout", change_handler); + } parse(value) { value = this.eval_expression(value); return isNaN(parseFloat(value)) ? null : flt(value, this.get_precision()); diff --git a/frappe/public/js/frappe/form/footer/base_timeline.js b/frappe/public/js/frappe/form/footer/base_timeline.js index ab4ad95a81..702d964442 100644 --- a/frappe/public/js/frappe/form/footer/base_timeline.js +++ b/frappe/public/js/frappe/form/footer/base_timeline.js @@ -86,7 +86,7 @@ class BaseTimeline { }); if (item.icon) { timeline_item.append(` -
+
${frappe.utils.icon(item.icon, item.icon_size || 'md')}
`); diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index 115a62e098..b3feae3ee8 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -136,6 +136,7 @@ class FormTimeline extends BaseTimeline { this.timeline_items.push(...this.get_energy_point_timeline_contents()); this.timeline_items.push(...this.get_version_timeline_contents()); this.timeline_items.push(...this.get_share_timeline_contents()); + this.timeline_items.push(...this.get_workflow_timeline_contents()); this.timeline_items.push(...this.get_like_timeline_contents()); this.timeline_items.push(...this.get_custom_timeline_contents()); this.timeline_items.push(...this.get_assignment_timeline_contents()); @@ -146,7 +147,9 @@ class FormTimeline extends BaseTimeline { } get_user_link(user) { - const user_display_text = (frappe.user_info(user).fullname || '').bold(); + const user_display_text = ( + (frappe.session.user == user ? __("You") : frappe.user_info(user).fullname) || '' + ).bold(); return frappe.utils.get_form_link('User', user, true, user_display_text); } @@ -339,11 +342,26 @@ class FormTimeline extends BaseTimeline { icon_size: 'sm', creation: like_log.creation, content: __('{0} Liked', [this.get_user_link(like_log.owner)]), + title: "Like", }); }); return like_timeline_contents; } + get_workflow_timeline_contents() { + let workflow_timeline_contents = []; + (this.doc_info.workflow_logs || []).forEach(workflow_log => { + workflow_timeline_contents.push({ + icon: 'branch', + icon_size: 'sm', + creation: workflow_log.creation, + content: `${this.get_user_link(workflow_log.owner)} ${__(workflow_log.content)}`, + title: "Workflow", + }); + }); + return workflow_timeline_contents; + } + get_custom_timeline_contents() { let custom_timeline_contents = []; (this.doc_info.additional_timeline_content || []).forEach(custom_item => { diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index dd96b57fb5..ba522a4085 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -2,86 +2,191 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { constructor(opts) { /* Options: doctype, target, setters, get_query, action, add_filters_group, data_fields, primary_action_label */ Object.assign(this, opts); - var me = this; - if (this.doctype != "[Select]") { - frappe.model.with_doctype(this.doctype, function () { - me.make(); - }); + this.for_select = this.doctype == "[Select]"; + if (!this.for_select) { + frappe.model.with_doctype(this.doctype, () => this.init()); } else { - this.make(); + this.init(); } } - make() { - let me = this; + init() { this.page_length = 20; this.start = 0; - let fields = this.get_primary_filters(); + this.fields = this.get_fields(); - // Make results area - fields = fields.concat([ - { fieldtype: "HTML", fieldname: "results_area" }, + this.make(); + } + + get_fields() { + const primary_fields = this.get_primary_filters(); + const result_fields = this.get_result_fields(); + const data_fields = this.get_data_fields(); + const child_selection_fields = this.get_child_selection_fields(); + + return [...primary_fields, ...result_fields, ...data_fields, ...child_selection_fields]; + } + + get_result_fields() { + const show_next_page = () => { + this.start += 20; + this.get_results(); + }; + return [ { - fieldtype: "Button", fieldname: "more_btn", label: __("More"), - click: () => { - this.start += 20; - this.get_results(); - } + fieldtype: "HTML", fieldname: "results_area" + }, + { + fieldtype: "Button", fieldname: "more_btn", + label: __("More"), click: show_next_page.bind(this) } - ]); + ]; + } - // Custom Data Fields - if (this.data_fields) { - fields.push({ fieldtype: "Section Break" }); - fields = fields.concat(this.data_fields); + get_data_fields() { + if (this.data_fields && this.data_fields.length) { + // Custom Data Fields + return [ + { fieldtype: "Section Break" }, + ...this.data_fields + ]; + } else { + return []; } + } + get_child_selection_fields() { + const fields = []; + if (this.allow_child_item_selection && this.child_fieldname) { + fields.push({ fieldtype: "HTML", fieldname: "child_selection_area" }); + } + return fields; + } + + make() { let doctype_plural = this.doctype.plural(); + let title = __("Select {0}", [this.for_select ? __("value") : __(doctype_plural)]); this.dialog = new frappe.ui.Dialog({ - title: __("Select {0}", [(this.doctype == '[Select]') ? __("value") : __(doctype_plural)]), - fields: fields, + title: title, + fields: this.fields, primary_action_label: this.primary_action_label || __("Get Items"), - secondary_action_label: __("Make {0}", [__(me.doctype)]), - primary_action: function () { - let filters_data = me.get_custom_filters(); - me.action(me.get_checked_values(), cur_dialog.get_values(), me.args, filters_data); + secondary_action_label: __("Make {0}", [__(this.doctype)]), + primary_action: () => { + let filters_data = this.get_custom_filters(); + const data_values = cur_dialog.get_values(); // to pass values of data fields + const filtered_children = this.get_selected_child_names(); + const selected_documents = [...this.get_checked_values(), ...this.get_parent_name_of_selected_children()]; + this.action(selected_documents, { + ...this.args, + ...data_values, + ...filters_data, + filtered_children + }); }, - secondary_action: function (e) { - // If user wants to close the modal - if (e) { - frappe.route_options = {}; - if (Array.isArray(me.setters)) { - for (let df of me.setters) { - frappe.route_options[df.fieldname] = me.dialog.fields_dict[df.fieldname].get_value() || undefined; - } - } else { - Object.keys(me.setters).forEach(function (setter) { - frappe.route_options[setter] = me.dialog.fields_dict[setter].get_value() || undefined; - }); - } - - frappe.new_doc(me.doctype, true); - } - } + secondary_action: this.make_new_document.bind(this) }); if (this.add_filters_group) { this.make_filter_area(); } + this.args = {}; + + this.setup_results(); + this.bind_events(); + this.get_results(); + this.dialog.show(); + } + + make_new_document(e) { + // If user wants to close the modal + if (e) { + this.set_route_options(); + frappe.new_doc(this.doctype, true); + } + } + + set_route_options() { + // set route options to get pre-filled form fields + frappe.route_options = {}; + if (Array.isArray(this.setters)) { + for (let df of this.setters) { + frappe.route_options[df.fieldname] = this.dialog.fields_dict[df.fieldname].get_value() || undefined; + } + } else { + Object.keys(this.setters).forEach(setter => { + frappe.route_options[setter] = this.dialog.fields_dict[setter].get_value() || undefined; + }); + } + } + + 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()); + } - this.args = {}; + 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(); + }); + } else { + this.child_results = []; + this.get_results(); + this.$wrapper.removeClass('hidden'); + this.$child_wrapper.addClass('hidden'); + } + } - this.bind_events(); - this.get_results(); - this.dialog.show(); + render_child_datatable() { + if (!this.child_datatable) { + this.setup_child_datatable(); + } else { + setTimeout(() => { + this.child_datatable.rowmanager.checkMap = []; + this.child_datatable.refresh(this.get_child_datatable_rows()); + this.$child_wrapper.find('.dt-scrollable').css('height', '300px'); + }, 500); + } + } + + get_child_datatable_columns() { + const parent = this.doctype; + return [parent, ...this.child_columns].map(d => ({ name: frappe.unscrub(d), editable: false })); + } + + get_child_datatable_rows() { + return this.child_results.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_datatable = new frappe.DataTable(this.$child_wrapper.get(0), { + columns: header_columns, + data: rows, + layout: 'fluid', + inlineFilters: true, + serialNoColumn: false, + checkboxColumn: true, + cellHeight: 35, + noDataMessage: __('No Data'), + disableReorderColumn: true + }); + this.$child_wrapper.find('.dt-scrollable').css('height', '300px'); } get_primary_filters() { @@ -94,7 +199,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { columns[0] = [ { fieldtype: "Data", - label: __("Search"), + label: __("Name"), fieldname: "search_term" } ]; @@ -127,6 +232,16 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { // now a is a fixed-size array with mutable entries } + if (this.allow_child_item_selection) { + this.child_doctype = frappe.meta.get_docfield(this.doctype, this.child_fieldname).options; + columns[0].push({ + fieldtype: "Check", + label: __("Select {0}", [this.child_doctype]), + fieldname: "allow_child_item_selection", + onchange: this.toggle_child_selection.bind(this) + }); + } + fields = [ ...columns[0], { fieldtype: "Column Break" }, @@ -156,6 +271,9 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.get_results(); } }); + // 'Apply Filter' breaks since the filers are not in a popover + // Hence keeping it hidden + this.filter_group.wrapper.find('.apply-filters').hide(); } get_custom_filters() { @@ -166,7 +284,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { }); }, {}); } else { - return []; + return {}; } } @@ -200,6 +318,34 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { }); } + get_parent_name_of_selected_children() { + if (!this.child_datatable || !this.child_datatable.datamanager.rows.length) return []; + + 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); + } + return parent_names; + }, []); + + return parent_names; + } + + get_selected_child_names() { + if (!this.child_datatable || !this.child_datatable.datamanager.rows.length) return []; + + let checked_names = this.child_datatable.rowmanager.checkMap.reduce((checked_names, checked, index) => { + if (checked == 1) { + const child_row_name = this.child_results[index].name; + checked_names.push(child_row_name); + } + return checked_names; + }, []); + + return checked_names; + } + get_checked_values() { // Return name of checked value. return this.$results.find('.list-item-container').map(function () { @@ -276,6 +422,8 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { me.$results.append(me.make_list_row(result)); }); + this.$results.find(".list-item--head").css("z-index", 0); + if (frappe.flags.auto_scroll) { this.$results.animate({ scrollTop: me.$results.prop('scrollHeight') }, 500); } @@ -297,7 +445,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.render_result_list(checked, 0, false); } - get_results() { + get_filters_from_setters() { let me = this; let filters = this.get_query ? this.get_query().filters : {} || {}; let filter_fields = []; @@ -321,12 +469,18 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { }); } - let filter_group = this.get_custom_filters(); - Object.assign(filters, filter_group); + return [filters, filter_fields]; + } - let args = { - doctype: me.doctype, - txt: me.dialog.fields_dict["search_term"].get_value(), + get_args_for_search() { + let [filters, filter_fields] = this.get_filters_from_setters(); + + let custom_filters = this.get_custom_filters(); + Object.assign(filters, custom_filters); + + return { + doctype: this.doctype, + txt: this.dialog.fields_dict["search_term"].get_value(), filters: filters, filter_fields: filter_fields, start: this.start, @@ -334,25 +488,81 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { query: this.get_query ? this.get_query().query : '', as_dict: 1 }; - frappe.call({ + } + + async perform_search(args) { + const res = await frappe.call({ type: "GET", method: 'frappe.desk.search.search_widget', no_spinner: true, args: args, - callback: function (r) { - let more = 0; - me.results = []; - if (r.values.length) { - if (r.values.length > me.page_length) { - r.values.pop(); - more = 1; - } - r.values.forEach(function (result) { - result.checked = 0; - me.results.push(result); - }); + }); + const more = res.values.length && res.values.length > this.page_length ? 1 : 0; + if (more) { + res.values.pop(); + } + + return [res, more]; + } + + async get_results() { + const args = this.get_args_for_search(); + const [res, more] = await this.perform_search(args); + + this.results = []; + if (res.values.length) { + res.values.forEach(result => { + result.checked = 0; + this.results.push(result); + }); + } + this.render_result_list(this.results, more); + } + + async get_filtered_parents_for_child_search() { + const parent_search_args = this.get_args_for_search(); + parent_search_args.filter_fields = ['name']; + // eslint-disable-next-line no-unused-vars + const [response, _] = await this.perform_search(parent_search_args); + + let parent_names = []; + if (response.values.length) { + parent_names = response.values.map(v => v.name); + } + return parent_names; + } + + async add_parent_filters(filters) { + const parent_names = await this.get_filtered_parents_for_child_search(); + if (parent_names.length) { + filters.push([ "parent", "in", parent_names ]); + } + } + + add_custom_child_filters(filters) { + if (this.add_filters_group && this.filter_group) { + this.filter_group.get_filters().forEach(filter => { + if (filter[0] == this.child_doctype) { + filters.push([filter[1], filter[2], filter[3]]); } - me.render_result_list(me.results, more); + }); + } + } + + async get_child_result() { + let filters = [["parentfield", "=", this.child_fieldname]]; + + await this.add_parent_filters(filters); + this.add_custom_child_filters(filters); + + return frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: this.child_doctype, + filters: filters, + fields: ['name', 'parent', ...this.child_columns], + parent: this.doctype, + order_by: 'parent' } }); } diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index beacb136e6..3c7f8ac39a 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -3,9 +3,15 @@ frappe.provide("frappe.views"); frappe.views.BaseList = class BaseList { constructor(opts) { Object.assign(this, opts); + this.init_page() } show() { + this.meta = frappe.get_meta(this.doctype); + this.set_title(); + // in loading state? + if (!this.meta) return; + frappe.run_serially([ () => this.init(), () => this.before_refresh(), @@ -34,8 +40,6 @@ frappe.views.BaseList = class BaseList { setup_defaults() { this.page_name = frappe.get_route_str(); - this.page_title = this.page_title || frappe.router.doctype_layout || __(this.doctype); - this.meta = frappe.get_meta(this.doctype); this.settings = frappe.listview_settings[this.doctype] || {}; this.user_settings = frappe.get_user_settings(this.doctype); @@ -150,13 +154,21 @@ frappe.views.BaseList = class BaseList { } } - setup_page() { + init_page() { this.page = this.parent.page; + this.make_skeleton(); this.$page = $(this.parent); !this.hide_card_layout && this.page.main.addClass('frappe-card'); this.page.page_form.removeClass("row").addClass("flex"); this.hide_page_form && this.page.page_form.hide(); this.hide_sidebar && this.$page.addClass('no-list-sidebar'); + } + + make_skeleton() { + this.skeleton = $(`
`).prependTo(this.page.main.parent()); + } + + setup_page() { this.setup_page_head(); } @@ -167,6 +179,7 @@ frappe.views.BaseList = class BaseList { } set_title() { + this.page_title = this.page_title || frappe.router.doctype_layout || __(this.doctype); this.page.set_title(this.page_title); } @@ -280,6 +293,7 @@ frappe.views.BaseList = class BaseList { } setup_list_wrapper() { + this.skeleton.remove(); // clear skeleton this.$frappe_list = $('
').appendTo( this.page.main ); diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index 3b99560411..931f2cf587 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -4,7 +4,7 @@ export default class BulkOperations { this.doctype = doctype; } - print(docs) { + print (docs) { const print_settings = frappe.model.get_doc(':Print Settings', 'Print Settings'); const allow_print_for_draft = cint(print_settings.allow_print_for_draft); const is_submittable = frappe.model.is_submittable(this.doctype); @@ -27,31 +27,38 @@ export default class BulkOperations { if (valid_docs.length > 0) { const dialog = new frappe.ui.Dialog({ title: __('Print Documents'), - fields: [{ - 'fieldtype': 'Check', - 'label': __('With Letterhead'), - 'fieldname': 'with_letterhead' - }, - { - 'fieldtype': 'Select', - 'label': __('Print Format'), - 'fieldname': 'print_sel', - options: frappe.meta.get_print_formats(this.doctype) - }] + 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.with_letterhead ? 1 : 0; + 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')); + '&no_letterhead=' + (with_letterhead ? '0' : '1') + + '&letterhead=' + encodeURIComponent(letterhead) + ); + if (!w) { frappe.msgprint(__('Please enable pop-ups')); return; @@ -64,7 +71,28 @@ export default class BulkOperations { } } - delete(docnames, done = null) { + get_letterhead_options () { + const letterhead_options = [__("No Letterhead")]; + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: 'Letter Head', + fields: ['name', 'is_default'], + limit: 0 + }, + async: false, + callback (r) { + if (r.message) { + r.message.forEach(letterhead => { + letterhead_options.push(letterhead.name); + }); + } + } + }); + return letterhead_options; + } + + delete (docnames, done = null) { frappe .call({ method: 'frappe.desk.reportview.delete_items', @@ -88,7 +116,7 @@ export default class BulkOperations { }); } - assign(docnames, done) { + assign (docnames, done) { if (docnames.length > 0) { const assign_to = new frappe.ui.form.AssignToDialog({ obj: this, @@ -106,7 +134,7 @@ export default class BulkOperations { } } - apply_assignment_rule(docnames, done) { + apply_assignment_rule (docnames, done) { if (docnames.length > 0) { frappe.call('frappe.automation.doctype.assignment_rule.assignment_rule.bulk_apply', { doctype: this.doctype, @@ -115,7 +143,7 @@ export default class BulkOperations { } } - submit_or_cancel(docnames, action='submit', done=null) { + submit_or_cancel (docnames, action = 'submit', done = null) { action = action.toLowerCase(); frappe .call({ @@ -140,7 +168,7 @@ export default class BulkOperations { }); } - edit(docnames, field_mappings, done) { + edit (docnames, field_mappings, done) { let field_options = Object.keys(field_mappings).sort(); const status_regex = /status/i; @@ -198,16 +226,16 @@ export default class BulkOperations { if (default_field) set_value_field(dialog); // to set `Value` df based on default `Field` - function set_value_field(dialogObj) { + function set_value_field (dialogObj) { const new_df = Object.assign({}, field_mappings[dialogObj.get_value('field')]); /* if the field label has status in it and if it has select fieldtype with no default value then set a default value from the available option. */ - if(new_df.label.match(status_regex) && + if (new_df.label.match(status_regex) && new_df.fieldtype === 'Select' && !new_df.default) { let options = []; - if(typeof new_df.options==="string") { + if (typeof new_df.options === "string") { options = new_df.options.split("\n"); } //set second option as default if first option is an empty string @@ -223,8 +251,7 @@ export default class BulkOperations { dialog.show(); } - - add_tags(docnames, done) { + add_tags (docnames, done) { const dialog = new frappe.ui.Dialog({ title: __('Add Tags'), fields: [ @@ -233,7 +260,7 @@ export default class BulkOperations { fieldname: 'tags', label: __("Tags"), reqd: true, - get_data: function(txt) { + get_data: function (txt) { return frappe.db.get_link_options("Tag", txt); } }, @@ -262,7 +289,7 @@ export default class BulkOperations { dialog.show(); } - export(doctype, docnames) { + export (doctype, docnames) { frappe.require('data_import_tools.bundle.js', () => { const data_exporter = new frappe.data_import.DataExporter(doctype, 'Insert New Records'); data_exporter.dialog.set_value('export_records', 'by_filter'); diff --git a/frappe/public/js/frappe/list/list_factory.js b/frappe/public/js/frappe/list/list_factory.js index b467919d7e..d870fdb6fc 100644 --- a/frappe/public/js/frappe/list/list_factory.js +++ b/frappe/public/js/frappe/list/list_factory.js @@ -8,38 +8,50 @@ frappe.views.ListFactory = class ListFactory extends frappe.views.Factory { make (route) { var me = this; var doctype = route[1]; + const meta_loaded = frappe.get_meta(doctype) ? true : false; + const page_name = frappe.get_route_str(); + let view_class = this.get_view_class(route, doctype); + this.make_list_view_page(page_name, doctype, view_class); + + if (view_class && view_class.load_last_view && view_class.load_last_view()) { + // view can have custom routing logic + return; + } frappe.model.with_doctype(doctype, function () { + if (!meta_loaded) { + frappe.views.list_view[page_name].show(); + } if (locals['DocType'][doctype].issingle) { frappe.set_re_route('Form', doctype); } else { - // List / Gantt / Kanban / etc - // File is a special view - const view_name = doctype !== 'File' ? frappe.utils.to_title_case(route[2] || 'List') : 'File'; - let view_class = frappe.views[view_name + 'View']; - if (!view_class) view_class = frappe.views.ListView; - - if (view_class && view_class.load_last_view && view_class.load_last_view()) { - // view can have custom routing logic - return; - } - - frappe.provide('frappe.views.list_view.' + doctype); - const page_name = frappe.get_route_str(); - - if (!frappe.views.list_view[page_name]) { - frappe.views.list_view[page_name] = new view_class({ - doctype: doctype, - parent: me.make_page(true, page_name) - }); - } else { - frappe.container.change_to(page_name); - } + frappe.container.change_to(page_name); me.set_cur_list(); } }); } + get_view_class(route, doctype) { + // List / Gantt / Kanban / etc + // File is a special view + const view_name = doctype !== 'File' ? frappe.utils.to_title_case(route[2] || 'List') : 'File'; + let view_class = frappe.views[view_name + 'View']; + if (!view_class) view_class = frappe.views.ListView; + + return view_class; + } + + make_list_view_page(page_name, doctype, view_class) { + frappe.provide('frappe.views.list_view.' + doctype); + + if (!frappe.views.list_view[page_name]) { + frappe.views.list_view[page_name] = new view_class({ + doctype: doctype, + parent: this.make_page(true, page_name) + }); + } + } + show() { if (this.re_route_to_view()) { return; diff --git a/frappe/public/js/frappe/ui/dialog.js b/frappe/public/js/frappe/ui/dialog.js index 00336a2137..58175381cf 100644 --- a/frappe/public/js/frappe/ui/dialog.js +++ b/frappe/public/js/frappe/ui/dialog.js @@ -153,7 +153,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { set_secondary_action(click) { this.footer.removeClass('hide'); - this.get_secondary_btn().removeClass('hide').on('click', click); + this.get_secondary_btn().removeClass('hide').off('click').on('click', click); } set_secondary_action_label(label) { diff --git a/frappe/public/js/frappe/ui/messages.js b/frappe/public/js/frappe/ui/messages.js index 067fed233c..185d275ac3 100644 --- a/frappe/public/js/frappe/ui/messages.js +++ b/frappe/public/js/frappe/ui/messages.js @@ -140,7 +140,7 @@ frappe.msgprint = function(msg, title, is_minimizable) { return; } - if(data.alert) { + if(data.alert || data.toast) { frappe.show_alert(data); return; } @@ -361,7 +361,7 @@ frappe.hide_progress = function() { } // Floating Message -frappe.show_alert = function(message, seconds=7, actions={}) { +frappe.show_alert = frappe.toast = function(message, seconds=7, actions={}) { let indicator_icon_map = { 'orange': "solid-warning", 'yellow': "solid-warning", diff --git a/frappe/public/js/frappe/utils/number_format.js b/frappe/public/js/frappe/utils/number_format.js index 1c39f42ec5..32e3669caf 100644 --- a/frappe/public/js/frappe/utils/number_format.js +++ b/frappe/public/js/frappe/utils/number_format.js @@ -8,7 +8,12 @@ if (!window.frappe) window.frappe = {}; function flt(v, decimals, number_format) { if (v == null || v == '') return 0; - if (typeof v !== "number") { + if (!(typeof v === "number" || String(parseFloat(v)) == v)) { + // cases in which this block should not run + // 1. 'v' is already a number + // 2. v is already parsed but in string form + // if (typeof v !== "number") { + v = v + ""; // strip currency symbol if exists @@ -25,6 +30,7 @@ function flt(v, decimals, number_format) { v = 0; } + v = parseFloat(v); if (decimals != null) return _round(v, decimals); return v; diff --git a/frappe/public/js/frappe/views/pageview.js b/frappe/public/js/frappe/views/pageview.js index 705d13b7f0..c8944e272a 100644 --- a/frappe/public/js/frappe/views/pageview.js +++ b/frappe/public/js/frappe/views/pageview.js @@ -148,4 +148,4 @@ frappe.show_message_page = function(opts) { ); frappe.container.change_to(opts.page_name); -}; \ No newline at end of file +}; diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 1053f9b7c5..1478f24b13 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -832,6 +832,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { if (this.raw_data.add_total_row) { data = data.slice(); data.splice(-1, 1); + this.$page.find('.layout-main-section').css('--report-total-height', '310px'); } this.$report.show(); @@ -854,10 +855,6 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { } }; - if (this.raw_data.add_total_row) { - this.$page.find('.layout-main-section').css('--report-total-height', '310px'); - } - if (this.report_settings.get_datatable_options) { datatable_options = this.report_settings.get_datatable_options(datatable_options); } diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index 1257d9b3a4..0de7103cc3 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -739,6 +739,10 @@ body { animation-duration: 400ms; } +.skeleton-bg { + background-color: var(--skeleton-bg); +} + .workspace-skeleton { transition: ease; .widget-group-title { diff --git a/frappe/public/scss/desk/global.scss b/frappe/public/scss/desk/global.scss index 333ee30e4d..ec7fc35cfe 100644 --- a/frappe/public/scss/desk/global.scss +++ b/frappe/public/scss/desk/global.scss @@ -327,10 +327,6 @@ select.input-xs { } } -// .frappe-card { -// @include card(); -// } - .head-title { font-size: var(--text-lg); font-weight: 700; @@ -591,4 +587,4 @@ details > summary:focus { .chart-container { direction: ltr; } -*/ \ No newline at end of file +*/ diff --git a/frappe/public/scss/desk/index.scss b/frappe/public/scss/desk/index.scss index c5b8271a36..1d1124bd58 100644 --- a/frappe/public/scss/desk/index.scss +++ b/frappe/public/scss/desk/index.scss @@ -47,3 +47,4 @@ @import "link_preview"; @import "../common/quill"; @import "plyr"; +@import "version"; diff --git a/frappe/public/scss/desk/version.scss b/frappe/public/scss/desk/version.scss new file mode 100644 index 0000000000..ddcf1f07a5 --- /dev/null +++ b/frappe/public/scss/desk/version.scss @@ -0,0 +1,33 @@ +.version-info { + overflow: auto; + + pre { + border: 0px; + margin: 0px; + background-color: inherit; + } + + .table { + background-color: inherit; + } + + .success { + background-color: var(--green-100) !important; + } + + .danger { + background-color: var(--red-100) !important; + } +} + +[data-theme="dark"] { + .version-info { + .danger, .success { + color: var(--gray-900); + + td { + color: var(--gray-900); + } + } + } +} \ No newline at end of file diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss index 823ec9b08a..eb6e83e7fe 100644 --- a/frappe/public/scss/website/index.scss +++ b/frappe/public/scss/website/index.scss @@ -8,6 +8,7 @@ @import "../common/flex"; @import "../common/buttons"; @import "../common/modal"; +@import "../desk/toast"; @import "../common/indicator"; @import "../common/controls"; @import "../common/awesomeplete"; diff --git a/frappe/templates/print_formats/standard_macros.html b/frappe/templates/print_formats/standard_macros.html index f8dc6c370c..55c76a00c2 100644 --- a/frappe/templates/print_formats/standard_macros.html +++ b/frappe/templates/print_formats/standard_macros.html @@ -135,11 +135,13 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}" {% elif df.fieldtype=="Check" and doc[df.fieldname] %} + {% elif df.fieldtype=="Check" and not doc[df.fieldname] %} + {% elif df.fieldtype in ("Image", "Attach Image") and frappe.utils.is_image(doc[doc.meta.get_field(df.fieldname).options]) %} = 3 %}{{ "" }} {%- elif df.align -%}{{ "text-" + df.align }} - {%- elif df.fieldtype in ("Int", "Float", "Currency", "Check", "Percent") -%}{{ "text-right" }} + {%- elif df.fieldtype in ("Int", "Float", "Currency", "Percent") -%}{{ "text-right" }} + {%- elif df.fieldtype in ("Check") -%}{{ "text-center" }} {%- else -%}{{ "" }} {%- endif -%} {% endmacro %} diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 1797698a11..ee184843ad 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -63,6 +63,28 @@ def clean(value): return value +def missing_in_backup(doctypes, file): + """Returns list of missing doctypes in the backup. + + Args: + doctypes (list): List of DocTypes to be checked + file (str): Path of the database file + + Returns: + doctypes(list): doctypes that are missing in backup + """ + predicate = ( + 'COPY public."tab{}"' + if frappe.conf.db_type == "postgres" + else "CREATE TABLE `tab{}`" + ) + with gzip.open(file, "rb") as f: + content = f.read().decode("utf8").lower() + + return [doctype for doctype in doctypes + if predicate.format(doctype).lower() not in content] + + def exists_in_backup(doctypes, file): """Checks if the list of doctypes exist in the database.sql.gz file supplied @@ -73,14 +95,8 @@ def exists_in_backup(doctypes, file): Returns: bool: True if all tables exist """ - predicate = ( - 'COPY public."tab{}"' - if frappe.conf.db_type == "postgres" - else "CREATE TABLE `tab{}`" - ) - with gzip.open(file, "rb") as f: - content = f.read().decode("utf8") - return all(predicate.format(doctype).lower() in content.lower() for doctype in doctypes) + missing_doctypes = missing_in_backup(doctypes, file) + return len(missing_doctypes) == 0 class BaseTestCommands(unittest.TestCase): @@ -222,7 +238,7 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} backup --verbose") self.assertEqual(self.returncode, 0) database = fetch_latest_backups(partial=True)["database"] - self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + self.assertEqual([], missing_in_backup(backup["includes"]["includes"], database)) # test 8: take a backup with frappe.conf.backup.excludes self.execute( @@ -233,7 +249,7 @@ class TestCommands(BaseTestCommands): self.assertEqual(self.returncode, 0) database = fetch_latest_backups(partial=True)["database"] self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) - self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + self.assertEqual([], missing_in_backup(backup["includes"]["includes"], database)) # test 9: take a backup with --include (with frappe.conf.excludes still set) self.execute( @@ -242,7 +258,7 @@ class TestCommands(BaseTestCommands): ) self.assertEqual(self.returncode, 0) database = fetch_latest_backups(partial=True)["database"] - self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + self.assertEqual([], missing_in_backup(backup["includes"]["includes"], database)) # test 10: take a backup with --exclude self.execute( @@ -257,7 +273,7 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} backup --ignore-backup-conf") self.assertEqual(self.returncode, 0) database = fetch_latest_backups()["database"] - self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database)) + self.assertEqual([], missing_in_backup(backup["excludes"]["excludes"], database)) def test_restore(self): # step 0: create a site to run the test on diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index e153220a1d..72bec78db7 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -197,6 +197,7 @@ class TestDB(unittest.TestCase): frappe.delete_doc(test_doctype, doc) clear_custom_fields(test_doctype) + @run_only_if(db_type_is.MARIADB) class TestDDLCommandsMaria(unittest.TestCase): test_table_name = "TestNotes" @@ -205,7 +206,7 @@ class TestDDLCommandsMaria(unittest.TestCase): frappe.db.commit() frappe.db.sql( f""" - CREATE TABLE `tab{self.test_table_name}` (`id` INT NULL,PRIMARY KEY (`id`)); + CREATE TABLE `tab{self.test_table_name}` (`id` INT NULL, content TEXT, PRIMARY KEY (`id`)); """ ) @@ -230,7 +231,10 @@ class TestDDLCommandsMaria(unittest.TestCase): def test_describe(self) -> None: self.assertEqual( - (("id", "int(11)", "NO", "PRI", None, ""),), + ( + ("id", "int(11)", "NO", "PRI", None, ""), + ("content", "text", "YES", "", None, ""), + ), frappe.db.describe(self.test_table_name), ) @@ -240,6 +244,17 @@ class TestDDLCommandsMaria(unittest.TestCase): self.assertGreater(len(test_table_description), 0) self.assertIn("varchar(255)", test_table_description[0]) + def test_add_index(self) -> None: + index_name = "test_index" + frappe.db.add_index(self.test_table_name, ["id", "content(50)"], index_name) + indexs_in_table = frappe.db.sql( + f""" + SHOW INDEX FROM tab{self.test_table_name} + WHERE Key_name = '{index_name}'; + """ + ) + self.assertEquals(len(indexs_in_table), 2) + @run_only_if(db_type_is.POSTGRES) class TestDDLCommandsPost(unittest.TestCase): @@ -248,7 +263,7 @@ class TestDDLCommandsPost(unittest.TestCase): def setUp(self) -> None: frappe.db.sql( f""" - CREATE TABLE "tab{self.test_table_name}" ("id" INT NULL,PRIMARY KEY ("id")) + CREATE TABLE "tab{self.test_table_name}" ("id" INT NULL, content text, PRIMARY KEY ("id")) """ ) @@ -273,7 +288,9 @@ class TestDDLCommandsPost(unittest.TestCase): self.test_table_name = new_table_name def test_describe(self) -> None: - self.assertEqual([("id",)], frappe.db.describe(self.test_table_name)) + self.assertEqual( + [("id",), ("content",)], frappe.db.describe(self.test_table_name) + ) def test_change_type(self) -> None: frappe.db.change_column_type(self.test_table_name, "id", "varchar(255)") @@ -292,3 +309,15 @@ class TestDDLCommandsPost(unittest.TestCase): self.assertGreater(len(check_change), 0) self.assertIn("character varying", check_change[0]) + def test_add_index(self) -> None: + index_name = "test_index" + frappe.db.add_index(self.test_table_name, ["id", "content(50)"], index_name) + indexs_in_table = frappe.db.sql( + f""" + SELECT indexname + FROM pg_indexes + WHERE tablename = 'tab{self.test_table_name}' + AND indexname = '{index_name}' ; + """, + ) + self.assertEquals(len(indexs_in_table), 1) \ No newline at end of file diff --git a/frappe/tests/test_translate.py b/frappe/tests/test_translate.py index 9620978c4f..949e4f9d77 100644 --- a/frappe/tests/test_translate.py +++ b/frappe/tests/test_translate.py @@ -63,11 +63,12 @@ class TestTranslate(unittest.TestCase): Case 2: frappe.form_dict._lang is not set, but preferred_language cookie is """ - with patch.object(frappe.translate, "get_preferred_language_cookie", return_value=second_lang): - set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)]) + with patch.object(frappe.translate, "get_preferred_language_cookie", return_value='fr'): + set_request(method="POST", path="/", headers=[("Accept-Language", 'hr')]) return_val = get_language() - - self.assertNotIn(return_val, [second_lang, get_parent_language(second_lang)]) + # system default language + self.assertEqual(return_val, 'en') + self.assertNotIn(return_val, [second_lang, get_parent_language(second_lang)]) def test_guest_request_language_resolution_with_cookie(self): """Test for frappe.translate.get_language diff --git a/frappe/tests/test_webform.py b/frappe/tests/test_webform.py new file mode 100644 index 0000000000..92dc441bdf --- /dev/null +++ b/frappe/tests/test_webform.py @@ -0,0 +1,67 @@ +import unittest + +import frappe +from frappe.www.list import get_list_context + + +class TestWebsite(unittest.TestCase): + def test_get_context_hook_of_webform(self): + create_custom_doctype() + create_webform() + + # check context for apps without any hook + context_list = get_list_context("", "Custom Doctype", "test-webform") + self.assertFalse(context_list) + + # create a hook to get webform_context + set_webform_hook( + "webform_list_context", + "frappe.www._test._test_webform.webform_list_context", + ) + # check context for apps with hook + context_list = get_list_context("", "Custom Doctype", "test-webform") + self.assertTrue(context_list) + + +def create_custom_doctype(): + frappe.get_doc( + { + "doctype": "DocType", + "name": "Custom Doctype", + "module": "Core", + "custom": 1, + "fields": [{"label": "Title", "fieldname": "title", "fieldtype": "Data"}], + } + ).insert(ignore_if_duplicate=True) + + +def create_webform(): + frappe.get_doc( + { + "doctype": "Web Form", + "module": "Core", + "title": "Test Webform", + "route": "test-webform", + "doc_type": "Custom Doctype", + "web_form_fields": [ + { + "doctype": "Web Form Field", + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + } + ], + } + ).insert(ignore_if_duplicate=True) + + +def set_webform_hook(key, value): + from frappe import hooks + + # reset hooks + for hook in "webform_list_context": + if hasattr(hooks, hook): + delattr(hooks, hook) + + setattr(hooks, key, value) + frappe.cache().delete_key("app_hooks") diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index d8ad728136..9f6ad70a35 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -62,16 +62,15 @@ def create_todo_records(): }).insert() @frappe.whitelist() -def create_communication_records(): - if frappe.db.get_all('Communication', {'subject': 'Test Form Communication 1'}): - return - - frappe.get_doc({ +def create_communication_record(): + doc = frappe.get_doc({ "doctype": "Communication", "recipients": "test@gmail.com", "subject": "Test Form Communication 1", "communication_date": frappe.utils.now_datetime(), - }).insert() + }) + doc.insert() + return doc @frappe.whitelist() def setup_workflow(): diff --git a/frappe/translations/af.csv b/frappe/translations/af.csv index b383fea767..fb52b4038d 100644 --- a/frappe/translations/af.csv +++ b/frappe/translations/af.csv @@ -3262,7 +3262,7 @@ Drop,drop, Drop Here,Drop hier, Drop files here,Laat lêers hier neer, Dynamic Template,Dinamiese sjabloon, -ERPNext Role,ERPVolgende rol, +ERPNext Role,ERPNext rol, Email / Notifications,E-pos / kennisgewings, Email Account setup please enter your password for: {0},Voer u wagwoord in vir die e-posrekening vir: {0}, Email Address whose Google Contacts are to be synced.,E-posadres waarvan die Google-kontakte gesinkroniseer moet word., diff --git a/frappe/translations/am.csv b/frappe/translations/am.csv index 52ed7c5f11..4dfa1bd8cd 100644 --- a/frappe/translations/am.csv +++ b/frappe/translations/am.csv @@ -3262,7 +3262,7 @@ Drop,ጣል ያድርጉ።, Drop Here,እዚህ ጣል ያድርጉ።, Drop files here,ፋይሎችን እዚህ ይጣሉ።, Dynamic Template,ተለዋዋጭ አብነት, -ERPNext Role,የኢአርኤክስ ቀጣይ ሚና።, +ERPNext Role,ERPNext ሚና, Email / Notifications,ኢሜይል / ማስታወቂያዎች, Email Account setup please enter your password for: {0},የኢሜል አካውንት ማዋቀር እባክዎን ለሚከተለው ይለፍ ቃልዎን ያስገቡ ፦ {0}, Email Address whose Google Contacts are to be synced.,የጉግል አድራሻዎች የሚመሳሰሉበት የኢሜል አድራሻ ፡፡, diff --git a/frappe/translations/da.csv b/frappe/translations/da.csv index 32f2ac9f6d..4516ed82ca 100644 --- a/frappe/translations/da.csv +++ b/frappe/translations/da.csv @@ -3262,7 +3262,7 @@ Drop,Dråbe, Drop Here,Drop Here, Drop files here,Slip filer her, Dynamic Template,Dynamisk skabelon, -ERPNext Role,ERPNæste rolle, +ERPNext Role,ERPNext rolle, Email / Notifications,E-mail / underretninger, Email Account setup please enter your password for: {0},Opsætning af e-mail-konto: indtast venligst din adgangskode til: {0}, Email Address whose Google Contacts are to be synced.,"E-mail-adresse, hvis Google-kontakter skal synkroniseres.", diff --git a/frappe/translations/id.csv b/frappe/translations/id.csv index 6207ecaa05..c4eae3e145 100644 --- a/frappe/translations/id.csv +++ b/frappe/translations/id.csv @@ -3262,7 +3262,7 @@ Drop,Penurunan, Drop Here,Jatuhkan Di Sini, Drop files here,Letakkan file di sini, Dynamic Template,Template Dinamis, -ERPNext Role,Peran ERPN, +ERPNext Role,Peran ERPNext, Email / Notifications,Notifikasi email, Email Account setup please enter your password for: {0},"Pengaturan Akun Email, harap masukkan kata sandi Anda untuk: {0}", Email Address whose Google Contacts are to be synced.,Alamat Email yang Kontak Google-nya harus disinkronkan., diff --git a/frappe/translations/is.csv b/frappe/translations/is.csv index c06065b120..fd0b552701 100644 --- a/frappe/translations/is.csv +++ b/frappe/translations/is.csv @@ -3262,7 +3262,7 @@ Drop,Dropi, Drop Here,Sendu hér, Drop files here,Sendu skrár hér, Dynamic Template,Dynamískt sniðmát, -ERPNext Role,ERPNæsta hlutverk, +ERPNext Role,ERPNext hlutverk, Email / Notifications,Netfang / tilkynningar, Email Account setup please enter your password for: {0},Uppsetning tölvupóstreikninga vinsamlegast sláðu inn lykilorðið þitt fyrir: {0}, Email Address whose Google Contacts are to be synced.,Netfang þar sem samstillt er Google tengiliði, diff --git a/frappe/translations/it.csv b/frappe/translations/it.csv index f61d467ebe..1d4c1af0f2 100644 --- a/frappe/translations/it.csv +++ b/frappe/translations/it.csv @@ -3262,7 +3262,7 @@ Drop,Far cadere, Drop Here,Drop Here, Drop files here,Trascina i file qui, Dynamic Template,Modello dinamico, -ERPNext Role,ERPSuccessivo ruolo, +ERPNext Role,ruolo ERPNext, Email / Notifications,Notifiche di posta elettronica, Email Account setup please enter your password for: {0},"Impostazione dell'account e-mail, inserire la password per: {0}", Email Address whose Google Contacts are to be synced.,Indirizzo email i cui contatti Google devono essere sincronizzati., diff --git a/frappe/translations/ja.csv b/frappe/translations/ja.csv index 441cfa44ef..35029e0058 100644 --- a/frappe/translations/ja.csv +++ b/frappe/translations/ja.csv @@ -3262,7 +3262,7 @@ Drop,ドロップ, Drop Here,ここにドロップ, Drop files here,ここにファイルをドロップします, Dynamic Template,動的テンプレート, -ERPNext Role,ERP次のロール, +ERPNext Role,ERPNext の役割, Email / Notifications,メール/通知, Email Account setup please enter your password for: {0},メールアカウントのセットアップ:{0}のパスワードを入力してください, Email Address whose Google Contacts are to be synced.,Googleの連絡先を同期するメールアドレス。, diff --git a/frappe/translations/km.csv b/frappe/translations/km.csv index 70a719d63d..5e8e1fc2d5 100644 --- a/frappe/translations/km.csv +++ b/frappe/translations/km.csv @@ -3262,7 +3262,7 @@ Drop,ទម្លាក់។, Drop Here,ទម្លាក់នៅទីនេះ។, Drop files here,ទម្លាក់ឯកសារនៅទីនេះ។, Dynamic Template,គំរូឌីណាមិក, -ERPNext Role,តួនាទី ERP បន្ទាប់។, +ERPNext Role,ERPNext តួនាទី។, Email / Notifications,អ៊ីមែល / ការជូនដំណឹង, Email Account setup please enter your password for: {0},រៀបចំគណនីអ៊ីមែលសូមបញ្ចូលពាក្យសម្ងាត់របស់អ្នកសម្រាប់៖ {0}, Email Address whose Google Contacts are to be synced.,អាសយដ្ឋានអ៊ីមែលដែលទំនាក់ទំនងរបស់ Google នឹងត្រូវធ្វើសមកាលកម្ម។, diff --git a/frappe/translations/mr.csv b/frappe/translations/mr.csv index 383b7a1c1e..6e98f94434 100644 --- a/frappe/translations/mr.csv +++ b/frappe/translations/mr.csv @@ -3262,7 +3262,7 @@ Drop,थेंब, Drop Here,येथे ड्रॉप करा, Drop files here,फायली येथे सोडा, Dynamic Template,डायनॅमिक टेम्पलेट, -ERPNext Role,ईआरपीनेक्स्ट रोल, +ERPNext Role,ERPNext रोल, Email / Notifications,ईमेल / सूचना, Email Account setup please enter your password for: {0},ईमेल खाते सेटअप यासाठी आपला संकेतशब्द प्रविष्ट करा: {0}, Email Address whose Google Contacts are to be synced.,ज्यांचे Google संपर्क समक्रमित केले जातील असा ईमेल पत्ता., diff --git a/frappe/translations/rw.csv b/frappe/translations/rw.csv index e0a1142a6a..002d22e72b 100644 --- a/frappe/translations/rw.csv +++ b/frappe/translations/rw.csv @@ -3262,7 +3262,7 @@ Drop,Tera, Drop Here,Tera Hano, Drop files here,Tera dosiye hano, Dynamic Template,Icyitegererezo, -ERPNext Role,Uruhare rwa ERPN, +ERPNext Role,uruhare rwa ERPNext, Email / Notifications,Imeri / Amatangazo, Email Account setup please enter your password for: {0},Imeri ya konte ya imeri nyamuneka andika ijambo ryibanga rya: {0}, Email Address whose Google Contacts are to be synced.,Aderesi ya imeri abo Google igomba guhuza., diff --git a/frappe/translations/sk.csv b/frappe/translations/sk.csv index 1d9442ffdb..7583eec96e 100644 --- a/frappe/translations/sk.csv +++ b/frappe/translations/sk.csv @@ -3262,7 +3262,7 @@ Drop,Pokles, Drop Here,Drop sem, Drop files here,Sem presuňte súbory, Dynamic Template,Dynamická šablóna, -ERPNext Role,ERPĎalšia rola, +ERPNext Role,ERPNext rola, Email / Notifications,E-mail / Upozornenia, Email Account setup please enter your password for: {0},"Nastavenie e-mailového účtu, zadajte heslo pre: {0}", Email Address whose Google Contacts are to be synced.,"E-mailová adresa, ktorej kontakty Google sa majú synchronizovať.", diff --git a/frappe/translations/sq.csv b/frappe/translations/sq.csv index 3d3fe564b3..b26b104850 100644 --- a/frappe/translations/sq.csv +++ b/frappe/translations/sq.csv @@ -3262,7 +3262,7 @@ Drop,Drop, Drop Here,Hidh këtu, Drop files here,Hidh skedarët këtu, Dynamic Template,Modeli Dinamik, -ERPNext Role,Roli ERPN, +ERPNext Role,Roli i ERPNext, Email / Notifications,Email / njoftime, Email Account setup please enter your password for: {0},Konfigurimi i llogarisë email ju lutemi shkruani fjalëkalimin tuaj për: {0, Email Address whose Google Contacts are to be synced.,Adresa e Email-it Kontaktet e të cilit Google duhet të sinkronizohen., diff --git a/frappe/translations/sv.csv b/frappe/translations/sv.csv index fe8458de35..8ab480d659 100644 --- a/frappe/translations/sv.csv +++ b/frappe/translations/sv.csv @@ -3262,7 +3262,7 @@ Drop,Släppa, Drop Here,Släpp här, Drop files here,Släpp filer här, Dynamic Template,Dynamisk mall, -ERPNext Role,ERPNästa roll, +ERPNext Role,ERPNext roll, Email / Notifications,E-post / aviseringar, Email Account setup please enter your password for: {0},"Ange e-postkonto, ange ditt lösenord för: {0}", Email Address whose Google Contacts are to be synced.,E-postadress vars Google-kontakter ska synkroniseras., diff --git a/frappe/website/doctype/web_form/web_form.js b/frappe/website/doctype/web_form/web_form.js index 4d38d11891..d69d21c64d 100644 --- a/frappe/website/doctype/web_form/web_form.js +++ b/frappe/website/doctype/web_form/web_form.js @@ -47,7 +47,7 @@ frappe.ui.form.on("Web Form", { frm.add_custom_button(__('Get Fields'), () => { let webform_fieldtypes = frappe.meta.get_field('Web Form Field', 'fieldtype').options.split('\n'); - let fieldnames = (frm.doc.fields || []).map(d => d.fieldname); + let fieldnames = (frm.doc.web_form_fields || []).map(d => d.fieldname); frappe.model.with_doctype(frm.doc.doc_type, () => { let meta = frappe.get_meta(frm.doc.doc_type); for (let field of meta.fields) { diff --git a/frappe/www/_test/_test_webform.py b/frappe/www/_test/_test_webform.py new file mode 100644 index 0000000000..3209e3e03c --- /dev/null +++ b/frappe/www/_test/_test_webform.py @@ -0,0 +1,6 @@ +def webform_list_context(module): + return {"get_list": get_webform_context_list} + + +def get_webform_context_list(): + pass diff --git a/frappe/www/list.py b/frappe/www/list.py index 6462c2914a..a45a180220 100644 --- a/frappe/www/list.py +++ b/frappe/www/list.py @@ -161,6 +161,14 @@ def get_list_context(context, doctype, web_form_name=None): module = load_doctype_module(doctype) list_context = update_context_from_module(module, list_context) + # get context for custom webform + if meta.custom and web_form_name: + webform_list_contexts = frappe.get_hooks('webform_list_context') + if webform_list_contexts: + out = frappe._dict(frappe.get_attr(webform_list_contexts[0])(meta.module) or {}) + if out: + list_context = out + # get context from web form module if web_form_name: web_form = frappe.get_doc('Web Form', web_form_name) diff --git a/socketio.js b/socketio.js index 502a80f07b..ac97c77d32 100644 --- a/socketio.js +++ b/socketio.js @@ -265,9 +265,11 @@ function get_chat_room(socket, room) { } function get_site_name(socket) { + var hostname_from_host = get_hostname(socket.request.headers.host); + if (socket.request.headers['x-frappe-site-name']) { return get_hostname(socket.request.headers['x-frappe-site-name']); - } else if (['localhost', '127.0.0.1'].indexOf(socket.request.headers.host) !== -1 && + } else if (['localhost', '127.0.0.1'].indexOf(hostname_from_host) !== -1 && conf.default_site) { // from currentsite.txt since host is localhost return conf.default_site;
Id + Time + State + Info + Progress +