diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index f02694846d..96e9be8b3c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -13,3 +13,9 @@ fe20515c23a3ac41f1092bf0eaf0a0a452ec2e85 # Updating license headers 34460265554242a8d05fb09f049033b1117e1a2b + +# Refactor "not a in b" -> "a not in b" +745297a49d516e5e3c4bb3e1b0c4235e7d31165d + +# Clean up whitespace +b2fc959307c7c79f5584625569d5aed04133ba13 diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 19a7c68e19..246bdbe096 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -50,7 +50,9 @@ if [ "$TYPE" == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; f if [ "$TYPE" == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi if [ "$TYPE" == "ui" ]; then bench setup requirements --node; fi -if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi +bench setup requirements --dev + +if [ "$TYPE" == "ui" ]; then sed -i 's/^web: bench serve/web: bench serve --with-coverage/g' Procfile; fi # install node-sass which is required for website theme test cd ./apps/frappe || exit @@ -60,4 +62,4 @@ cd ../.. bench start & bench --site test_site reinstall --yes if [ "$TYPE" == "server" ]; then bench --site test_site_producer reinstall --yes; fi -CI=Yes bench build --app frappe +if [ "$TYPE" == "server" ]; then CI=Yes bench build --app frappe; fi diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index 9831df7f30..90f4608a22 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -41,6 +41,7 @@ if __name__ == "__main__": # this is a push build, run all builds if not pr_number: os.system('echo "::set-output name=build::strawberry"') + os.system('echo "::set-output name=build-server::strawberry"') sys.exit(0) files_list = files_list or get_files_list(pr_number=pr_number, repo=repo) @@ -52,7 +53,8 @@ if __name__ == "__main__": ci_files_changed = any(f for f in files_list if is_ci(f)) only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list) - only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) + updated_py_file_count = len(list(filter(is_py, files_list))) + only_py_changed = updated_py_file_count == len(files_list) if ci_files_changed: print("CI related files were updated, running all build processes.") @@ -65,8 +67,12 @@ if __name__ == "__main__": print("Only Frontend code was updated; Stopping Python build process.") sys.exit(0) - elif only_py_changed and build_type == "ui": - print("Only Python code was updated, stopping Cypress build process.") - sys.exit(0) + elif build_type == "ui": + if only_py_changed: + print("Only Python code was updated, stopping Cypress build process.") + sys.exit(0) + elif updated_py_file_count > 0: + # both frontend and backend code were updated + os.system('echo "::set-output name=build-server::strawberry"') os.system('echo "::set-output name=build::strawberry"') diff --git a/.github/workflows/semgrep.yml b/.github/workflows/linters.yml similarity index 68% rename from .github/workflows/semgrep.yml rename to .github/workflows/linters.yml index 325411cf5c..443ee45bf7 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/linters.yml @@ -1,15 +1,24 @@ -name: Semgrep +name: Linters on: pull_request: { } jobs: - semgrep: + + linters: name: Frappe Linter runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install and Run Pre-commit + uses: pre-commit/action@v2.0.3 + - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index cb502f68a7..fc8093444e 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -137,10 +137,16 @@ jobs: - name: UI Tests if: ${{ steps.check-build.outputs.build == 'strawberry' }} - run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID + run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT env: CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb + - name: Stop server + if: ${{ steps.check-build.outputs.build-server == 'strawberry' }} + run: | + ps -ef | grep "frappe serve" | awk '{print $2}' | xargs kill -s SIGINT 2> /dev/null || true + sleep 5 + - name: Check If Coverage Report Exists id: check_coverage uses: andstor/file-existence-action@v1 @@ -156,3 +162,13 @@ jobs: directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/ verbose: true flags: ui-tests + + - name: Upload Server Coverage Data + if: ${{ steps.check-build.outputs.build-server == 'strawberry' }} + uses: codecov/codecov-action@v2 + with: + name: MariaDB + fail_ci_if_error: true + files: /home/runner/frappe-bench/sites/coverage.xml + verbose: true + flags: server diff --git a/.mergify.yml b/.mergify.yml index 0bd9641d5b..63fe1a0086 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -48,3 +48,7 @@ pull_request_rules: actions: merge: method: squash + commit_message_template: | + {{ title }} (#{{ number }}) + + {{ body }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..f3c3447cb3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +exclude: 'node_modules|.git' +default_stages: [commit] +fail_fast: false + + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: trailing-whitespace + files: "frappe.*" + exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" + - id: check-yaml + - id: no-commit-to-branch + args: ['--branch', 'develop'] + - id: check-merge-conflict + - id: check-ast + + +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/README.md b/README.md index ef471aa05a..8c8317c8bd 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - + diff --git a/codecov.yml b/codecov.yml index bc59416d2f..1326403cfe 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,7 +3,6 @@ codecov: coverage: status: - patch: off project: default: false server: diff --git a/cypress/fixtures/child_table_doctype.js b/cypress/fixtures/child_table_doctype.js new file mode 100644 index 0000000000..f65e5d1765 --- /dev/null +++ b/cypress/fixtures/child_table_doctype.js @@ -0,0 +1,30 @@ +export default { + name: "Child Table Doctype", + actions: [], + custom: 1, + autoname: "field:title", + creation: "2022-02-09 20:15:21.242213", + doctype: "DocType", + editable_grid: 1, + engine: "InnoDB", + fields: [ + { + fieldname: "title", + fieldtype: "Data", + in_list_view: 1, + label: "Title", + unique: 1 + } + ], + links: [], + istable: 1, + modified: "2022-02-10 12:03:12.603763", + modified_by: "Administrator", + module: "Custom", + naming_rule: "By fieldname", + owner: "Administrator", + permissions: [], + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; \ No newline at end of file diff --git a/cypress/fixtures/child_table_doctype_1.js b/cypress/fixtures/child_table_doctype_1.js new file mode 100644 index 0000000000..4657d63e2e --- /dev/null +++ b/cypress/fixtures/child_table_doctype_1.js @@ -0,0 +1,59 @@ +export default { + name: "Child Table Doctype 1", + actions: [], + custom: 1, + autoname: "format: Test-{####}", + creation: "2022-02-09 20:15:21.242213", + doctype: "DocType", + editable_grid: 1, + engine: "InnoDB", + fields: [ + { + fieldname: "data", + fieldtype: "Data", + in_list_view: 1, + label: "Data" + }, + { + fieldname: "barcode", + fieldtype: "Barcode", + in_list_view: 1, + label: "Barcode" + }, + { + fieldname: "check", + fieldtype: "Check", + in_list_view: 1, + label: "Check" + }, + { + fieldname: "rating", + fieldtype: "Rating", + in_list_view: 1, + label: "Rating" + }, + { + fieldname: "duration", + fieldtype: "Duration", + in_list_view: 1, + label: "Duration" + }, + { + fieldname: "date", + fieldtype: "Date", + in_list_view: 1, + label: "Date" + } + ], + links: [], + istable: 1, + modified: "2022-02-10 12:03:12.603763", + modified_by: "Administrator", + module: "Custom", + naming_rule: "By fieldname", + owner: "Administrator", + permissions: [], + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; \ No newline at end of file diff --git a/cypress/fixtures/doctype_to_link.js b/cypress/fixtures/doctype_to_link.js new file mode 100644 index 0000000000..f5335b1755 --- /dev/null +++ b/cypress/fixtures/doctype_to_link.js @@ -0,0 +1,45 @@ +export default { + name: "Doctype to Link", + actions: [], + custom: 1, + naming_rule: "By fieldname", + autoname: "field:title", + creation: "2022-02-09 20:15:21.242213", + doctype: "DocType", + editable_grid: 1, + engine: "InnoDB", + fields: [ + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "unique": 1 + } + ], + links: [ + { + "group": "Child Doctype", + "link_doctype": "Doctype With Child Table", + "link_fieldname": "title" + } + ], + modified: "2022-02-10 12:03:12.603763", + modified_by: "Administrator", + module: "Custom", + owner: "Administrator", + permissions: [ + { + create: 1, + delete: 1, + email: 1, + print: 1, + read: 1, + role: 'System Manager', + share: 1, + write: 1 + } + ], + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; \ No newline at end of file diff --git a/cypress/fixtures/doctype_with_child_table.js b/cypress/fixtures/doctype_with_child_table.js new file mode 100644 index 0000000000..014074b0b5 --- /dev/null +++ b/cypress/fixtures/doctype_with_child_table.js @@ -0,0 +1,52 @@ +export default { + name: "Doctype With Child Table", + actions: [], + custom: 1, + autoname: "field:title", + creation: "2022-02-09 20:15:21.242213", + doctype: "DocType", + editable_grid: 1, + engine: "InnoDB", + fields: [ + { + fieldname: "title", + fieldtype: "Data", + label: "Title", + unique: 1 + }, + { + fieldname: "child_table", + fieldtype: "Table", + label: "Child Table", + options: "Child Table Doctype", + reqd: 1 + }, + { + fieldname: "child_table_1", + fieldtype: "Table", + label: "Child Table 1", + options: "Child Table Doctype 1" + } + ], + links: [], + modified: "2022-02-10 12:03:12.603763", + modified_by: "Administrator", + module: "Custom", + naming_rule: "By fieldname", + owner: "Administrator", + permissions: [ + { + create: 1, + delete: 1, + email: 1, + print: 1, + read: 1, + role: 'System Manager', + share: 1, + write: 1 + } + ], + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; diff --git a/cypress/integration/control_autocomplete.js b/cypress/integration/control_autocomplete.js new file mode 100644 index 0000000000..3bf3e829f9 --- /dev/null +++ b/cypress/integration/control_autocomplete.js @@ -0,0 +1,57 @@ +context('Control Autocomplete', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + function get_dialog_with_autocomplete(options) { + cy.visit('/app/website'); + return cy.dialog({ + title: 'Autocomplete', + fields: [ + { + 'label': 'Select an option', + 'fieldname': 'autocomplete', + 'fieldtype': 'Autocomplete', + 'options': options || ['Option 1', 'Option 2', 'Option 3'], + } + ] + }); + } + + it('should set the valid value', () => { + get_dialog_with_autocomplete().as('dialog'); + + cy.get('.frappe-control[data-fieldname=autocomplete] input').focus().as('input'); + cy.wait(1000); + cy.get('@input').type('2', { delay: 300 }); + cy.get('.frappe-control[data-fieldname=autocomplete]').findByRole('listbox').should('be.visible'); + cy.get('.frappe-control[data-fieldname=autocomplete] input').type('{enter}', { delay: 300 }); + cy.get('.frappe-control[data-fieldname=autocomplete] input').blur(); + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('autocomplete'); + expect(value).to.eq('Option 2'); + dialog.clear(); + }); + }); + + it('should set the valid value with different label', () => { + const options_with_label = [ + { label: "Option 1", value: "option_1" }, + { label: "Option 2", value: "option_2" } + ]; + get_dialog_with_autocomplete(options_with_label).as('dialog'); + + cy.get('.frappe-control[data-fieldname=autocomplete] input').focus().as('input'); + cy.get('.frappe-control[data-fieldname=autocomplete]').findByRole('listbox').should('be.visible'); + cy.get('@input').type('2', { delay: 300 }); + cy.get('.frappe-control[data-fieldname=autocomplete] input').type('{enter}', { delay: 300 }); + cy.get('.frappe-control[data-fieldname=autocomplete] input').blur(); + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('autocomplete'); + expect(value).to.eq('option_2'); + dialog.clear(); + }); + }); + +}); diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js index 5f1ab86d41..03ab61fac4 100644 --- a/cypress/integration/control_barcode.js +++ b/cypress/integration/control_barcode.js @@ -21,7 +21,6 @@ context('Control Barcode', () => { get_dialog_with_barcode().as('dialog'); cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') - .focus() .type('123456789') .blur(); cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]') @@ -38,7 +37,6 @@ context('Control Barcode', () => { get_dialog_with_barcode().as('dialog'); cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') - .focus() .type('123456789') .blur(); cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js index 5c531a0823..d89eba8840 100644 --- a/cypress/integration/control_icon.js +++ b/cypress/integration/control_icon.js @@ -19,18 +19,18 @@ context('Control Icon', () => { get_dialog_with_icon().as('dialog'); cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').click(); - cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); - cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'active'); + cy.get('.icon-picker .icon-wrapper[id=heart-active]').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'heart-active'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); - expect(value).to.equal('active'); + expect(value).to.equal('heart-active'); }); - cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); - cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'resting'); + cy.get('.icon-picker .icon-wrapper[id=heart]').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'heart'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); - expect(value).to.equal('resting'); + expect(value).to.equal('heart'); }); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 6d16769b37..7a7e94d2f5 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -58,6 +58,23 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=link] input').should('have.value', ''); }); + it("should be possible set empty value explicitly", () => { + get_dialog_with_link().as("dialog"); + + cy.intercept("POST", "/api/method/frappe.client.validate_link").as("validate_link"); + + cy.get(".frappe-control[data-fieldname=link] input") + .type(" ", { delay: 100 }) + .blur(); + cy.wait("@validate_link"); + cy.get(".frappe-control[data-fieldname=link] input").should("have.value", ""); + cy.window() + .its("cur_dialog") + .then((dialog) => { + expect(dialog.get_value("link")).to.equal(''); + }); + }); + it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); @@ -78,7 +95,52 @@ context('Control Link', () => { }); }); - it('should fetch valid value', () => { + it('show title field in link', () => { + get_dialog_with_link().as('dialog'); + + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "ToDo", + "property": "show_title_field_in_link", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "1" + }, true); + + cy.window().its('frappe').then(frappe => { + if (!frappe.boot) { + frappe.boot = { + link_title_doctypes: ['ToDo'] + }; + } else { + frappe.boot.link_title_doctypes = ['ToDo']; + } + }); + + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); + + cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); + cy.wait('@search_link'); + cy.get('@input').type('todo for link'); + cy.wait('@search_link'); + cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); + cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); + cy.get('.frappe-control[data-fieldname=link] input').blur(); + cy.get('@dialog').then(dialog => { + cy.get('@todos').then(todos => { + let field = dialog.get_field('link'); + let value = field.get_value(); + let label = field.get_label_value(); + + expect(value).to.eq(todos[0]); + expect(label).to.eq('this is a test todo for link'); + + cy.remove_doc("Property Setter", "ToDo-main-show_title_field_in_link"); + }); + }); + }); + + it('should update dependant fields (via fetch_from)', () => { cy.get('@todos').then(todos => { cy.visit(`/app/todo/${todos[0]}`); cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); @@ -89,7 +151,67 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( 'contain', 'Administrator' ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", "Administrator"); + + // invalid input + cy.get('@input').clear().type('invalid input', {delay: 100}).blur(); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', '' + ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", null); + + // set valid value again + cy.get('@input').clear().type('Administrator', {delay: 100}).blur(); + cy.wait('@validate_link'); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", "Administrator"); + + // clear input + cy.get('@input').clear().blur(); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', '' + ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", ""); }); }); + it("should set default values", () => { + cy.insert_doc("Property Setter", { + "doctype_or_field": "DocField", + "doc_type": "ToDo", + "field_name": "assigned_by", + "property": "default", + "property_type": "Text", + "value": "Administrator" + }, true); + cy.reload(); + cy.new_form("ToDo"); + cy.fill_field("description", "new", "Text Editor"); + cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); + cy.findByRole("button", {name: "Save"}).click(); + cy.wait("@save_form"); + cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( + "contain", "Administrator" + ); + // if user clears default value explicitly, system should not reset default again + cy.get_field("assigned_by").clear().blur(); + cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); + cy.findByRole("button", {name: "Save"}).click(); + cy.wait("@save_form"); + cy.get_field("assigned_by").should("have.value", ""); + cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( + "contain", "" + ); + }); }); diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js index 16ffd41cf4..019de1991d 100644 --- a/cypress/integration/dashboard_links.js +++ b/cypress/integration/dashboard_links.js @@ -1,7 +1,23 @@ +import doctype_with_child_table from '../fixtures/doctype_with_child_table'; +import child_table_doctype from '../fixtures/child_table_doctype'; +import child_table_doctype_1 from '../fixtures/child_table_doctype_1'; +import doctype_to_link from '../fixtures/doctype_to_link'; +const doctype_to_link_name = doctype_to_link.name; +const child_table_doctype_name = child_table_doctype.name; + context('Dashboard links', () => { before(() => { cy.visit('/login'); cy.login(); + cy.insert_doc('DocType', child_table_doctype, true); + cy.insert_doc('DocType', child_table_doctype_1, true); + cy.insert_doc('DocType', doctype_with_child_table, true); + cy.insert_doc('DocType', doctype_to_link, true); + return cy.window().its('frappe').then(frappe => { + return frappe.xcall("frappe.tests.ui_test_helpers.update_child_table", { + name: child_table_doctype_name + }); + }); }); it('Adding a new contact, checking for the counter on the dashboard and deleting the created contact', () => { @@ -62,4 +78,14 @@ context('Dashboard links', () => { cy.findByText('Website Analytics'); }); }); + + it('check if child table is populated with linked field on creation from dashboard link', () => { + cy.new_form(doctype_to_link_name); + cy.fill_field("title", "Test Linking"); + cy.findByRole("button", {name: "Save"}).click(); + + cy.get('.document-link .btn-new').click(); + cy.get('.frappe-control[data-fieldname="child_table"] .rows .data-row .col[data-fieldname="doctype_to_link"]') + .should('contain.text', 'Test Linking'); + }); }); diff --git a/cypress/integration/datetime.js b/cypress/integration/datetime.js index ef1952dc94..4a24faf40b 100644 --- a/cypress/integration/datetime.js +++ b/cypress/integration/datetime.js @@ -103,6 +103,7 @@ context('Control Date, Time and DateTime', () => { input_value: '12-02-2019 11:00' // admin timezone (Asia/Kolkata) } ]; + datetime_formats.forEach(d => { it(`test datetime format ${d.date_format} ${d.time_format}`, () => { cy.set_value('System Settings', 'System Settings', { diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js index 9aa6b5d89d..12f54f2b6e 100644 --- a/cypress/integration/depends_on.js +++ b/cypress/integration/depends_on.js @@ -55,10 +55,31 @@ context('Depends On', () => { 'read_only_depends_on': "eval:doc.test_field=='Some Other Value'", 'options': "Child Test Depends On" }, + { + "label": "Dependent Tab", + "fieldname": "dependent_tab", + "fieldtype": "Tab Break", + "depends_on": "eval:doc.test_field=='Show Tab'" + }, + { + "fieldname": "tab_section", + "fieldtype": "Section Break", + }, + { + "label": "Field in Tab", + "fieldname": "field_in_tab", + "fieldtype": "Data", + } ] }); }); }); + it('should show the tab on other setting field value', () => { + cy.new_form('Test Depends On'); + cy.fill_field('test_field', 'Show Tab'); + cy.get('body').click(); + cy.findByRole("tab", {name: "Dependent Tab"}).should('be.visible'); + }); it('should set the field as mandatory depending on other fields value', () => { cy.new_form('Test Depends On'); cy.fill_field('test_field', 'Some Value'); diff --git a/cypress/integration/grid.js b/cypress/integration/grid.js new file mode 100644 index 0000000000..4fa52712cf --- /dev/null +++ b/cypress/integration/grid.js @@ -0,0 +1,92 @@ +context('Grid', () => { + beforeEach(() => { + cy.login(); + cy.visit('/app/website'); + }); + before(() => { + cy.login(); + cy.visit('/app/website'); + return cy.window().its('frappe').then(frappe => { + return frappe.call("frappe.tests.ui_test_helpers.create_contact_phone_nos_records"); + }); + }); + it('update docfield property using update_docfield_property', () => { + cy.visit('/app/contact/Test Contact'); + cy.window().its("cur_frm").then(frm => { + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + let field = frm.get_field("phone_nos"); + field.grid.update_docfield_property("is_primary_phone", "hidden", true); + + cy.get('@table').find('[data-idx="1"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_phone"]').should("be.hidden"); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + + cy.get('@table').find('[data-idx="2"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_phone"]').should("be.hidden"); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + }); + }); + it('update docfield property using toggle_display', () => { + cy.visit('/app/contact/Test Contact'); + cy.window().its("cur_frm").then(frm => { + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + let field = frm.get_field("phone_nos"); + field.grid.toggle_display("is_primary_mobile_no", false); + + cy.get('@table').find('[data-idx="1"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_mobile_no"]').should("be.hidden"); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + + cy.get('@table').find('[data-idx="2"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_mobile_no"]').should("be.hidden"); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + }); + }); + it('update docfield property using toggle_enable', () => { + cy.visit('/app/contact/Test Contact'); + cy.window().its("cur_frm").then(frm => { + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + let field = frm.get_field("phone_nos"); + field.grid.toggle_enable("phone", false); + + + cy.get('@table').find('[data-idx="1"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get('@table-form').find('.frappe-control[data-fieldname="phone"] .control-value').should('have.class', 'like-disabled-input'); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + + cy.get('@table').find('[data-idx="2"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get('@table-form').find('.frappe-control[data-fieldname="phone"] .control-value').should('have.class', 'like-disabled-input'); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + }); + }); + it('update docfield property using toggle_reqd', () => { + cy.visit('/app/contact/Test Contact'); + cy.window().its("cur_frm").then(frm => { + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + let field = frm.get_field("phone_nos"); + field.grid.toggle_reqd("phone", false); + + cy.get('@table').find('[data-idx="1"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get_field("phone").as('phone-field'); + cy.get('@phone-field').focus().clear().wait(500).blur(); + cy.get('@phone-field').should("not.have.class", "has-error"); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + + cy.get('@table').find('[data-idx="2"] .edit-grid-row').click(); + cy.get('.grid-row-open').as('table-form'); + cy.get_field("phone").as('phone-field'); + cy.get('@phone-field').focus().clear().wait(500).blur(); + cy.get('@phone-field').should("not.have.class", "has-error"); + cy.get('@table-form').find('.grid-footer-toolbar').click(); + + }); + }); +}); + diff --git a/cypress/integration/grid_search.js b/cypress/integration/grid_search.js new file mode 100644 index 0000000000..d30545a2e1 --- /dev/null +++ b/cypress/integration/grid_search.js @@ -0,0 +1,107 @@ +import doctype_with_child_table from '../fixtures/doctype_with_child_table'; +import child_table_doctype from '../fixtures/child_table_doctype'; +import child_table_doctype_1 from '../fixtures/child_table_doctype_1'; +const doctype_with_child_table_name = doctype_with_child_table.name; + +context('Grid Search', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/website'); + cy.insert_doc('DocType', child_table_doctype, true); + cy.insert_doc('DocType', child_table_doctype_1, true); + cy.insert_doc('DocType', doctype_with_child_table, true); + return cy.window().its('frappe').then(frappe => { + return frappe.xcall("frappe.tests.ui_test_helpers.insert_doctype_with_child_table_record", { + name: doctype_with_child_table_name + }); + }); + }); + + it('Test search row visibility', () => { + cy.window().its('frappe').then(frappe => { + frappe.model.user_settings.save('Doctype With Child Table', 'GridView', { + 'Child Table Doctype 1': [ + {'fieldname': 'data', 'columns': 2}, + {'fieldname': 'barcode', 'columns': 1}, + {'fieldname': 'check', 'columns': 1}, + {'fieldname': 'rating', 'columns': 2}, + {'fieldname': 'duration', 'columns': 2}, + {'fieldname': 'date', 'columns': 2} + ] + }); + }); + + cy.visit(`/app/doctype-with-child-table/Test Grid Search`); + + cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table'); + cy.get('@table').find('.grid-row-check:last').click(); + cy.get('@table').find('.grid-footer').contains('Delete').click(); + cy.get('.grid-heading-row .grid-row .search').should('not.exist'); + }); + + it('test search field for different fieldtypes', () => { + cy.visit(`/app/doctype-with-child-table/Test Grid Search`); + + cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table'); + + // Index Column + cy.get('@table').find('.grid-heading-row .row-index.search input').type('3'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2); + cy.get('@table').find('.grid-heading-row .row-index.search input').clear(); + + // Data Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('Data'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 1); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').clear(); + + // Barcode Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('092'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').clear(); + + // Check Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('1'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 9); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear(); + + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('0'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 11); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear(); + + // Rating Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').type('3'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').clear(); + + // Duration Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('3d'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').clear(); + + // Date Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('2022'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').clear(); + }); + + it('test with multiple filter', () => { + cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table'); + + // Data Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('a'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 10); + + // Barcode Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('0'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 8); + + // Duration Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('d'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 5); + + // Date Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('02-'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2); + }); +}); \ No newline at end of file diff --git a/cypress/integration/list_paging.js b/cypress/integration/list_paging.js new file mode 100644 index 0000000000..4a59024a7b --- /dev/null +++ b/cypress/integration/list_paging.js @@ -0,0 +1,38 @@ +context('List Paging', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + return cy.window().its('frappe').then(frappe => { + return frappe.call("frappe.tests.ui_test_helpers.create_multiple_todo_records"); + }); + }); + + it('test load more with count selection buttons', () => { + cy.visit('/app/todo/view/report'); + + cy.get('.list-paging-area .list-count').should('contain.text', '20 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '40 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '60 of'); + + cy.get('.list-paging-area .btn-group .btn-paging[data-value="100"]').click(); + + cy.get('.list-paging-area .list-count').should('contain.text', '100 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '200 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '300 of'); + + // check if refresh works after load more + cy.get('.page-head .standard-actions [data-original-title="Refresh"]').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '300 of'); + + cy.get('.list-paging-area .btn-group .btn-paging[data-value="500"]').click(); + + cy.get('.list-paging-area .list-count').should('contain.text', '500 of'); + cy.get('.list-paging-area .btn-more').click(); + + cy.get('.list-paging-area .list-count').should('contain.text', '1000 of'); + }); +}); diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index b161af2df7..3e0d1c9d50 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -12,6 +12,7 @@ context('List View', () => { cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true }); cy.get('.actions-btn-group button').contains('Actions').should('be.visible'); cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh'); + cy.wait(3000); // wait before you hit another 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 7752ad0f0b..607db506c7 100644 --- a/cypress/integration/multi_select_dialog.js +++ b/cypress/integration/multi_select_dialog.js @@ -77,11 +77,11 @@ context('MultiSelectDialog', () => { it('tests more button', () => { cy.get_open_dialog() - .get(`.frappe-control[data-fieldname="more_btn"]`) + .get(`.frappe-control[data-fieldname="more_child_btn"]`) .should('exist') .as('more-btn'); - cy.get_open_dialog().get('.list-item-container').should(($rows) => { + cy.get_open_dialog().get('.datatable .dt-scrollable .dt-row').should(($rows) => { expect($rows).to.have.length(20); }); @@ -89,7 +89,7 @@ context('MultiSelectDialog', () => { cy.get('@more-btn').find('button').click({force: true}); cy.wait('@get-more-records'); - cy.get_open_dialog().get('.list-item-container').should(($rows) => { + cy.get_open_dialog().get('.datatable .dt-scrollable .dt-row').should(($rows) => { if ($rows.length <= 20) { throw new Error("More button doesn't work"); } diff --git a/cypress/integration/number_card.js b/cypress/integration/number_card.js new file mode 100644 index 0000000000..a01ff1152d --- /dev/null +++ b/cypress/integration/number_card.js @@ -0,0 +1,22 @@ +context('Number Card', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + it('Check filter populate for child table doctype', () => { + cy.visit('/app/number-card/new-number-card-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('label', 'Test Number Card', '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/report_view.js b/cypress/integration/report_view.js index 629ae72eb8..bacbf9c172 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -7,34 +7,37 @@ 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', 'enabled': 0, - // submit document - 'docstatus': 1 - }, true).as('doc'); + 'docstatus': 1 // submit document + }, true); + }); + + 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 cy.get('.dt-row-0 > .dt-cell--col-3').should('contain', 'Submitted'); let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); + // select the cell cell.dblclick(); cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true }); + cy.get('.dt-row-0 > .dt-cell--col-3').click(); // click outside + cy.wait('@value-update'); - cy.get('@doc').then(doc => { - cy.call('frappe.client.get_value', { - doctype: doc.doctype, - filters: { - name: doc.name, - }, - fieldname: 'enabled' - }).then(r => { - expect(r.message.enabled).to.equals(1); - }); + + cy.call('frappe.client.get_value', { + doctype: doctype_name, + filters: { + title: 'Doc 1', + }, + fieldname: 'enabled' + }).then(r => { + expect(r.message.enabled).to.equals(1); }); }); -}); \ No newline at end of file +}); diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js index dfe80e0019..5808bd52ef 100644 --- a/cypress/integration/timeline_email.js +++ b/cypress/integration/timeline_email.js @@ -14,12 +14,12 @@ context('Timeline Email', () => { cy.wait(700); }); - it('Adding email and verifying timeline content for email attachment, deleting attachment and ToDo', () => { + it('Adding email and verifying timeline content for email attachment', () => { cy.visit('/app/todo'); cy.get('.list-row > .level-left > .list-subject').eq(0).click(); //Creating a new email - cy.get('.timeline-actions > .btn').click(); + cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click(); cy.fill_field('recipients', 'test@example.com', 'MultiSelect'); cy.get('.modal.show > .modal-dialog > .modal-content > .modal-body > :nth-child(1) > .form-layout > .form-page > :nth-child(3) > .section-body > .form-column > form > [data-fieldtype="Text Editor"] > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').type('Test Mail'); @@ -43,7 +43,9 @@ context('Timeline Email', () => { cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click(); cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .dropdown-menu > li > .grey-link').eq(9).click(); cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click(); + }); + it('Deleting attachment and ToDo', () => { cy.visit('/app/todo'); cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click(); @@ -57,11 +59,11 @@ context('Timeline Email', () => { cy.wait(500); //To check if the discard button functionality in email is working correctly - cy.get('.timeline-actions > .btn').click(); + cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click(); cy.fill_field('recipients', 'test@example.com', 'MultiSelect'); cy.get('.modal-footer > .standard-actions > .btn-secondary').contains('Discard').click(); cy.wait(500); - cy.get('.timeline-actions > .btn').click(); + cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click(); cy.wait(500); cy.get_field('recipients', 'MultiSelect').should('have.text', ''); cy.get('.modal-header:visible > .modal-actions > .btn-modal-close > .icon').click(); diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js index 65586366e6..fbff451305 100644 --- a/cypress/integration/workspace.js +++ b/cypress/integration/workspace.js @@ -23,7 +23,7 @@ context('Workspace 2.0', () => { // check if sidebar item is added in pubic section cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0'); - cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); cy.wait(300); cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0'); @@ -33,56 +33,54 @@ context('Workspace 2.0', () => { }); it('Add New Block', () => { - cy.get('.codex-editor__redactor .ce-block'); - cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); - cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Heading').click(); + cy.get('.ce-block').click().type('{enter}'); + cy.get('.block-list-container .block-list-item').contains('Heading').click(); cy.get(":focus").type('Header'); cy.get(".ce-block:last").find('.ce-header').should('exist'); - cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); - cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Text').click(); + cy.get('.ce-block:last').click().type('{enter}'); + cy.get('.block-list-container .block-list-item').contains('Text').click(); cy.get(":focus").type('Paragraph text'); cy.get(".ce-block:last").find('.ce-paragraph').should('exist'); }); it('Delete A Block', () => { - cy.get(".ce-block:last").find('.delete-paragraph').click(); + cy.get(":focus").click(); + cy.get('.paragraph-control .setting-btn').click(); + cy.get('.paragraph-control .dropdown-item').contains('Delete').click(); cy.get(".ce-block:last").find('.ce-paragraph').should('not.exist'); }); it('Shrink and Expand A Block', () => { - cy.get(".ce-block:last").find('.tune-btn').click(); - cy.get('.ce-settings--opened .ce-shrink-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-11'); - cy.get('.ce-settings--opened .ce-shrink-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-10'); - cy.get('.ce-settings--opened .ce-shrink-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-9'); - cy.get('.ce-settings--opened .ce-expand-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-10'); - cy.get('.ce-settings--opened .ce-expand-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-11'); - cy.get('.ce-settings--opened .ce-expand-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-12'); - }); + cy.get(":focus").click(); + cy.get('.ce-block:last .setting-btn').click(); + cy.get('.ce-block:last .dropdown-item').contains('Shrink').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-11'); + cy.get('.ce-block:last .dropdown-item').contains('Shrink').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-10'); + cy.get('.ce-block:last .dropdown-item').contains('Shrink').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-9'); + cy.get('.ce-block:last .dropdown-item').contains('Expand').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-10'); + cy.get('.ce-block:last .dropdown-item').contains('Expand').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-11'); + cy.get('.ce-block:last .dropdown-item').contains('Expand').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-12'); - it('Change Header Text Size', () => { - cy.get('.ce-settings--opened .cdx-settings-button[data-level="3"]').click(); - cy.get(".ce-block:last").find('.widget-head h3').should('exist'); - cy.get('.ce-settings--opened .cdx-settings-button[data-level="4"]').click(); - cy.get(".ce-block:last").find('.widget-head h4').should('exist'); - - cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); }); it('Delete Private Page', () => { cy.get('.codex-editor__redactor .ce-block'); cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); - cy.get('.sidebar-item-container[item-name="Test Private Page"]').find('.sidebar-item-control .delete-page').click(); + cy.get('.sidebar-item-container[item-name="Test Private Page"]') + .find('.sidebar-item-control .setting-btn').click(); + cy.get('.sidebar-item-container[item-name="Test Private Page"]') + .find('.dropdown-item[title="Delete Workspace"]').click({force: true}); cy.wait(300); cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click(); - cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); cy.get('.codex-editor__redactor .ce-block'); cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist'); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 758b3cde2b..4f273af21f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -110,34 +110,6 @@ Cypress.Commands.add('get_doc', (doctype, name) => { }); }); -Cypress.Commands.add('insert_doc', (doctype, args, ignore_duplicate) => { - return cy - .window() - .its('frappe.csrf_token') - .then(csrf_token => { - return cy - .request({ - method: 'POST', - url: `/api/resource/${doctype}`, - body: args, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Frappe-CSRF-Token': csrf_token - }, - failOnStatusCode: !ignore_duplicate - }) - .then(res => { - let status_codes = [200]; - if (ignore_duplicate) { - status_codes.push(409); - } - expect(res.status).to.be.oneOf(status_codes); - return res.body; - }); - }); -}); - Cypress.Commands.add('remove_doc', (doctype, name) => { return cy .window() diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 792cb56198..ff31aa4b74 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -9,7 +9,7 @@ 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 postCssPlugin = require("@frappe/esbuild-plugin-postcss2").default; const ignore_assets = require("./ignore-assets"); const sass_options = require("./sass_options"); const build_cleanup_plugin = require("./build-cleanup"); @@ -286,7 +286,7 @@ function get_watch_config() { notify_redis({ error }); } else { let { - assets_json, + new_assets_json, prev_assets_json } = await write_assets_json(result.metafile); @@ -294,7 +294,7 @@ function get_watch_config() { if (prev_assets_json) { changed_files = get_rebuilt_assets( prev_assets_json, - assets_json + new_assets_json ); let timestamp = new Date().toLocaleTimeString(); @@ -384,6 +384,7 @@ let prev_assets_json; let curr_assets_json; async function write_assets_json(metafile) { + let rtl = false; prev_assets_json = curr_assets_json; let out = {}; for (let output in metafile.outputs) { @@ -392,13 +393,14 @@ async function write_assets_json(metafile) { if (info.entryPoint) { let key = path.basename(info.entryPoint); if (key.endsWith('.css') && asset_path.includes('/css-rtl/')) { + rtl = true; key = `rtl_${key}`; } out[key] = asset_path; } } - let assets_json_path = path.resolve(assets_path, "assets.json"); + let assets_json_path = path.resolve(assets_path, `assets${rtl?'-rtl':''}.json`); let assets_json; try { assets_json = await fs.promises.readFile(assets_json_path, "utf-8"); @@ -407,21 +409,21 @@ async function write_assets_json(metafile) { } assets_json = JSON.parse(assets_json); // update with new values - assets_json = Object.assign({}, assets_json, out); - curr_assets_json = assets_json; + let new_assets_json = Object.assign({}, assets_json, out); + curr_assets_json = new_assets_json; await fs.promises.writeFile( assets_json_path, - JSON.stringify(assets_json, null, 4) + JSON.stringify(new_assets_json, null, 4) ); - await update_assets_json_in_cache(assets_json); + await update_assets_json_in_cache(); return { - assets_json, + new_assets_json, prev_assets_json }; } -function update_assets_json_in_cache(assets_json) { +function update_assets_json_in_cache() { // update assets_json cache in redis, so that it can be read directly by python return new Promise(resolve => { let client = get_redis_subscriber("redis_cache"); @@ -429,7 +431,7 @@ function update_assets_json_in_cache(assets_json) { client.on("error", _ => { log_warn("Cannot connect to redis_cache to update assets_json"); }); - client.set("assets_json", JSON.stringify(assets_json), err => { + client.del("assets_json", err => { client.unref(); resolve(); }); diff --git a/esbuild/frappe-html.js b/esbuild/frappe-html.js index 8c4b7ca3d7..9a7edb144d 100644 --- a/esbuild/frappe-html.js +++ b/esbuild/frappe-html.js @@ -20,7 +20,8 @@ module.exports = { .then(content => { content = scrub_html_template(content); return { - contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n` + contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n`, + watchFiles: [filepath] }; }) .catch(() => { diff --git a/frappe/__init__.py b/frappe/__init__.py index a8bf114b9b..7d3de64eb7 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -35,6 +35,7 @@ from frappe.query_builder import ( patch_query_execute, patch_query_aggregation, ) +from frappe.utils.data import cstr __version__ = '14.0.0-dev' @@ -102,7 +103,7 @@ def as_unicode(text, encoding='utf-8'): '''Convert to unicode if required''' if isinstance(text, str): return text - elif text==None: + elif text is None: return '' elif isinstance(text, bytes): return str(text, encoding) @@ -143,6 +144,8 @@ lang = local("lang") # This if block is never executed when running the code. It is only used for # telling static code analyzer where to find dynamically defined attributes. if typing.TYPE_CHECKING: + from frappe.utils.redis_wrapper import RedisWrapper + from frappe.database.mariadb.database import MariaDBDatabase from frappe.database.postgres.database import PostgresDatabase from frappe.query_builder.builder import MariaDB, Postgres @@ -150,6 +153,7 @@ if typing.TYPE_CHECKING: db: typing.Union[MariaDBDatabase, PostgresDatabase] qb: typing.Union[MariaDB, Postgres] + # end: static analysis hack def init(site, sites_path=None, new_site=False): @@ -211,6 +215,7 @@ def init(site, sites_path=None, new_site=False): local.cache = {} local.document_cache = {} local.meta_cache = {} + local.autoincremented_status_map = {site: -1} local.form_dict = _dict() local.session = _dict() local.dev_server = _dev_server @@ -294,7 +299,7 @@ def get_conf(site=None): class init_site: def __init__(self, site=None): - '''If site==None, initialize it for empty site ('') to load common_site_config.json''' + '''If site is None, initialize it for empty site ('') to load common_site_config.json''' self.site = site or '' def __enter__(self): @@ -311,9 +316,8 @@ def destroy(): release_local(local) -# memcache redis_server = None -def cache(): +def cache() -> "RedisWrapper": """Returns redis connection.""" global redis_server if not redis_server: @@ -356,7 +360,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, response JSON and shown in a pop-up / modal. :param msg: Message. - :param title: [optional] Message title. + :param title: [optional] Message title. Default: "Message". :param raise_exception: [optional] Raise given exception and show message. :param as_table: [optional] If `msg` is a list of lists, render as HTML table. :param as_list: [optional] If `msg` is a list, render as un-ordered list. @@ -393,8 +397,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, if flags.print_messages and out.message: print(f"Message: {strip_html_tags(out.message)}") - if title: - out.title = title + out.title = title or _("Message", context="Default title of the message dialog") if not indicator and raise_exception: indicator = 'red' @@ -446,7 +449,7 @@ def throw(msg, exc=ValidationError, title=None, is_minimizable=None, wide=None, msgprint(msg, raise_exception=exc, title=title, indicator='red', is_minimizable=is_minimizable, wide=wide, as_list=as_list) def emit_js(js, user=False, **kwargs): - if user == False: + if user is False: user = session.user publish_realtime('eval_js', js, user=user, **kwargs) @@ -849,8 +852,7 @@ def set_value(doctype, docname, fieldname, value=None): return frappe.client.set_value(doctype, docname, fieldname, value) def get_cached_doc(*args, **kwargs): - if args and len(args) > 1 and isinstance(args[1], str): - key = get_document_cache_key(args[0], args[1]) + if key := can_cache_doc(args): # local cache doc = local.document_cache.get(key) if doc: @@ -868,8 +870,24 @@ def get_cached_doc(*args, **kwargs): return doc +def can_cache_doc(args): + """ + Determine if document should be cached based on get_doc params. + Returns cache key if doc can be cached, None otherwise. + """ + + if not args: + return + + doctype = args[0] + name = doctype if len(args) == 1 else args[1] + + # Only cache if both doctype and name are strings + if isinstance(doctype, str) and isinstance(name, str): + return get_document_cache_key(doctype, name) + def get_document_cache_key(doctype, name): - return '{0}::{1}'.format(doctype, name) + return f'{doctype}::{name}' def clear_document_cache(doctype, name): cache().hdel("last_modified", doctype) @@ -910,8 +928,7 @@ def get_doc(*args, **kwargs): doc = frappe.model.document.get_doc(*args, **kwargs) # set in cache - if args and len(args) > 1: - key = get_document_cache_key(args[0], args[1]) + if key := can_cache_doc(args): local.document_cache[key] = doc cache().hset('document_cache', key, doc.as_dict()) @@ -961,8 +978,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa def delete_doc_if_exists(doctype, name, force=0): """Delete document if exists.""" - if db.exists(doctype, name): - delete_doc(doctype, name, force=force) + delete_doc(doctype, name, force=force, ignore_missing=True) def reload_doctype(doctype, force=False, reset_permissions=False): """Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files.""" @@ -1000,7 +1016,7 @@ def get_module(modulename): def scrub(txt): """Returns sluggified string. e.g. `Sales Order` becomes `sales_order`.""" - return txt.replace(' ', '_').replace('-', '_').lower() + return cstr(txt).replace(' ', '_').replace('-', '_').lower() def unscrub(txt): """Returns titlified string. e.g. `sales_order` becomes `Sales Order`.""" @@ -1235,9 +1251,10 @@ def get_newargs(fn, kwargs): if hasattr(fn, 'fnargs'): fnargs = fn.fnargs else: - fnargs = inspect.getfullargspec(fn).args - fnargs.extend(inspect.getfullargspec(fn).kwonlyargs) - varkw = inspect.getfullargspec(fn).varkw + fullargspec = inspect.getfullargspec(fn) + fnargs = fullargspec.args + fnargs.extend(fullargspec.kwonlyargs) + varkw = fullargspec.varkw newargs = {} for a in kwargs: @@ -1661,7 +1678,7 @@ def local_cache(namespace, key, generator, regenerate_if_none=False): if key not in local.cache[namespace]: local.cache[namespace][key] = generator() - elif local.cache[namespace][key]==None and regenerate_if_none: + elif local.cache[namespace][key] is None and regenerate_if_none: # if key exists but the previous result was None local.cache[namespace][key] = generator() diff --git a/frappe/api.py b/frappe/api.py index b061761d10..226853c47b 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -94,7 +94,8 @@ def handle(): "data": doc.save().as_dict() }) - if doc.parenttype and doc.parent: + # check for child table doctype + if doc.get("parenttype"): frappe.get_doc(doc.parenttype, doc.parent).save() frappe.db.commit() @@ -158,7 +159,10 @@ def get_request_form_data(): else: data = frappe.local.form_dict.data - return frappe.parse_json(data) + try: + return frappe.parse_json(data) + except ValueError: + return frappe.local.form_dict def validate_auth(): @@ -207,7 +211,6 @@ def validate_oauth(authorization_header): pass - def validate_auth_via_api_keys(authorization_header): """ Authenticate request using API keys and set session user diff --git a/frappe/app.py b/frappe/app.py index d73dd67983..975a2e2002 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -192,12 +192,7 @@ def make_form_dict(request): if not isinstance(args, dict): frappe.throw(_("Invalid request arguments")) - try: - frappe.local.form_dict = frappe._dict({ - k: v[0] if isinstance(v, (list, tuple)) else v for k, v in args.items() - }) - except IndexError: - frappe.local.form_dict = frappe._dict(args) + frappe.local.form_dict = frappe._dict(args) if "_" in frappe.local.form_dict: # _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict @@ -299,7 +294,6 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No _sites_path = sites_path from werkzeug.serving import run_simple - patch_werkzeug_reloader() if profile or os.environ.get('USE_PROFILER'): application = ProfilerMiddleware(application, sort_by=('cumtime', 'calls')) @@ -330,23 +324,3 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No use_debugger=not in_test_env, use_evalex=not in_test_env, threaded=not no_threading) - -def patch_werkzeug_reloader(): - """ - This function monkey patches Werkzeug reloader to ignore reloading files in - the __pycache__ directory. - - To be deprecated when upgrading to Werkzeug 2. - """ - - from werkzeug._reloader import WatchdogReloaderLoop - - trigger_reload = WatchdogReloaderLoop.trigger_reload - - def custom_trigger_reload(self, filename): - if os.path.basename(os.path.dirname(filename)) == "__pycache__": - return - - return trigger_reload(self, filename) - - WatchdogReloaderLoop.trigger_reload = custom_trigger_reload diff --git a/frappe/auth.py b/frappe/auth.py index a87edb6460..d4778eb0c1 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -111,7 +111,8 @@ class LoginManager: self.user_type = None if frappe.local.form_dict.get('cmd')=='login' or frappe.local.request.path=="/api/method/login": - if self.login()==False: return + if self.login() is False: + return self.resume = False # run login triggers diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index a8c75bffd9..90099eebb6 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -272,7 +272,7 @@ def apply(doc=None, method=None, doctype=None, name=None): for todo in todos_to_close: _todo = frappe.get_doc("ToDo", todo) _todo.status = "Closed" - _todo.save() + _todo.save(ignore_permissions=True) break else: diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json index fa2606dc43..40b265b34f 100644 --- a/frappe/automation/workspace/tools/tools.json +++ b/frappe/automation/workspace/tools/tools.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"ToDo\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Note\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"File\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Assignment Rule\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Auto Repeat\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Tools\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Automation\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Event Streaming\", \"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Note\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Auto Repeat\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Event Streaming\",\"col\":4}}]", "creation": "2020-03-02 14:53:24.980279", "docstatus": 0, "doctype": "Workspace", @@ -208,7 +208,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:02.839181", + "modified": "2022-01-13 17:48:48.456763", "modified_by": "Administrator", "module": "Automation", "name": "Tools", @@ -217,7 +217,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 26, + "sequence_id": 26.0, "shortcuts": [ { "label": "ToDo", diff --git a/frappe/boot.py b/frappe/boot.py index 6eb3b9d263..63221fe1be 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -7,6 +7,7 @@ bootstrap client session import frappe import frappe.defaults import frappe.desk.desk_page +from frappe.desk.doctype.route_history.route_history import frequently_visited_links from frappe.desk.form.load import get_meta_bundle from frappe.utils.change_log import get_versions from frappe.translate import get_lang_dict @@ -15,10 +16,9 @@ from frappe.social.doctype.energy_point_settings.energy_point_settings import is from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points 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 +from frappe.utils import get_time_zone, add_user_info def get_bootinfo(): """build and return boot info""" @@ -91,6 +91,7 @@ def get_bootinfo(): bootinfo.additional_filters_config = get_additional_filters_from_hooks() bootinfo.desk_settings = get_desk_settings() bootinfo.app_logo_url = get_app_logo() + bootinfo.link_title_doctypes = get_link_title_doctypes() return bootinfo @@ -109,8 +110,8 @@ def load_conf_settings(bootinfo): if key in conf: bootinfo[key] = conf.get(key) def load_desktop_data(bootinfo): - from frappe.desk.desktop import get_wspace_sidebar_items - bootinfo.allowed_workspaces = get_wspace_sidebar_items().get('pages') + from frappe.desk.desktop import get_workspace_sidebar_items + bootinfo.allowed_workspaces = get_workspace_sidebar_items().get('pages') bootinfo.module_page_map = get_controller("Workspace").get_module_page_map() bootinfo.dashboards = frappe.get_all("Dashboard") @@ -330,6 +331,16 @@ def get_country_codes(bootinfo): country_codes = get_all() bootinfo.country_codes = frappe._dict(country_codes) +@frappe.whitelist() +def get_link_title_doctypes(): + dts = frappe.get_all("DocType", {"show_title_field_in_link": 1}) + custom_dts = frappe.get_all( + "Property Setter", + {"property": "show_title_field_in_link", "value": "1"}, + ["doc_type as name"], + ) + return [d.name for d in dts + custom_dts if d] + def set_time_zone(bootinfo): bootinfo.time_zone = { "system": get_time_zone(), diff --git a/frappe/build.py b/frappe/build.py index 6b93b8b93a..7a06ee3a22 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -1,25 +1,21 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import os -import re -import json import shutil +import re import subprocess -from subprocess import getoutput -from io import StringIO -from tempfile import mkdtemp, mktemp from distutils.spawn import find_executable - -import frappe -from frappe.utils.minify import JavascriptMinify +from subprocess import getoutput +from tempfile import mkdtemp, mktemp +from urllib.parse import urlparse import click import psutil -from urllib.parse import urlparse -from semantic_version import Version from requests import head from requests.exceptions import HTTPError +from semantic_version import Version +import frappe timestamps = {} app_paths = None @@ -32,6 +28,7 @@ class AssetsNotDownloadedError(Exception): class AssetsDontExistError(HTTPError): pass + def download_file(url, prefix): from requests import get @@ -277,12 +274,14 @@ def check_node_executable(): click.echo(f"{warn} Please install yarn using below command and try again.\nnpm install -g yarn") click.echo() + def get_node_env(): node_env = { "NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}" } return node_env + def get_safe_max_old_space_size(): safe_max_old_space_size = 0 try: @@ -296,6 +295,7 @@ def get_safe_max_old_space_size(): return safe_max_old_space_size + def generate_assets_map(): symlinks = {} @@ -344,7 +344,6 @@ def clear_broken_symlinks(): os.remove(path) - def unstrip(message: str) -> str: """Pads input string on the right side until the last available column in the terminal """ @@ -397,94 +396,6 @@ def link_assets_dir(source, target, hard_link=False): symlink(source, target, overwrite=True) -def build(no_compress=False, verbose=False): - for target, sources in get_build_maps().items(): - pack(os.path.join(assets_path, target), sources, no_compress, verbose) - - -def get_build_maps(): - """get all build.jsons with absolute paths""" - # framework js and css files - - build_maps = {} - for app_path in app_paths: - path = os.path.join(app_path, "public", "build.json") - if os.path.exists(path): - with open(path) as f: - try: - for target, sources in (json.loads(f.read() or "{}")).items(): - # update app path - source_paths = [] - for source in sources: - if isinstance(source, list): - s = frappe.get_pymodule_path(source[0], *source[1].split("/")) - else: - s = os.path.join(app_path, source) - source_paths.append(s) - - build_maps[target] = source_paths - except ValueError as e: - print(path) - print("JSON syntax error {0}".format(str(e))) - return build_maps - - -def pack(target, sources, no_compress, verbose): - outtype, outtxt = target.split(".")[-1], "" - jsm = JavascriptMinify() - - for f in sources: - suffix = None - if ":" in f: - f, suffix = f.split(":") - if not os.path.exists(f) or os.path.isdir(f): - print("did not find " + f) - continue - timestamps[f] = os.path.getmtime(f) - try: - with open(f, "r") as sourcefile: - data = str(sourcefile.read(), "utf-8", errors="ignore") - - extn = f.rsplit(".", 1)[1] - - if ( - outtype == "js" - and extn == "js" - and (not no_compress) - and suffix != "concat" - and (".min." not in f) - ): - tmpin, tmpout = StringIO(data.encode("utf-8")), StringIO() - jsm.minify(tmpin, tmpout) - minified = tmpout.getvalue() - if minified: - outtxt += str(minified or "", "utf-8").strip("\n") + ";" - - if verbose: - print("{0}: {1}k".format(f, int(len(minified) / 1024))) - elif outtype == "js" and extn == "html": - # add to frappe.templates - outtxt += html_to_js_template(f, data) - else: - outtxt += "\n/*\n *\t%s\n */" % f - outtxt += "\n" + data + "\n" - - except Exception: - print("--Error in:" + f + "--") - print(frappe.get_traceback()) - - with open(target, "w") as f: - f.write(outtxt.encode("utf-8")) - - print("Wrote %s - %sk" % (target, str(int(os.path.getsize(target) / 1024)))) - - -def html_to_js_template(path, content): - """returns HTML template content as Javascript code, adding it to `frappe.templates`""" - return """frappe.templates["{key}"] = '{content}';\n""".format( - key=path.rsplit("/", 1)[-1][:-5], content=scrub_html_template(content)) - - def scrub_html_template(content): """Returns HTML content with removed whitespace and comments""" # remove whitespace to a single space @@ -496,37 +407,7 @@ def scrub_html_template(content): return content.replace("'", "\'") -def files_dirty(): - for target, sources in get_build_maps().items(): - for f in sources: - if ":" in f: - f, suffix = f.split(":") - if not os.path.exists(f) or os.path.isdir(f): - continue - if os.path.getmtime(f) != timestamps.get(f): - print(f + " dirty") - return True - else: - return False - - -def compile_less(): - if not find_executable("lessc"): - return - - for path in app_paths: - less_path = os.path.join(path, "public", "less") - if os.path.exists(less_path): - for fname in os.listdir(less_path): - if fname.endswith(".less") and fname != "variables.less": - fpath = os.path.join(less_path, fname) - mtime = os.path.getmtime(fpath) - if fpath in timestamps and mtime == timestamps[fpath]: - continue - - timestamps[fpath] = mtime - - print("compiling {0}".format(fpath)) - - css_path = os.path.join(path, "public", "css", fname.rsplit(".", 1)[0] + ".css") - os.system("lessc {0} > {1}".format(fpath, css_path)) +def html_to_js_template(path, content): + """returns HTML template content as Javascript code, adding it to `frappe.templates`""" + return """frappe.templates["{key}"] = '{content}';\n""".format( + key=path.rsplit("/", 1)[-1][:-5], content=scrub_html_template(content)) diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 0df8878da4..94a845639b 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -148,7 +148,7 @@ def build_table_count_cache(): data = ( frappe.qb.from_(information_schema.tables).select(table_name, table_rows) ).run(as_dict=True) - counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data} + counts = {d.get('name').replace('tab', '', 1): d.get('count', None) for d in data} _cache.set_value("information_schema:counts", counts) return counts diff --git a/frappe/client.py b/frappe/client.py index e835e7fee7..1898994afe 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -99,7 +99,6 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren if not filters: filters = None - if frappe.get_meta(doctype).issingle: value = frappe.db.get_values_from_single(fields, filters, doctype, as_dict=as_dict, debug=debug) else: @@ -129,7 +128,7 @@ def set_value(doctype, name, fieldname, value=None): :param fieldname: fieldname string or JSON / dict with key value pair :param value: value if fieldname is JSON / dict''' - if fieldname!="idx" and fieldname in frappe.model.default_fields: + if fieldname in (frappe.model.default_fields + frappe.model.child_table_fields): frappe.throw(_("Cannot edit standard fields")) if not value: @@ -142,14 +141,15 @@ def set_value(doctype, name, fieldname, value=None): else: values = {fieldname: value} - doc = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True) - if doc and doc.parent and doc.parenttype: + # check for child table doctype + if not frappe.get_meta(doctype).istable: + doc = frappe.get_doc(doctype, name) + doc.update(values) + else: + doc = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True) doc = frappe.get_doc(doc.parenttype, doc.parent) child = doc.getone({"doctype": doctype, "name": name}) child.update(values) - else: - doc = frappe.get_doc(doctype, name) - doc.update(values) doc.save() @@ -163,10 +163,10 @@ def insert(doc=None): if isinstance(doc, str): doc = json.loads(doc) - if doc.get("parent") and doc.get("parenttype"): + if doc.get("parenttype"): # inserting a child record - parent = frappe.get_doc(doc.get("parenttype"), doc.get("parent")) - parent.append(doc.get("parentfield"), doc) + parent = frappe.get_doc(doc.parenttype, doc.parent) + parent.append(doc.parentfield, doc) parent.save() return parent.as_dict() else: @@ -187,10 +187,10 @@ def insert_many(docs=None): frappe.throw(_('Only 200 inserts allowed in one request')) for doc in docs: - if doc.get("parent") and doc.get("parenttype"): + if doc.get("parenttype"): # inserting a child record - parent = frappe.get_doc(doc.get("parenttype"), doc.get("parent")) - parent.append(doc.get("parentfield"), doc) + parent = frappe.get_doc(doc.parenttype, doc.parent) + parent.append(doc.parentfield, doc) parent.save() out.append(parent.name) else: diff --git a/frappe/commands/site.py b/frappe/commands/site.py old mode 100755 new mode 100644 index 677325e02d..63da4db093 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -1,7 +1,7 @@ # imports - standard imports import os -import sys import shutil +import sys # imports - third party imports import click @@ -19,36 +19,38 @@ from frappe.exceptions import SiteNotSpecifiedError @click.option('--db-type', default='mariadb', type=click.Choice(['mariadb', 'postgres']), help='Optional "postgres" or "mariadb". Default is "mariadb"') @click.option('--db-host', help='Database Host') @click.option('--db-port', type=int, help='Database Port') -@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB') -@click.option('--mariadb-root-password', help='Root password for MariaDB') +@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"') +@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL') @click.option('--no-mariadb-socket', is_flag=True, default=False, help='Set MariaDB host to % and use TCP/IP Socket instead of using the UNIX Socket') @click.option('--admin-password', help='Administrator password for new site', default=None) @click.option('--verbose', is_flag=True, default=False, help='Verbose') @click.option('--force', help='Force restore if site/database already exists', is_flag=True, default=False) @click.option('--source_sql', help='Initiate database with a SQL file') @click.option('--install-app', multiple=True, help='Install app after installation') -def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin_password=None, - verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False, - install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None): +@click.option('--set-default', is_flag=True, default=False, help='Set the new site as default site') +def new_site(site, db_root_username=None, db_root_password=None, admin_password=None, + verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False, + install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None, + set_default=False): "Create a new site" from frappe.installer import _new_site frappe.init(site=site, new_site=True) - _new_site(db_name, site, mariadb_root_username=mariadb_root_username, - mariadb_root_password=mariadb_root_password, admin_password=admin_password, - verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force, - no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host, - db_port=db_port, new_site=True) + _new_site(db_name, site, db_root_username=db_root_username, + db_root_password=db_root_password, admin_password=admin_password, + verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force, + no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host, + db_port=db_port, new_site=True) - if len(frappe.utils.get_sites()) == 1: + if set_default: use(site) @click.command('restore') @click.argument('sql-file-path') -@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB') -@click.option('--mariadb-root-password', help='Root password for MariaDB') +@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"') +@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL') @click.option('--db-name', help='Database name for site in case it is a new one') @click.option('--admin-password', help='Administrator password for new site') @click.option('--install-app', multiple=True, help='Install app after installation') @@ -57,17 +59,17 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin @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, encryption_key=None, mariadb_root_username=None, mariadb_root_password=None, +def restore(context, sql_file_path, encryption_key=None, db_root_username=None, db_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, - extract_sql_from_archive, extract_files, + extract_sql_from_archive, is_downgrade, is_partial, - validate_database_sql + validate_database_sql, ) from frappe.utils.backups import Backup if not os.path.exists(sql_file_path): @@ -150,8 +152,8 @@ def restore(context, sql_file_path, encryption_key=None, mariadb_root_username=N try: - _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, - mariadb_root_password=mariadb_root_password, admin_password=admin_password, + _new_site(frappe.conf.db_name, site, db_root_username=db_root_username, + db_root_password=db_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) @@ -205,7 +207,7 @@ def restore(context, sql_file_path, encryption_key=None, mariadb_root_username=N @click.option('--encryption-key', help='Backup encryption key') @pass_context def partial_restore(context, sql_file_path, verbose, encryption_key=None): - from frappe.installer import partial_restore, extract_sql_from_archive + from frappe.installer import extract_sql_from_archive, partial_restore from frappe.utils.backups import Backup if not os.path.exists(sql_file_path): @@ -290,16 +292,16 @@ def partial_restore(context, sql_file_path, verbose, encryption_key=None): @click.command('reinstall') @click.option('--admin-password', help='Administrator Password for reinstalled site') -@click.option('--mariadb-root-username', help='Root username for MariaDB') -@click.option('--mariadb-root-password', help='Root password for MariaDB') +@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"') +@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL') @click.option('--yes', is_flag=True, default=False, help='Pass --yes to skip confirmation') @pass_context -def reinstall(context, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False): +def reinstall(context, admin_password=None, db_root_username=None, db_root_password=None, yes=False): "Reinstall site ie. wipe all data and start over" site = get_site(context) - _reinstall(site, admin_password, mariadb_root_username, mariadb_root_password, yes, verbose=context.verbose) + _reinstall(site, admin_password, db_root_username, db_root_password, yes, verbose=context.verbose) -def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False, verbose=False): +def _reinstall(site, admin_password=None, db_root_username=None, db_root_password=None, yes=False, verbose=False): from frappe.installer import _new_site if not yes: @@ -319,7 +321,7 @@ def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_ro frappe.init(site=site) _new_site(frappe.conf.db_name, site, verbose=verbose, force=True, reinstall=True, install_apps=installed, - mariadb_root_username=mariadb_root_username, mariadb_root_password=mariadb_root_password, + db_root_username=db_root_username, db_root_password=db_root_password, admin_password=admin_password) @click.command('install-app') @@ -447,21 +449,17 @@ 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" - from frappe.migrate import migrate + from frappe.migrate import SiteMigration for site in context.sites: click.secho(f"Migrating {site}", fg="green") - frappe.init(site=site) - frappe.connect() try: - migrate( - context.verbose, + SiteMigration( skip_failing=skip_failing, - skip_search_index=skip_search_index - ) + skip_search_index=skip_search_index, + ).run(site=site) finally: print() - frappe.destroy() if not context.sites: raise SiteNotSpecifiedError @@ -547,7 +545,7 @@ def _use(site, sites_path='.'): def use(site, sites_path='.'): if os.path.exists(os.path.join(sites_path, site)): - with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile: + with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile: sitefile.write(site) print("Current Site set to {}".format(site)) else: @@ -660,16 +658,16 @@ def uninstall(context, app, dry_run, yes, no_backup, force): @click.command('drop-site') @click.argument('site') -@click.option('--root-login', default='root') -@click.option('--root-password') +@click.option('--db-root-username', '--mariadb-root-username', '--root-login', help='Root username for MariaDB or PostgreSQL, Default is "root"') +@click.option('--db-root-password', '--mariadb-root-password', '--root-password', help='Root password for MariaDB or PostgreSQL') @click.option('--archived-sites-path') @click.option('--no-backup', is_flag=True, default=False) @click.option('--force', help='Force drop-site even if an error is encountered', is_flag=True, default=False) -def drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False): - _drop_site(site, root_login, root_password, archived_sites_path, force, no_backup) +def drop_site(site, db_root_username='root', db_root_password=None, archived_sites_path=None, force=False, no_backup=False): + _drop_site(site, db_root_username, db_root_password, archived_sites_path, force, no_backup) -def _drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False): +def _drop_site(site, db_root_username=None, db_root_password=None, archived_sites_path=None, force=False, no_backup=False): "Remove site from database and filesystem" from frappe.database import drop_user_and_database from frappe.utils.backups import scheduled_backup @@ -679,7 +677,9 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= try: if not no_backup: - scheduled_backup(ignore_files=False, force=True) + click.secho(f"Taking backup of {site}", fg="green") + odb = scheduled_backup(ignore_files=False, force=True, verbose=True) + odb.print_summary() except Exception as err: if force: pass @@ -694,7 +694,8 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= click.echo("\n".join(messages)) sys.exit(1) - drop_user_and_database(frappe.conf.db_name, root_login, root_password) + click.secho("Dropping site database and user", fg="green") + drop_user_and_database(frappe.conf.db_name, db_root_username, db_root_password) archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites') @@ -753,6 +754,7 @@ def set_admin_password(context, admin_password=None, logout_all_sessions=False): def set_user_password(site, user, password, logout_all_sessions=False): import getpass + from frappe.utils.password import update_password try: @@ -883,15 +885,16 @@ def stop_recording(context): raise SiteNotSpecifiedError @click.command('ngrok') +@click.option('--bind-tls', is_flag=True, default=False, help='Returns a reference to the https tunnel.') @pass_context -def start_ngrok(context): +def start_ngrok(context, bind_tls): from pyngrok import ngrok site = get_site(context) frappe.init(site=site) port = frappe.conf.http_port or frappe.conf.webserver_port - tunnel = ngrok.connect(addr=str(port), host_header=site) + tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls) print(f'Public URL: {tunnel.public_url}') print('Inspect logs at http://localhost:4040') @@ -952,7 +955,7 @@ def trim_database(context, dry_run, format, no_backup): doctype_tables = frappe.get_all("DocType", pluck="name") for x in database_tables: - doctype = x.lstrip("tab") + doctype = x.replace("tab", "", 1) if not (doctype in doctype_tables or x.startswith("__") or x in STANDARD_TABLES): TABLES_TO_DROP.append(x) @@ -966,7 +969,7 @@ def trim_database(context, dry_run, format, no_backup): odb = scheduled_backup( ignore_conf=False, - include_doctypes=",".join(x.lstrip("tab") for x in TABLES_TO_DROP), + include_doctypes=",".join(x.replace("tab", "", 1) for x in TABLES_TO_DROP), ignore_files=True, force=True, ) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 41b607b192..c0bb44efab 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -623,6 +623,7 @@ def transform_database(context, table, engine, row_format, failfast): @click.command('run-tests') @click.option('--app', help="For App") @click.option('--doctype', help="For DocType") +@click.option('--case', help="Select particular TestCase") @click.option('--doctype-list-path', help="Path to .txt file for list of doctypes. Example erpnext/tests/server/agriculture.txt") @click.option('--test', multiple=True, help="Specific test") @click.option('--ui-tests', is_flag=True, default=False, help="Run UI Tests") @@ -636,9 +637,10 @@ def transform_database(context, table, engine, row_format, failfast): @pass_context def run_tests(context, app=None, module=None, doctype=None, test=(), profile=False, coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None, - skip_test_records=False, skip_before_tests=False, failfast=False): + skip_test_records=False, skip_before_tests=False, failfast=False, case=None): with CodeCoverage(coverage, app): + import frappe import frappe.test_runner tests = test site = get_site(context) @@ -658,7 +660,7 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, force=context.force, profile=profile, junit_xml_output=junit_xml_output, - ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast) + ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast, case=case) if len(ret.failures) == 0 and len(ret.errors) == 0: ret = 0 @@ -741,8 +743,9 @@ def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=Fals @click.option('--profile', is_flag=True, default=False) @click.option('--noreload', "no_reload", is_flag=True, default=False) @click.option('--nothreading', "no_threading", is_flag=True, default=False) +@click.option('--with-coverage', is_flag=True, default=False) @pass_context -def serve(context, port=None, profile=False, no_reload=False, no_threading=False, sites_path='.', site=None): +def serve(context, port=None, profile=False, no_reload=False, no_threading=False, sites_path='.', site=None, with_coverage=False): "Start development web server" import frappe.app @@ -750,8 +753,12 @@ def serve(context, port=None, profile=False, no_reload=False, no_threading=False site = None else: site = context.sites[0] - - frappe.app.serve(port=port, profile=profile, no_reload=no_reload, no_threading=no_threading, site=site, sites_path='.') + with CodeCoverage(with_coverage, 'frappe'): + if with_coverage: + # unable to track coverage with threading enabled + no_threading = True + no_reload = True + frappe.app.serve(port=port, profile=profile, no_reload=no_reload, no_threading=no_threading, site=site, sites_path='.') @click.command('request') diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py index cd9af498aa..33672a7dea 100644 --- a/frappe/core/doctype/comment/test_comment.py +++ b/frappe/core/doctype/comment/test_comment.py @@ -70,6 +70,19 @@ class TestComment(unittest.TestCase): reference_name = test_blog.name ))), 0) + # test for filtering html and css injection elements + frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) + + frappe.form_dict.comment = 'Comment' + frappe.form_dict.comment_by = 'hacker' + + add_comment() + + self.assertEqual(frappe.get_all('Comment', fields = ['content'], filters = dict( + reference_doctype = test_blog.doctype, + reference_name = test_blog.name + ))[0]['content'], 'Comment') + test_blog.delete() diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 1ab07d92e4..475762f39d 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE from collections import Counter +from typing import List import frappe from frappe import _ from frappe.model.document import Document @@ -17,6 +18,7 @@ from urllib.parse import unquote from frappe.utils.user import is_system_user from frappe.contacts.doctype.contact.contact import get_contact_name from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule +from parse import compile exclude_from_linked_with = True @@ -113,6 +115,44 @@ class Communication(Document, CommunicationEmailMixin): frappe.publish_realtime('new_message', self.as_dict(), user=self.reference_name, after_commit=True) + def set_signature_in_email_content(self): + """Set sender's User.email_signature or default outgoing's EmailAccount.signature to the email + """ + if not self.content: + return + + quill_parser = compile('
{}
') + email_body = quill_parser.parse(self.content) + + if not email_body: + return + + email_body = email_body[0] + + user_email_signature = frappe.db.get_value( + "User", + self.sender, + "email_signature", + ) if self.sender else None + + signature = user_email_signature or frappe.db.get_value( + "Email Account", + {"default_outgoing": 1, "add_signature": 1}, + "signature", + ) + + if not signature: + return + + _signature = quill_parser.parse(signature)[0] if "ql-editor" in signature else None + + if (_signature or signature) not in self.content: + self.content = f'{self.content}


{signature}' + + def before_save(self): + if not self.flags.skip_add_signature: + self.set_signature_in_email_content() + def on_update(self): # add to _comment property of the doctype, so it shows up in # comments count for the list view @@ -367,15 +407,8 @@ def get_permission_query_conditions_for_communication(user): return """`tabCommunication`.email_account in ({email_accounts})"""\ .format(email_accounts=','.join(email_accounts)) -def get_contacts(email_strings, auto_create_contact=False): - email_addrs = [] - - for email_string in email_strings: - if email_string: - result = getaddresses([email_string]) - for email in result: - email_addrs.append(email[1]) - +def get_contacts(email_strings: List[str], auto_create_contact=False) -> List[str]: + email_addrs = get_emails(email_strings) contacts = [] for email in email_addrs: email = get_email_without_link(email) @@ -404,6 +437,17 @@ def get_contacts(email_strings, auto_create_contact=False): return contacts +def get_emails(email_strings: List[str]) -> List[str]: + email_addrs = [] + + for email_string in email_strings: + if email_string: + result = getaddresses([email_string]) + for email in result: + email_addrs.append(email[1]) + + return email_addrs + def add_contact_links_to_communication(communication, contact_name): contact_links = frappe.get_all("Dynamic Link", filters={ "parenttype": "Contact", @@ -449,8 +493,12 @@ def get_email_without_link(email): if not frappe.get_all("Email Account", filters={"enable_automatic_linking": 1}): return email - email_id = email.split("@")[0].split("+")[0] - email_host = email.split("@")[1] + try: + _email = email.split("@") + email_id = _email[0].split("+")[0] + email_host = _email[1] + except IndexError: + return email return "{0}@{1}".format(email_id, email_host) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 54ddbce2c4..b51749ccb7 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -1,30 +1,51 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe import json -from email.utils import formataddr -from frappe.core.utils import get_parent_doc -from frappe.utils import (get_url, get_formatted_email, cint, list_to_str, - validate_email_address, split_emails, parse_addr, get_datetime) -from frappe.email.email_body import get_message_id +from typing import TYPE_CHECKING, Dict + +import frappe import frappe.email.smtp -import time from frappe import _ -from frappe.utils.background_jobs import enqueue +from frappe.email.email_body import get_message_id +from frappe.utils import (cint, get_datetime, get_formatted_email, + list_to_str, split_emails, validate_email_address) + +if TYPE_CHECKING: + from frappe.core.doctype.communication.communication import Communication + OUTGOING_EMAIL_ACCOUNT_MISSING = _(""" Unable to send mail because of a missing email account. Please setup default Email Account from Setup > Email > Email Account """) + @frappe.whitelist() -def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent", - sender=None, sender_full_name=None, recipients=None, communication_medium="Email", send_email=False, - print_html=None, print_format=None, attachments='[]', send_me_a_copy=False, cc=None, bcc=None, - flags=None, read_receipt=None, print_letterhead=True, email_template=None, communication_type=None, - ignore_permissions=False): - """Make a new communication. +def make( + doctype=None, + name=None, + content=None, + subject=None, + sent_or_received="Sent", + sender=None, + sender_full_name=None, + recipients=None, + communication_medium="Email", + send_email=False, + print_html=None, + print_format=None, + attachments="[]", + send_me_a_copy=False, + cc=None, + bcc=None, + read_receipt=None, + print_letterhead=True, + email_template=None, + communication_type=None, + **kwargs, +) -> Dict[str, str]: + """Make a new communication. Checks for email permissions for specified Document. :param doctype: Reference DocType. :param name: Reference Document name. @@ -41,22 +62,76 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = :param send_me_a_copy: Send a copy to the sender (default **False**). :param email_template: Template which is used to compose mail . """ - is_error_report = (doctype=="User" and name==frappe.session.user and subject=="Error Report") - send_me_a_copy = cint(send_me_a_copy) + if kwargs: + from frappe.utils.commands import warn + warn( + f"Options {kwargs} used in frappe.core.doctype.communication.email.make " + "are deprecated or unsupported", + category=DeprecationWarning + ) - if not ignore_permissions: - if doctype and name and not is_error_report and not frappe.has_permission(doctype, "email", name) and not (flags or {}).get('ignore_doctype_permissions'): - raise frappe.PermissionError("You are not allowed to send emails related to: {doctype} {name}".format( - doctype=doctype, name=name)) + if doctype and name and not frappe.has_permission(doctype=doctype, ptype="email", doc=name): + raise frappe.PermissionError( + f"You are not allowed to send emails related to: {doctype} {name}" + ) - if not sender: - sender = get_formatted_email(frappe.session.user) + return _make( + doctype=doctype, + name=name, + content=content, + subject=subject, + sent_or_received=sent_or_received, + sender=sender, + sender_full_name=sender_full_name, + recipients=recipients, + communication_medium=communication_medium, + send_email=send_email, + print_html=print_html, + print_format=print_format, + attachments=attachments, + send_me_a_copy=cint(send_me_a_copy), + cc=cc, + bcc=bcc, + read_receipt=read_receipt, + print_letterhead=print_letterhead, + email_template=email_template, + communication_type=communication_type, + add_signature=False, + ) + +def _make( + doctype=None, + name=None, + content=None, + subject=None, + sent_or_received="Sent", + sender=None, + sender_full_name=None, + recipients=None, + communication_medium="Email", + send_email=False, + print_html=None, + print_format=None, + attachments="[]", + send_me_a_copy=False, + cc=None, + bcc=None, + read_receipt=None, + print_letterhead=True, + email_template=None, + communication_type=None, + add_signature=True, +) -> Dict[str, str]: + """Internal method to make a new communication that ignores Permission checks. + """ + + sender = sender or get_formatted_email(frappe.session.user) recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients cc = list_to_str(cc) if isinstance(cc, list) else cc bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc - comm = frappe.get_doc({ + comm: "Communication" = frappe.get_doc({ "doctype":"Communication", "subject": subject, "content": content, @@ -73,32 +148,36 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "message_id":get_message_id().strip(" <>"), "read_receipt":read_receipt, "has_attachment": 1 if attachments else 0, - "communication_type": communication_type - }).insert(ignore_permissions=True) - - comm.save(ignore_permissions=True) - - if isinstance(attachments, str): - attachments = json.loads(attachments) + "communication_type": communication_type, + }) + comm.flags.skip_add_signature = not add_signature + comm.insert(ignore_permissions=True) # if not committed, delayed task doesn't find the communication if attachments: + if isinstance(attachments, str): + attachments = json.loads(attachments) add_attachments(comm.name, attachments) if cint(send_email): if not comm.get_outgoing_email_account(): - frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError) + frappe.throw( + msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError + ) - comm.send_email(print_html=print_html, print_format=print_format, - send_me_a_copy=send_me_a_copy, print_letterhead=print_letterhead) + comm.send_email( + print_html=print_html, + print_format=print_format, + send_me_a_copy=send_me_a_copy, + print_letterhead=print_letterhead, + ) emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) - return { - "name": comm.name, - "emails_not_sent_to": ", ".join(emails_not_sent_to or []) - } -def validate_email(doc): + return {"name": comm.name, "emails_not_sent_to": ", ".join(emails_not_sent_to)} + + +def validate_email(doc: "Communication") -> None: """Validate Email Addresses of Recipients and CC""" if not (doc.communication_type=="Communication" and doc.communication_medium == "Email") or doc.flags.in_receive: return @@ -114,8 +193,6 @@ def validate_email(doc): for email in split_emails(doc.bcc): validate_email_address(email, throw=True) - # validate sender - def set_incoming_outgoing_accounts(doc): from frappe.email.doctype.email_account.email_account import EmailAccount incoming_email_account = EmailAccount.find_incoming( diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index b6d8070d00..dd9f58342e 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -1,3 +1,4 @@ +from typing import List import frappe from frappe import _ from frappe.core.utils import get_parent_doc @@ -194,14 +195,18 @@ class CommunicationEmailMixin: return _("Leave this conversation") return '' - def exclude_emails_list(self, is_inbound_mail_communcation=False, include_sender=False): + def exclude_emails_list(self, is_inbound_mail_communcation=False, include_sender=False) -> List: """List of mail id's excluded while sending mail. """ all_ids = self.get_all_email_addresses(exclude_displayname=True) - final_ids = self.mail_recipients(is_inbound_mail_communcation = is_inbound_mail_communcation) + \ - self.mail_bcc(is_inbound_mail_communcation = is_inbound_mail_communcation) + \ - self.mail_cc(is_inbound_mail_communcation = is_inbound_mail_communcation, include_sender=include_sender) - return set(all_ids) - set(final_ids) + + final_ids = ( + self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation) + + self.mail_bcc(is_inbound_mail_communcation=is_inbound_mail_communcation) + + self.mail_cc(is_inbound_mail_communcation=is_inbound_mail_communcation, include_sender=include_sender) + ) + + return list(set(all_ids) - set(final_ids)) def get_assignees(self): """Get owners of the reference document. diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index f26e70771b..8012d8facf 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -4,6 +4,7 @@ import unittest from urllib.parse import quote import frappe +from frappe.core.doctype.communication.communication import get_emails from frappe.email.doctype.email_queue.email_queue import EmailQueue test_records = frappe.get_test_records('Communication') @@ -201,6 +202,19 @@ class TestCommunication(unittest.TestCase): self.assertIn(("Note", note.name), doc_links) + def test_parse_emails(self): + emails = get_emails( + [ + 'comm_recipient+DocType+DocName@example.com', + '"First, LastName" ', + 'test@user.com' + ] + ) + + self.assertEqual(emails[0], "comm_recipient+DocType+DocName@example.com") + self.assertEqual(emails[1], "first.lastname@email.com") + self.assertEqual(emails[2], "test@user.com") + class TestCommunicationEmailMixin(unittest.TestCase): def new_communication(self, recipients=None, cc=None, bcc=None): recipients = ', '.join(recipients or []) diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py index 79570d5048..9f1492af19 100644 --- a/frappe/core/doctype/data_export/exporter.py +++ b/frappe/core/doctype/data_export/exporter.py @@ -324,7 +324,7 @@ class DataExporter: d = doc.copy() meta = frappe.get_meta(dt) if self.all_doctypes: - d.name = '"'+ d.name+'"' + d.name = f'"{d.name}"' if len(rows) < rowidx + 1: rows.append([""] * (len(self.columns) + 1)) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 216db53c72..dfc560a98a 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -44,6 +44,7 @@ frappe.ui.form.on('Data Import', { } frm.dashboard.show_progress(__('Import Progress'), percent, message); frm.page.set_indicator(__('In Progress'), 'orange'); + frm.trigger('update_primary_action'); // hide progress when complete if (data.current === data.total) { @@ -80,7 +81,10 @@ frappe.ui.form.on('Data Import', { frm.trigger('show_import_log'); frm.trigger('show_import_warnings'); frm.trigger('toggle_submit_after_import'); - frm.trigger('show_import_status'); + + if (frm.doc.status != 'Pending') + frm.trigger('show_import_status'); + frm.trigger('show_report_error_button'); if (frm.doc.status === 'Partial Success') { @@ -128,40 +132,49 @@ frappe.ui.form.on('Data Import', { }, show_import_status(frm) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - let successful_records = import_log.filter(log => log.success); - let failed_records = import_log.filter(log => !log.success); - if (successful_records.length === 0) return; + frappe.call({ + 'method': 'frappe.core.doctype.data_import.data_import.get_import_status', + 'args': { + 'data_import_name': frm.doc.name + }, + 'callback': function(r) { + let successful_records = cint(r.message.success); + let failed_records = cint(r.message.failed); + let total_records = cint(r.message.total_records); - let message; - if (failed_records.length === 0) { - let message_args = [successful_records.length]; - if (frm.doc.import_type === 'Insert New Records') { - message = - successful_records.length > 1 - ? __('Successfully imported {0} records.', message_args) - : __('Successfully imported {0} record.', message_args); - } else { - message = - successful_records.length > 1 - ? __('Successfully updated {0} records.', message_args) - : __('Successfully updated {0} record.', message_args); + if (!total_records) return; + + let message; + if (failed_records === 0) { + let message_args = [successful_records]; + if (frm.doc.import_type === 'Insert New Records') { + message = + successful_records > 1 + ? __('Successfully imported {0} records.', message_args) + : __('Successfully imported {0} record.', message_args); + } else { + message = + successful_records > 1 + ? __('Successfully updated {0} records.', message_args) + : __('Successfully updated {0} record.', message_args); + } + } else { + let message_args = [successful_records, total_records]; + if (frm.doc.import_type === 'Insert New Records') { + message = + successful_records > 1 + ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) + : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); + } else { + message = + successful_records > 1 + ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) + : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); + } + } + frm.dashboard.set_headline(message); } - } else { - let message_args = [successful_records.length, import_log.length]; - if (frm.doc.import_type === 'Insert New Records') { - message = - successful_records.length > 1 - ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) - : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); - } else { - message = - successful_records.length > 1 - ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) - : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); - } - } - frm.dashboard.set_headline(message); + }); }, show_report_error_button(frm) { @@ -275,7 +288,7 @@ frappe.ui.form.on('Data Import', { }, show_import_preview(frm, preview_data) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); + let import_log = preview_data.import_log; if ( frm.import_preview && @@ -316,6 +329,15 @@ frappe.ui.form.on('Data Import', { ); }, + export_import_log(frm) { + open_url_post( + '/api/method/frappe.core.doctype.data_import.data_import.download_import_log', + { + data_import_name: frm.doc.name + } + ); + }, + show_import_warnings(frm, preview_data) { let columns = preview_data.columns; let warnings = JSON.parse(frm.doc.template_warnings || '[]'); @@ -391,92 +413,131 @@ frappe.ui.form.on('Data Import', { frm.trigger('show_import_log'); }, - show_import_log(frm) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - let logs = import_log; - frm.toggle_display('import_log', false); - frm.toggle_display('import_log_section', logs.length > 0); + render_import_log(frm) { + frappe.call({ + 'method': 'frappe.client.get_list', + 'args': { + 'doctype': 'Data Import Log', + 'filters': { + 'data_import': frm.doc.name + }, + 'fields': ['success', 'docname', 'messages', 'exception', 'row_indexes'], + 'limit_page_length': 5000, + 'order_by': 'log_index' + }, + callback: function(r) { + let logs = r.message; - if (logs.length === 0) { - frm.get_field('import_log_preview').$wrapper.empty(); + if (logs.length === 0) return; + + frm.toggle_display('import_log_section', true); + + let rows = logs + .map(log => { + let html = ''; + if (log.success) { + if (frm.doc.import_type === 'Insert New Records') { + html = __('Successfully imported {0}', [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}` + ]); + } else { + html = __('Successfully updated {0}', [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}` + ]); + } + } else { + let messages = (JSON.parse(log.messages || '[]')) + .map(JSON.parse) + .map(m => { + let title = m.title ? `${m.title}` : ''; + let message = m.message ? `

${m.message}
` : ''; + return title + message; + }) + .join(''); + let id = frappe.dom.get_unique_id(); + html = `${messages} + +
+
+
${log.exception}
+
+
`; + } + let indicator_color = log.success ? 'green' : 'red'; + let title = log.success ? __('Success') : __('Failure'); + + if (frm.doc.show_failed_logs && log.success) { + return ''; + } + + return ` + ${JSON.parse(log.row_indexes).join(', ')} + +
${title}
+ + + ${html} + + `; + }) + .join(''); + + if (!rows && frm.doc.show_failed_logs) { + rows = ` + ${__('No failed logs')} + `; + } + + frm.get_field('import_log_preview').$wrapper.html(` + + + + + + + ${rows} +
${__('Row Number')}${__('Status')}${__('Message')}
+ `); + } + }); + }, + + show_import_log(frm) { + frm.toggle_display('import_log_section', false); + + if (frm.import_in_progress) { return; } - let rows = logs - .map(log => { - let html = ''; - if (log.success) { - if (frm.doc.import_type === 'Insert New Records') { - html = __('Successfully imported {0}', [ - `${frappe.utils.get_form_link( - frm.doc.reference_doctype, - log.docname, - true - )}` - ]); - } else { - html = __('Successfully updated {0}', [ - `${frappe.utils.get_form_link( - frm.doc.reference_doctype, - log.docname, - true - )}` - ]); - } + frappe.call({ + 'method': 'frappe.client.get_count', + 'args': { + 'doctype': 'Data Import Log', + 'filters': { + 'data_import': frm.doc.name + } + }, + 'callback': function(r) { + let count = r.message; + if (count < 5000) { + frm.trigger('render_import_log'); } else { - let messages = log.messages - .map(JSON.parse) - .map(m => { - let title = m.title ? `${m.title}` : ''; - let message = m.message ? `
${m.message}
` : ''; - return title + message; - }) - .join(''); - let id = frappe.dom.get_unique_id(); - html = `${messages} - -
-
-
${log.exception}
-
-
`; + frm.toggle_display('import_log_section', false); + frm.add_custom_button(__('Export Import Log'), () => + frm.trigger('export_import_log') + ); } - let indicator_color = log.success ? 'green' : 'red'; - let title = log.success ? __('Success') : __('Failure'); - - if (frm.doc.show_failed_logs && log.success) { - return ''; - } - - return ` - ${log.row_indexes.join(', ')} - -
${title}
- - - ${html} - - `; - }) - .join(''); - - if (!rows && frm.doc.show_failed_logs) { - rows = ` - ${__('No failed logs')} - `; - } - - frm.get_field('import_log_preview').$wrapper.html(` - - - - - - - ${rows} -
${__('Row Number')}${__('Status')}${__('Message')}
- `); + } + }); }, }); diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index fe6fb90481..9e948dac8c 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -1,194 +1,197 @@ { - "actions": [], - "autoname": "format:{reference_doctype} Import on {creation}", - "beta": 1, - "creation": "2019-08-04 14:16:08.318714", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "reference_doctype", - "import_type", - "download_template", - "import_file", - "html_5", - "google_sheets_url", - "refresh_google_sheet", - "column_break_5", - "status", - "submit_after_import", - "mute_emails", - "template_options", - "import_warnings_section", - "template_warnings", - "import_warnings", - "section_import_preview", - "import_preview", - "import_log_section", - "import_log", - "show_failed_logs", - "import_log_preview" - ], - "fields": [ - { - "fieldname": "reference_doctype", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Document Type", - "options": "DocType", - "reqd": 1, - "set_only_once": 1 - }, - { - "fieldname": "import_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Import Type", - "options": "\nInsert New Records\nUpdate Existing Records", - "reqd": 1, - "set_only_once": 1 - }, - { - "depends_on": "eval:!doc.__islocal", - "fieldname": "import_file", - "fieldtype": "Attach", - "in_list_view": 1, - "label": "Import File", - "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" - }, - { - "fieldname": "import_preview", - "fieldtype": "HTML", - "label": "Import Preview" - }, - { - "fieldname": "section_import_preview", - "fieldtype": "Section Break", - "label": "Preview" - }, - { - "fieldname": "column_break_5", - "fieldtype": "Column Break" - }, - { - "fieldname": "template_options", - "fieldtype": "Code", - "hidden": 1, - "label": "Template Options", - "options": "JSON", - "read_only": 1 - }, - { - "fieldname": "import_log", - "fieldtype": "Code", - "label": "Import Log", - "options": "JSON" - }, - { - "fieldname": "import_log_section", - "fieldtype": "Section Break", - "label": "Import Log" - }, - { - "fieldname": "import_log_preview", - "fieldtype": "HTML", - "label": "Import Log Preview" - }, - { - "default": "Pending", - "fieldname": "status", - "fieldtype": "Select", - "hidden": 1, - "label": "Status", - "options": "Pending\nSuccess\nPartial Success\nError", - "read_only": 1 - }, - { - "fieldname": "template_warnings", - "fieldtype": "Code", - "hidden": 1, - "label": "Template Warnings", - "options": "JSON" - }, - { - "default": "0", - "fieldname": "submit_after_import", - "fieldtype": "Check", - "label": "Submit After Import", - "set_only_once": 1 - }, - { - "fieldname": "import_warnings_section", - "fieldtype": "Section Break", - "label": "Import File Errors and Warnings" - }, - { - "fieldname": "import_warnings", - "fieldtype": "HTML", - "label": "Import Warnings" - }, - { - "depends_on": "eval:!doc.__islocal", - "fieldname": "download_template", - "fieldtype": "Button", - "label": "Download Template" - }, - { - "default": "1", - "fieldname": "mute_emails", - "fieldtype": "Check", - "label": "Don't Send Emails", - "set_only_once": 1 - }, - { - "default": "0", - "fieldname": "show_failed_logs", - "fieldtype": "Check", - "label": "Show Failed Logs" - }, - { - "depends_on": "eval:!doc.__islocal && !doc.import_file", - "fieldname": "html_5", - "fieldtype": "HTML", - "options": "
Or
" - }, - { - "depends_on": "eval:!doc.__islocal && !doc.import_file\n", - "description": "Must be a publicly accessible Google Sheets URL", - "fieldname": "google_sheets_url", - "fieldtype": "Data", - "label": "Import from Google Sheets", - "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" - }, - { - "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)", - "fieldname": "refresh_google_sheet", - "fieldtype": "Button", - "label": "Refresh Google Sheet" - } - ], - "hide_toolbar": 1, - "links": [], - "modified": "2021-04-11 01:50:42.074623", - "modified_by": "Administrator", - "module": "Core", - "name": "Data Import", - "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 + "actions": [], + "autoname": "format:{reference_doctype} Import on {creation}", + "beta": 1, + "creation": "2019-08-04 14:16:08.318714", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "import_type", + "download_template", + "import_file", + "payload_count", + "html_5", + "google_sheets_url", + "refresh_google_sheet", + "column_break_5", + "status", + "submit_after_import", + "mute_emails", + "template_options", + "import_warnings_section", + "template_warnings", + "import_warnings", + "section_import_preview", + "import_preview", + "import_log_section", + "show_failed_logs", + "import_log_preview" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "import_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Import Type", + "options": "\nInsert New Records\nUpdate Existing Records", + "reqd": 1, + "set_only_once": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "import_file", + "fieldtype": "Attach", + "in_list_view": 1, + "label": "Import File", + "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + }, + { + "fieldname": "import_preview", + "fieldtype": "HTML", + "label": "Import Preview" + }, + { + "fieldname": "section_import_preview", + "fieldtype": "Section Break", + "label": "Preview" + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "template_options", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Options", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "import_log_section", + "fieldtype": "Section Break", + "label": "Import Log" + }, + { + "fieldname": "import_log_preview", + "fieldtype": "HTML", + "label": "Import Log Preview" + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "Pending\nSuccess\nPartial Success\nError", + "read_only": 1 + }, + { + "fieldname": "template_warnings", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Warnings", + "options": "JSON" + }, + { + "default": "0", + "fieldname": "submit_after_import", + "fieldtype": "Check", + "label": "Submit After Import", + "set_only_once": 1 + }, + { + "fieldname": "import_warnings_section", + "fieldtype": "Section Break", + "label": "Import File Errors and Warnings" + }, + { + "fieldname": "import_warnings", + "fieldtype": "HTML", + "label": "Import Warnings" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "download_template", + "fieldtype": "Button", + "label": "Download Template" + }, + { + "default": "1", + "fieldname": "mute_emails", + "fieldtype": "Check", + "label": "Don't Send Emails", + "set_only_once": 1 + }, + { + "default": "0", + "fieldname": "show_failed_logs", + "fieldtype": "Check", + "label": "Show Failed Logs" + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file", + "fieldname": "html_5", + "fieldtype": "HTML", + "options": "
Or
" + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file\n", + "description": "Must be a publicly accessible Google Sheets URL", + "fieldname": "google_sheets_url", + "fieldtype": "Data", + "label": "Import from Google Sheets", + "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + }, + { + "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)", + "fieldname": "refresh_google_sheet", + "fieldtype": "Button", + "label": "Refresh Google Sheet" + }, + { + "fieldname": "payload_count", + "fieldtype": "Int", + "hidden": 1, + "label": "Payload Count", + "read_only": 1 + } + ], + "hide_toolbar": 1, + "links": [], + "modified": "2022-02-01 20:08:37.624914", + "modified_by": "Administrator", + "module": "Core", + "name": "Data Import", + "naming_rule": "Expression", + "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", + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 5935ddc4ba..5972e79b4d 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -27,6 +27,7 @@ class DataImport(Document): self.validate_import_file() self.validate_google_sheets_url() + self.set_payload_count() def validate_import_file(self): if self.import_file: @@ -38,6 +39,12 @@ class DataImport(Document): return validate_google_sheets_url(self.google_sheets_url) + def set_payload_count(self): + if self.import_file: + i = self.get_importer() + payloads = i.import_file.get_payloads_for_import() + self.payload_count = len(payloads) + @frappe.whitelist() def get_preview_from_template(self, import_file=None, google_sheets_url=None): if import_file: @@ -67,7 +74,7 @@ class DataImport(Document): enqueue( start_import, queue="default", - timeout=6000, + timeout=10000, event="data_import", job_name=self.name, data_import=self.name, @@ -80,6 +87,9 @@ class DataImport(Document): def export_errored_rows(self): return self.get_importer().export_errored_rows() + def download_import_log(self): + return self.get_importer().export_import_log() + def get_importer(self): return Importer(self.reference_doctype, data_import=self) @@ -90,7 +100,6 @@ def get_preview_from_template(data_import, import_file=None, google_sheets_url=N import_file, google_sheets_url ) - @frappe.whitelist() def form_start_import(data_import): return frappe.get_doc("Data Import", data_import).start_import() @@ -145,6 +154,30 @@ def download_errored_template(data_import_name): data_import = frappe.get_doc("Data Import", data_import_name) data_import.export_errored_rows() +@frappe.whitelist() +def download_import_log(data_import_name): + data_import = frappe.get_doc("Data Import", data_import_name) + data_import.download_import_log() + +@frappe.whitelist() +def get_import_status(data_import_name): + import_status = {} + + logs = frappe.get_all('Data Import Log', fields=['count(*) as count', 'success'], + filters={'data_import': data_import_name}, + group_by='success') + + total_payload_count = frappe.db.get_value('Data Import', data_import_name, 'payload_count') + + for log in logs: + if log.get('success'): + import_status['success'] = log.get('count') + else: + import_status['failed'] = log.get('count') + + import_status['total_records'] = total_payload_count + + return import_status def import_file( doctype, file_path, import_type, submit_after_import=False, console=False diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index 0eb05aa354..6ab750ba25 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -24,12 +24,14 @@ frappe.listview_settings['Data Import'] = { 'Error': 'red' }; let status = doc.status; + if (imports_in_progress.includes(doc.name)) { status = 'In Progress'; } if (status == 'Pending') { status = 'Not Started'; } + return [__(status), colors[status], 'status,=,' + doc.status]; }, formatters: { diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index b9b2050763..f89eb31cc8 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -47,7 +47,13 @@ class Importer: ) def get_data_for_import_preview(self): - return self.import_file.get_data_for_import_preview() + out = self.import_file.get_data_for_import_preview() + + out.import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"], + filters={"data_import": self.data_import.name}, + order_by="log_index", limit=10) + + return out def before_import(self): # set user lang for translations @@ -58,7 +64,6 @@ class Importer: frappe.flags.in_import = True frappe.flags.mute_emails = self.data_import.mute_emails - self.data_import.db_set("status", "Pending") self.data_import.db_set("template_warnings", "") def import_data(self): @@ -79,20 +84,25 @@ class Importer: return # setup import log - if self.data_import.import_log: - import_log = frappe.parse_json(self.data_import.import_log) - else: - import_log = [] + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] - # remove previous failures from import log - import_log = [log for log in import_log if log.get("success")] + log_index = 0 + + # Do not remove rows in case of retry after an error or pending data import + if self.data_import.status == "Partial Success" and len(import_log) >= self.data_import.payload_count: + # remove previous failures from import log only in case of retry after partial success + import_log = [log for log in import_log if log.get("success")] # get successfully imported rows imported_rows = [] for log in import_log: log = frappe._dict(log) - if log.success: - imported_rows += log.row_indexes + if log.success or len(import_log) < self.data_import.payload_count: + imported_rows += json.loads(log.row_indexes) + + log_index = log.log_index # start import total_payload_count = len(payloads) @@ -146,25 +156,41 @@ class Importer: }, ) - import_log.append( - frappe._dict(success=True, docname=doc.name, row_indexes=row_indexes) - ) + create_import_log(self.data_import.name, log_index, { + 'success': True, + 'docname': doc.name, + 'row_indexes': row_indexes + }) + + log_index += 1 + + if not self.data_import.status == "Partial Success": + self.data_import.db_set("status", "Partial Success") + # commit after every successful import frappe.db.commit() except Exception: - import_log.append( - frappe._dict( - success=False, - exception=frappe.get_traceback(), - messages=frappe.local.message_log, - row_indexes=row_indexes, - ) - ) + messages = frappe.local.message_log frappe.clear_messages() + # rollback if exception frappe.db.rollback() + create_import_log(self.data_import.name, log_index, { + 'success': False, + 'exception': frappe.get_traceback(), + 'messages': messages, + 'row_indexes': row_indexes + }) + + log_index += 1 + + # Logs are db inserted directly so will have to be fetched again + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] + # set status failures = [log for log in import_log if not log.get("success")] if len(failures) == total_payload_count: @@ -178,7 +204,6 @@ class Importer: self.print_import_log(import_log) else: self.data_import.db_set("status", status) - self.data_import.db_set("import_log", json.dumps(import_log)) self.after_import() @@ -248,11 +273,14 @@ class Importer: if not self.data_import: return - import_log = frappe.parse_json(self.data_import.import_log or "[]") + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] + failures = [log for log in import_log if not log.get("success")] row_indexes = [] for f in failures: - row_indexes.extend(f.get("row_indexes", [])) + row_indexes.extend(json.loads(f.get("row_indexes", []))) # de duplicate row_indexes = list(set(row_indexes)) @@ -264,6 +292,30 @@ class Importer: build_csv_response(rows, _(self.doctype)) + def export_import_log(self): + from frappe.utils.csvutils import build_csv_response + + if not self.data_import: + return + + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"], + filters={"data_import": self.data_import.name}, + order_by="log_index") + + header_row = ["Row Numbers", "Status", "Message", "Exception"] + + rows = [header_row] + + for log in import_log: + row_number = json.loads(log.get("row_indexes"))[0] + status = "Success" if log.get('success') else "Failure" + message = "Successfully Imported {0}".format(log.get('docname')) if log.get('success') else \ + log.get("messages") + exception = frappe.utils.cstr(log.get("exception", '')) + rows += [[row_number, status, message, exception]] + + 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] successful_records = [log for log in import_log if log.success] @@ -566,7 +618,7 @@ class Row: ) # remove standard fields and __islocal - for key in frappe.model.default_fields + ("__islocal",): + for key in frappe.model.default_fields + frappe.model.child_table_fields + ("__islocal",): doc.pop(key, None) for col, value in zip(columns, values): @@ -1172,3 +1224,17 @@ def df_as_json(df): def get_select_options(df): return [d for d in (df.options or "").split("\n") if d] + +def create_import_log(data_import, log_index, log_details): + frappe.get_doc({ + 'doctype': 'Data Import Log', + 'log_index': log_index, + 'success': log_details.get('success'), + 'data_import': data_import, + 'row_indexes': json.dumps(log_details.get('row_indexes')), + 'docname': log_details.get('docname'), + 'messages': json.dumps(log_details.get('messages', '[]')), + 'exception': log_details.get('exception') + }).db_insert() + + diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index e1bc0e7ca5..11077ca58b 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -4,6 +4,7 @@ import unittest import frappe from frappe.core.doctype.data_import.importer import Importer +from frappe.tests.test_query_builder import db_type_is, run_only_if from frappe.utils import getdate, format_duration doctype_name = 'DocType for Import' @@ -54,21 +55,27 @@ class TestImporter(unittest.TestCase): self.assertEqual(len(preview.data), 4) self.assertEqual(len(preview.columns), 16) + # ignored on postgres because myisam doesn't exist on pg + @run_only_if(db_type_is.MARIADB) def test_data_import_without_mandatory_values(self): import_file = get_import_file('sample_import_file_without_mandatory') data_import = self.get_importer(doctype_name, import_file) frappe.local.message_log = [] data_import.start_import() data_import.reload() - import_log = frappe.parse_json(data_import.import_log) - self.assertEqual(import_log[0]['row_indexes'], [2,3]) - expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" - self.assertEqual(frappe.parse_json(import_log[0]['messages'][0])['message'], expected_error) - expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" - self.assertEqual(frappe.parse_json(import_log[0]['messages'][1])['message'], expected_error) - self.assertEqual(import_log[1]['row_indexes'], [4]) - self.assertEqual(frappe.parse_json(import_log[1]['messages'][0])['message'], "Title is required") + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"], + filters={"data_import": data_import.name}, + order_by="log_index") + + self.assertEqual(frappe.parse_json(import_log[0]['row_indexes']), [2,3]) + expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[0])['message'], expected_error) + expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[1])['message'], expected_error) + + self.assertEqual(frappe.parse_json(import_log[1]['row_indexes']), [4]) + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[1]['messages'])[0])['message'], "Title is required") def test_data_import_update(self): existing_doc = frappe.get_doc( diff --git a/frappe/social/doctype/post/__init__.py b/frappe/core/doctype/data_import_log/__init__.py similarity index 100% rename from frappe/social/doctype/post/__init__.py rename to frappe/core/doctype/data_import_log/__init__.py diff --git a/frappe/core/doctype/data_import_log/data_import_log.js b/frappe/core/doctype/data_import_log/data_import_log.js new file mode 100644 index 0000000000..c376edeec9 --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Data Import Log', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/data_import_log/data_import_log.json b/frappe/core/doctype/data_import_log/data_import_log.json new file mode 100644 index 0000000000..b1d991f099 --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.json @@ -0,0 +1,84 @@ +{ + "actions": [], + "creation": "2021-12-25 16:12:20.205889", + "doctype": "DocType", + "editable_grid": 1, + "engine": "MyISAM", + "field_order": [ + "data_import", + "row_indexes", + "success", + "docname", + "messages", + "exception", + "log_index" + ], + "fields": [ + { + "fieldname": "data_import", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Data Import", + "options": "Data Import" + }, + { + "fieldname": "docname", + "fieldtype": "Data", + "label": "Reference Name" + }, + { + "fieldname": "exception", + "fieldtype": "Text", + "label": "Exception" + }, + { + "fieldname": "row_indexes", + "fieldtype": "Code", + "label": "Row Indexes", + "options": "JSON" + }, + { + "default": "0", + "fieldname": "success", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Success" + }, + { + "fieldname": "log_index", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Log Index" + }, + { + "fieldname": "messages", + "fieldtype": "Code", + "label": "Messages", + "options": "JSON" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-12-29 11:19:19.646076", + "modified_by": "Administrator", + "module": "Core", + "name": "Data Import Log", + "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" +} \ No newline at end of file diff --git a/frappe/core/doctype/data_import_log/data_import_log.py b/frappe/core/doctype/data_import_log/data_import_log.py new file mode 100644 index 0000000000..a71aefa8bc --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.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 DataImportLog(Document): + pass diff --git a/frappe/core/doctype/data_import_log/test_data_import_log.py b/frappe/core/doctype/data_import_log/test_data_import_log.py new file mode 100644 index 0000000000..244404936e --- /dev/null +++ b/frappe/core/doctype/data_import_log/test_data_import_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestDataImportLog(unittest.TestCase): + pass diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 7bbf9422ba..3d9cf1064e 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -17,6 +17,7 @@ "hide_days", "hide_seconds", "reqd", + "is_virtual", "search_index", "column_break_18", "options", @@ -98,7 +99,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\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -534,13 +535,19 @@ "fieldname": "show_dashboard", "fieldtype": "Check", "label": "Show Dashboard" + }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Virtual" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-03 11:56:19.812863", + "modified": "2022-02-14 11:56:19.812863", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/doctype/boilerplate/test_controller._py b/frappe/core/doctype/doctype/boilerplate/test_controller._py index 5f4150ce9b..83a38c493d 100644 --- a/frappe/core/doctype/doctype/boilerplate/test_controller._py +++ b/frappe/core/doctype/doctype/boilerplate/test_controller._py @@ -2,7 +2,8 @@ # See license.txt # import frappe -import unittest +from frappe.tests.utils import FrappeTestCase -class Test{classname}(unittest.TestCase): + +class Test{classname}(FrappeTestCase): pass diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index b907ebc0bc..88cc5577a6 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -33,9 +33,16 @@ frappe.ui.form.on('DocType', { } } + const customize_form_link = "Customize Form"; if(!frappe.boot.developer_mode && !frm.doc.custom) { // make the document read-only frm.set_read_only(); + frm.dashboard.add_comment(__("DocTypes can not be modified, please use {0} instead", [customize_form_link]), "blue", true); + } else if (frappe.boot.developer_mode) { + let msg = __("This site is running in developer mode. Any change made here will be updated in code."); + msg += "
"; + msg += __("If you just want to customize for your site, use {0} instead.", [customize_form_link]); + frm.dashboard.add_comment(msg, "yellow"); } if(frm.is_new()) { @@ -54,6 +61,13 @@ frappe.ui.form.on('DocType', { frm.events.set_naming_rule_description(frm); }, + istable: (frm) => { + if (frm.doc.istable && frm.is_new()) { + frm.set_value('autoname', 'autoincrement'); + frm.set_value('allow_rename', 0); + } + }, + naming_rule: function(frm) { // set the "autoname" property based on naming_rule if (frm.doc.naming_rule && !frm.__from_autoname) { @@ -63,6 +77,10 @@ frappe.ui.form.on('DocType', { if (frm.doc.naming_rule=='Set by user') { frm.set_value('autoname', 'Prompt'); + } else if (frm.doc.naming_rule === 'Autoincrement') { + frm.set_value('autoname', 'autoincrement'); + // set allow rename to be false when using autoincrement + frm.set_value('allow_rename', 0); } else if (frm.doc.naming_rule=='By fieldname') { frm.set_value('autoname', 'field:'); } else if (frm.doc.naming_rule=='By "Naming Series" field') { @@ -84,6 +102,7 @@ frappe.ui.form.on('DocType', { set_naming_rule_description(frm) { let naming_rule_description = { 'Set by user': '', + 'Autoincrement': 'Uses Auto Increment feature of database.
WARNING: After using this option, any other naming option will not be accessible.', 'By fieldname': 'Format: field:[fieldname]. Valid fieldname must exist', 'By "Naming Series" field': 'Format: naming_series:[fieldname]. Fieldname called naming_series must exist', 'Expression': 'Format: format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.', @@ -104,6 +123,8 @@ frappe.ui.form.on('DocType', { frm.__from_autoname = true; if (frm.doc.autoname.toLowerCase() === 'prompt') { frm.set_value('naming_rule', 'Set by user'); + } else if (frm.doc.autoname.toLowerCase() === 'autoincrement') { + frm.set_value('naming_rule', 'Autoincrement'); } else if (frm.doc.autoname.startsWith('field:')) { frm.set_value('naming_rule', 'By fieldname'); } else if (frm.doc.autoname.startsWith('naming_series:')) { diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 03e3b65ea1..8169a59566 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -46,6 +46,7 @@ "allow_auto_repeat", "view_settings", "title_field", + "show_title_field_in_link", "search_fields", "default_print_format", "sort_field", @@ -207,7 +208,7 @@ "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.
", + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. autoincrement - Uses Databases' Auto Increment feature
  3. naming_series: - By Naming Series (field called naming_series must be present
  4. Prompt - Prompt user for a name
  5. [series] - Series by prefix (separated by a dot); for example PRE.#####
  6. \n
  7. 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", @@ -215,6 +216,7 @@ "oldfieldtype": "Data" }, { + "depends_on": "eval:doc.naming_rule !== \"Autoincrement\"", "fieldname": "name_case", "fieldtype": "Select", "label": "Name Case", @@ -281,6 +283,7 @@ }, { "default": "1", + "depends_on": "eval:doc.naming_rule !== \"Autoincrement\"", "fieldname": "allow_rename", "fieldtype": "Check", "label": "Allow Rename", @@ -564,7 +567,7 @@ "fieldtype": "Select", "label": "Naming Rule", "length": 40, - "options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" + "options": "\nSet by user\nAutoincrement\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" }, { "fieldname": "migration_hash", @@ -582,10 +585,17 @@ "fieldname": "document_states_section", "fieldtype": "Section Break", "label": "Document States" + }, + { + "default": "0", + "fieldname": "show_title_field_in_link", + "fieldtype": "Check", + "label": "Show Title in Link Fields" } ], "icon": "fa fa-bolt", "idx": 6, + "index_web_pages_for_search": 1, "links": [ { "group": "Views", @@ -663,10 +673,11 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2021-12-09 14:53:10.717788", + "modified": "2022-02-15 21:47:16.467217", "modified_by": "Administrator", "module": "Core", "name": "DocType", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -696,5 +707,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "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 3754288145..29b56fbff6 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -10,7 +10,9 @@ from frappe.cache_manager import clear_user_cache, clear_controller_cache import frappe from frappe import _ from frappe.utils import now, cint -from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields, data_field_options +from frappe.model import ( + no_value_fields, default_fields, table_fields, data_field_options, child_table_fields +) from frappe.model.document import Document from frappe.model.base_document import get_controller from frappe.custom.doctype.property_setter.property_setter import make_property_setter @@ -58,6 +60,7 @@ class DocType(Document): self.check_developer_mode() + self.validate_autoname() self.validate_name() self.set_defaults_for_single_and_table() @@ -74,6 +77,7 @@ class DocType(Document): self.make_amendable() self.make_repeatable() self.validate_nestedset() + self.validate_child_table() self.validate_website() self.ensure_minimum_max_attachment_limit() validate_links_table_fieldnames(self) @@ -689,16 +693,51 @@ class DocType(Document): }) self.nsm_parent_field = parent_field_name + def validate_child_table(self): + if not self.get("istable") or self.is_new(): + # if the doctype is not a child table then return + # if the doctype is a new doctype and also a child table then + # don't move forward as it will be handled via schema + return + + self.add_child_table_fields() + + def add_child_table_fields(self): + from frappe.database.schema import add_column + + add_column(self.name, "parent", "Data") + add_column(self.name, "parenttype", "Data") + add_column(self.name, "parentfield", "Data") + def get_max_idx(self): """Returns the highest `idx`""" max_idx = frappe.db.sql("""select max(idx) from `tabDocField` where parent = %s""", self.name) return max_idx and max_idx[0][0] or 0 + def validate_autoname(self): + if not self.is_new(): + doc_before_save = self.get_doc_before_save() + if doc_before_save: + if (self.autoname == "autoincrement" and doc_before_save.autoname != "autoincrement") \ + or (self.autoname != "autoincrement" and doc_before_save.autoname == "autoincrement"): + frappe.throw(_("Cannot change to/from Autoincrement naming rule")) + + else: + if self.autoname == "autoincrement": + self.allow_rename = 0 + def validate_name(self, name=None): if not name: name = self.name + # a Doctype name is the tablename created in database + # `tab` the length of tablename is limited to 64 characters + max_length = frappe.db.MAX_COLUMN_LENGTH - 3 + if len(name) > max_length: + # length(tab + ) should be equal to 64 characters hence doctype should be 61 characters + frappe.throw(_("Doctype name is limited to {0} characters ({1})").format(max_length, name), frappe.NameError) + flags = {"flags": re.ASCII} # a DocType name should not start or end with an empty space @@ -706,9 +745,12 @@ class DocType(Document): frappe.throw(_("DocType's name should not start or end with whitespace"), frappe.NameError) # a DocType's name should not start with a number or underscore - # and should only contain letters, numbers and underscore - if not re.match(r"^(?![\W])[^\d_\s][\w ]+$", name, **flags): - frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError) + # and should only contain letters, numbers, underscore, and hyphen + if not re.match(r"^(?![\W])[^\d_\s][\w -]+$", name, **flags): + frappe.throw(_( + "A DocType's name should start with a letter and can only " + "consist of letters, numbers, spaces, underscores and hyphens" + ), frappe.NameError, title="Invalid Name") validate_route_conflict(self.doctype, self.name) @@ -755,29 +797,39 @@ def validate_series(dt, autoname=None, name=None): def validate_links_table_fieldnames(meta): """Validate fieldnames in Links table""" - if frappe.flags.in_patch: return - if frappe.flags.in_fixtures: return - if not meta.links: return + if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures: + return - for index, link in enumerate(meta.links): - link_meta = frappe.get_meta(link.link_doctype) - if not link_meta.get_field(link.link_fieldname): - message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype)) + fieldnames = tuple(field.fieldname for field in meta.fields) + for index, link in enumerate(meta.links, 1): + if not frappe.get_meta(link.link_doctype).has_field(link.link_fieldname): + message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format( + index, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype) + ) frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname")) - if link.is_child_table and not meta.get_field(link.table_fieldname): - message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.table_fieldname), frappe.bold(meta.name)) + if not link.is_child_table: + continue + + if not link.parent_doctype: + message = _("Document Links Row #{0}: Parent DocType is mandatory for internal links").format(index) + frappe.throw(message, frappe.ValidationError, _("Parent Missing")) + + if not link.table_fieldname: + message = _("Document Links Row #{0}: Table Fieldname is mandatory for internal links").format(index) + frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing")) + + if meta.name == link.parent_doctype: + field_exists = link.table_fieldname in fieldnames + else: + field_exists = frappe.get_meta(link.parent_doctype).has_field(link.table_fieldname) + + if not field_exists: + message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format( + index, frappe.bold(link.table_fieldname), frappe.bold(meta.name) + ) frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname")) - if link.is_child_table: - if not link.parent_doctype: - message = _("Document Links Row #{0}: Parent DocType is mandatory for internal links").format(index+1) - frappe.throw(message, frappe.ValidationError, _("Parent Missing")) - - if not link.table_fieldname: - message = _("Document Links Row #{0}: Table Fieldname is mandatory for internal links").format(index+1) - frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing")) - def validate_fields_for_doctype(doctype): meta = frappe.get_meta(doctype, cached=False) validate_links_table_fieldnames(meta) @@ -1009,7 +1061,7 @@ def validate_fields(meta): sort_fields = [d.split()[0] for d in meta.sort_field.split(',')] for fieldname in sort_fields: - if not fieldname in fieldname_list + list(default_fields): + if fieldname not in (fieldname_list + list(default_fields) + list(child_table_fields)): frappe.throw(_("Sort field {0} must be a valid fieldname").format(fieldname), InvalidFieldNameError) @@ -1050,6 +1102,9 @@ def validate_fields(meta): field.fetch_from = field.fetch_from.strip('\n').strip() def validate_data_field_type(docfield): + if docfield.get("is_virtual"): + return + if docfield.fieldtype == "Data" and not (docfield.oldfieldtype and docfield.oldfieldtype != "Data"): if docfield.options and (docfield.options not in data_field_options): df_str = frappe.bold(_(docfield.label)) @@ -1295,10 +1350,9 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): else: raise -def check_fieldname_conflicts(doctype, fieldname): +def check_fieldname_conflicts(docfield): """Checks if fieldname conflicts with methods or properties""" - - doc = frappe.get_doc({"doctype": doctype}) + doc = frappe.get_doc({"doctype": docfield.dt}) available_objects = [x for x in dir(doc) if isinstance(x, str)] property_list = [ x for x in available_objects if isinstance(getattr(type(doc), x, None), property) @@ -1306,9 +1360,10 @@ def check_fieldname_conflicts(doctype, fieldname): method_list = [ x for x in available_objects if x not in property_list and callable(getattr(doc, x)) ] + msg = _("Fieldname {0} conflicting with meta object").format(docfield.fieldname) - if fieldname in method_list + property_list: - frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname)) + if docfield.fieldname in method_list + property_list: + frappe.msgprint(msg, raise_exception=not docfield.is_virtual) def clear_linked_doctype_cache(): frappe.cache().delete_value('linked_doctypes_without_ignore_user_permissions_enabled') diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 12c227464d..dc6d14b451 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -23,7 +23,8 @@ class TestDocType(unittest.TestCase): self.assertRaises(frappe.NameError, new_doctype("_Some DocType").insert) self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert) self.assertRaises(frappe.NameError, new_doctype("Some (DocType)").insert) - for name in ("Some DocType", "Some_DocType"): + self.assertRaises(frappe.NameError, new_doctype("Some Doctype with a name whose length is more than 61 characters").insert) + for name in ("Some DocType", "Some_DocType", "Some-DocType"): if frappe.db.exists("DocType", name): frappe.delete_doc("DocType", name) @@ -353,7 +354,6 @@ class TestDocType(unittest.TestCase): dump_docs = json.dumps(docs.get('docs')) cancel_all_linked_docs(dump_docs) data_link_doc.cancel() - data_doc.name = '{}-CANC-0'.format(data_doc.name) data_doc.load_from_db() self.assertEqual(data_link_doc.docstatus, 2) self.assertEqual(data_doc.docstatus, 2) @@ -377,7 +377,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - link_doc.insert(ignore_if_duplicate=True) + link_doc.insert() #create first parent doctype test_doc_1 = new_doctype('Test Doctype 1') @@ -392,7 +392,7 @@ class TestDocType(unittest.TestCase): for data in test_doc_1.get('permissions'): data.submit = 1 data.cancel = 1 - test_doc_1.insert(ignore_if_duplicate=True) + test_doc_1.insert() #crete second parent doctype doc = new_doctype('Test Doctype 2') @@ -407,7 +407,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - doc.insert(ignore_if_duplicate=True) + doc.insert() # create doctype data data_link_doc_1 = frappe.new_doc('Test Linked Doctype 1') @@ -438,7 +438,6 @@ class TestDocType(unittest.TestCase): # checking that doc for Test Doctype 2 is not canceled self.assertRaises(frappe.LinkExistsError, data_link_doc_1.cancel) - data_doc_2.name = '{}-CANC-0'.format(data_doc_2.name) data_doc.load_from_db() data_doc_2.load_from_db() self.assertEqual(data_link_doc_1.docstatus, 2) @@ -499,7 +498,30 @@ class TestDocType(unittest.TestCase): self.assertEqual(doc.is_virtual, 1) self.assertFalse(frappe.db.table_exists('Test Virtual Doctype')) -def new_doctype(name, unique=0, depends_on='', fields=None): + def test_default_fieldname(self): + fields = [{"label": "title", "fieldname": "title", "fieldtype": "Data", "default": "{some_fieldname}"}] + dt = new_doctype("DT with default field", fields=fields) + dt.insert() + + dt.delete() + + def test_autoincremented_doctype_transition(self): + frappe.delete_doc("testy_autoinc_dt") + dt = new_doctype("testy_autoinc_dt", autoincremented=True).insert(ignore_permissions=True) + dt.autoname = "hash" + + try: + dt.save(ignore_permissions=True) + except frappe.ValidationError as e: + self.assertEqual(e.args[0], "Cannot change to/from Autoincrement naming rule") + else: + self.fail("Shouldnt be possible to transition autoincremented doctype to any other naming rule") + finally: + # cleanup + dt.delete(ignore_permissions=True) + + +def new_doctype(name, unique=0, depends_on='', fields=None, autoincremented=False): doc = frappe.get_doc({ "doctype": "DocType", "module": "Core", @@ -515,7 +537,8 @@ def new_doctype(name, unique=0, depends_on='', fields=None): "role": "System Manager", "read": 1, }], - "name": name + "name": name, + "autoname": "autoincrement" if autoincremented else "" }) if fields: diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.py b/frappe/core/doctype/document_naming_rule/document_naming_rule.py index 8013f9df6f..5c445fd058 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py @@ -5,6 +5,7 @@ import frappe from frappe.model.document import Document from frappe.utils.data import evaluate_filters +from frappe.model.naming import parse_naming_series from frappe import _ class DocumentNamingRule(Document): @@ -27,7 +28,9 @@ class DocumentNamingRule(Document): return counter = frappe.db.get_value(self.doctype, self.name, 'counter', for_update=True) or 0 - doc.name = self.prefix + ('%0'+str(self.prefix_digits)+'d') % (counter + 1) + naming_series = parse_naming_series(self.prefix, doc=doc) + + doc.name = naming_series + ('%0'+str(self.prefix_digits)+'d') % (counter + 1) frappe.db.set_value(self.doctype, self.name, 'counter', counter + 1) @frappe.whitelist() diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index adf10b9a03..50a7b31bca 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE """ @@ -7,7 +7,6 @@ record of files naming for same name files: file.gif, file-1.gif, file-2.gif etc """ -import base64 import hashlib import imghdr import io @@ -17,9 +16,10 @@ import os import re import shutil import zipfile +from typing import TYPE_CHECKING, Tuple import requests -import requests.exceptions +from requests.exceptions import HTTPError, SSLError from PIL import Image, ImageFile, ImageOps from io import BytesIO from urllib.parse import quote, unquote @@ -31,6 +31,11 @@ from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, g from frappe.utils.image import strip_exif_data, optimize_image from frappe.utils.file_manager import safe_b64decode +if TYPE_CHECKING: + from PIL.ImageFile import ImageFile + from requests.models import Response + + class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -276,7 +281,7 @@ class File(Document): image, filename, extn = get_local_image(self.file_url) else: image, filename, extn = get_web_image(self.file_url) - except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError): + except (HTTPError, SSLError, IOError, TypeError): return size = width, height @@ -572,12 +577,10 @@ class File(Document): @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): + if isinstance(_file, str): _file = frappe.get_doc("File", _file) if not isinstance(_file, File): continue @@ -650,9 +653,17 @@ def setup_folder_path(filename, new_parent): from frappe.model.rename_doc import rename_doc rename_doc("File", file.name, file.get_name_based_on_parent_folder(), ignore_permissions=True) -def get_extension(filename, extn, content): +def get_extension(filename, extn, content: bytes = None, response: "Response" = None) -> str: mimetype = None + if response: + content_type = response.headers.get("Content-Type") + + if content_type: + _extn = mimetypes.guess_extension(content_type) + if _extn: + return _extn[1:] + if extn: # remove '?' char and parameters from extn if present if '?' in extn: @@ -695,14 +706,14 @@ def get_local_image(file_url): return image, filename, extn -def get_web_image(file_url): +def get_web_image(file_url: str) -> Tuple["ImageFile", str, str]: # download file_url = frappe.utils.get_url(file_url) r = requests.get(file_url, stream=True) try: r.raise_for_status() - except requests.exceptions.HTTPError as e: - if "404" in e.args[0]: + except HTTPError: + if r.status_code == 404: frappe.msgprint(_("File '{0}' not found").format(file_url)) else: frappe.msgprint(_("Unable to read file format for {0}").format(file_url)) @@ -721,7 +732,10 @@ def get_web_image(file_url): filename = get_random_filename() extn = None - extn = get_extension(filename, extn, r.content) + extn = get_extension(filename, extn, response=r) + if extn == "bin": + extn = get_extension(filename, extn, content=r.content) or "png" + filename = "/files/" + strip(unquote(filename)) return image, filename, extn @@ -731,7 +745,7 @@ def delete_file(path): """Delete file from `public folder`""" if path: if ".." in path.split("/"): - frappe.msgprint(_("It is risky to delete this file: {0}. Please contact your System Manager.").format(path)) + frappe.throw(_("It is risky to delete this file: {0}. Please contact your System Manager.").format(path)) parts = os.path.split(path.strip("/")) if parts[0]=="files": @@ -864,8 +878,9 @@ def extract_images_from_html(doc, content, is_private=False): else: filename = get_random_filename(content_type=mtype) - doctype = doc.parenttype if doc.parent else doc.doctype - name = doc.parent or doc.name + # attaching a file to a child table doc, attaches it to the parent doc + doctype = doc.parenttype if doc.get("parent") else doc.doctype + name = doc.get("parent") or doc.name _file = frappe.get_doc({ "doctype": "File", diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 2c1042e104..fb98a18d6e 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -1,15 +1,14 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import base64 import json import frappe import os import unittest + from frappe import _ -from frappe.core.doctype.file.file import get_attached_images, move_file, get_files_in_folder, unzip_file +from frappe.core.doctype.file.file import File, get_attached_images, move_file, get_files_in_folder, unzip_file from frappe.utils import get_files_path -# test_records = frappe.get_test_records('File') test_content1 = 'Hello' test_content2 = 'Hello World' @@ -24,8 +23,6 @@ def make_test_doc(): class TestSimpleFile(unittest.TestCase): - - def setUp(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() self.test_content = test_content1 @@ -38,21 +35,13 @@ class TestSimpleFile(unittest.TestCase): _file.save() self.saved_file_url = _file.file_url - def test_save(self): _file = frappe.get_doc("File", {"file_url": self.saved_file_url}) content = _file.get_content() self.assertEqual(content, self.test_content) - def tearDown(self): - # File gets deleted on rollback, so blank - pass - - class TestBase64File(unittest.TestCase): - - def setUp(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() self.test_content = base64.b64encode(test_content1.encode('utf-8')) @@ -66,18 +55,12 @@ class TestBase64File(unittest.TestCase): _file.save() self.saved_file_url = _file.file_url - def test_saved_content(self): _file = frappe.get_doc("File", {"file_url": self.saved_file_url}) content = _file.get_content() self.assertEqual(content, test_content1) - def tearDown(self): - # File gets deleted on rollback, so blank - pass - - class TestSameFileName(unittest.TestCase): def test_saved_content(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() @@ -130,8 +113,6 @@ class TestSameFileName(unittest.TestCase): class TestSameContent(unittest.TestCase): - - def setUp(self): self.attached_to_doctype1, self.attached_to_docname1 = make_test_doc() self.attached_to_doctype2, self.attached_to_docname2 = make_test_doc() @@ -186,10 +167,6 @@ class TestSameContent(unittest.TestCase): limit_property.delete() frappe.clear_cache(doctype='ToDo') - def tearDown(self): - # File gets deleted on rollback, so blank - pass - class TestFile(unittest.TestCase): def setUp(self): @@ -398,29 +375,39 @@ class TestFile(unittest.TestCase): def test_make_thumbnail(self): # test web image - test_file = frappe.get_doc({ + test_file: File = frappe.get_doc({ "doctype": "File", "file_name": 'logo', "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), }).insert(ignore_permissions=True) test_file.make_thumbnail() - self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg') + self.assertEqual(test_file.thumbnail_url, '/files/image_small.jpg') + + # test web image without extension + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": 'logo', + "file_url": frappe.utils.get_url('/_test/assets/image'), + }).insert(ignore_permissions=True) + + test_file.make_thumbnail() + self.assertTrue(test_file.thumbnail_url.endswith("_small.jpeg")) # test local image test_file.db_set('thumbnail_url', None) test_file.reload() test_file.file_url = "/files/image_small.jpg" test_file.make_thumbnail(suffix="xs", crop=True) - self.assertEquals(test_file.thumbnail_url, '/files/image_small_xs.jpg') + self.assertEqual(test_file.thumbnail_url, '/files/image_small_xs.jpg') frappe.clear_messages() test_file.db_set('thumbnail_url', None) test_file.reload() test_file.file_url = frappe.utils.get_url('unknown.jpg') test_file.make_thumbnail(suffix="xs") - self.assertEqual(json.loads(frappe.message_log[0]), {"message": f"File '{frappe.utils.get_url('unknown.jpg')}' not found"}) - self.assertEquals(test_file.thumbnail_url, None) + self.assertEqual(json.loads(frappe.message_log[0]).get("message"), f"File '{frappe.utils.get_url('unknown.jpg')}' not found") + self.assertEqual(test_file.thumbnail_url, None) def test_file_unzip(self): file_path = frappe.get_app_path('frappe', 'www/_test/assets/file.zip') diff --git a/frappe/core/doctype/payment_gateway/payment_gateway.json b/frappe/core/doctype/payment_gateway/payment_gateway.json index b97d72c771..7195b3949e 100644 --- a/frappe/core/doctype/payment_gateway/payment_gateway.json +++ b/frappe/core/doctype/payment_gateway/payment_gateway.json @@ -1,154 +1,55 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:gateway", - "beta": 0, - "creation": "2015-12-15 22:26:45.221162", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "autoname": "field:gateway", + "creation": "2022-01-24 21:09:47.229371", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gateway", + "gateway_settings", + "gateway_controller" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gateway", - "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": "Gateway", - "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, - "unique": 0 - }, + "fieldname": "gateway", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Gateway", + "reqd": 1, + "unique": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gateway_settings", - "fieldtype": "Link", - "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": "Gateway Settings", - "length": 0, - "no_copy": 0, - "options": "DocType", - "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": "gateway_settings", + "fieldtype": "Link", + "label": "Gateway Settings", + "options": "DocType" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gateway_controller", - "fieldtype": "Dynamic Link", - "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": "Gateway Controller", - "length": 0, - "no_copy": 0, - "options": "gateway_settings", - "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": "gateway_controller", + "fieldtype": "Dynamic Link", + "label": "Gateway Controller", + "options": "gateway_settings" } - ], - "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": "2018-02-05 14:24:33.526645", - "modified_by": "Administrator", - "module": "Core", - "name": "Payment Gateway", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2022-01-24 21:17:03.864719", + "modified_by": "Administrator", + "module": "Core", + "name": "Payment Gateway", + "naming_rule": "By fieldname", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "System Manager", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 } - ], - "quick_entry": 1, - "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 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 266017dd71..9cb40dffd4 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -61,7 +61,7 @@ class Report(Document): delete_permanently=True) def get_columns(self): - return [d.as_dict(no_default_fields = True) for d in self.columns] + return [d.as_dict(no_default_fields=True, no_child_table_fields=True) for d in self.columns] @frappe.whitelist() def set_doctype_roles(self): diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py index 36e3b09254..a077956d71 100644 --- a/frappe/core/doctype/report/test_report.py +++ b/frappe/core/doctype/report/test_report.py @@ -3,8 +3,10 @@ import frappe, json, os import unittest -from frappe.desk.query_report import run, save_report +from frappe.desk.query_report import run, save_report, add_total_row +from frappe.desk.reportview import delete_report, save_report as _save_report from frappe.custom.doctype.customize_form.customize_form import reset_customization +from frappe.core.doctype.user_permission.test_user_permission import create_user test_records = frappe.get_test_records('Report') test_dependencies = ['User'] @@ -30,6 +32,60 @@ class TestReport(unittest.TestCase): self.assertEqual(columns[1].get('label'), 'Module') self.assertTrue('User' in [d.get('name') for d in data]) + def test_save_or_delete_report(self): + '''Test for validations when editing / deleting report of type Report Builder''' + + try: + report = frappe.get_doc({ + 'doctype': 'Report', + 'ref_doctype': 'User', + 'report_name': 'Test Delete Report', + 'report_type': 'Report Builder', + 'is_standard': 'No', + }).insert() + + # Check for PermissionError + create_user("test_report_owner@example.com", "Website Manager") + frappe.set_user("test_report_owner@example.com") + self.assertRaises(frappe.PermissionError, delete_report, report.name) + + # Check for Report Type + frappe.set_user("Administrator") + report.db_set("report_type", "Custom Report") + self.assertRaisesRegex( + frappe.ValidationError, + "Only reports of type Report Builder can be deleted", + delete_report, + report.name + ) + + # Check if creating and deleting works with proper validations + frappe.set_user("test@example.com") + report_name = _save_report( + 'Dummy Report', + 'User', + json.dumps([{ + 'fieldname': 'email', + 'fieldtype': 'Data', + 'label': 'Email', + 'insert_after_index': 0, + 'link_field': 'name', + 'doctype': 'User', + 'options': 'Email', + 'width': 100, + 'id':'email', + 'name': 'Email' + }]) + ) + + doc = frappe.get_doc("Report", report_name) + delete_report(doc.name) + + finally: + frappe.set_user("Administrator") + frappe.db.rollback() + + def test_custom_report(self): reset_customization('User') custom_report_name = save_report( @@ -226,3 +282,56 @@ result = [ # Set user back to administrator frappe.set_user('Administrator') + + def test_add_total_row_for_tree_reports(self): + report_settings = { + 'tree': True, + 'parent_field': 'parent_value' + } + + columns = [ + { + "fieldname": "parent_column", + "label": "Parent Column", + "fieldtype": "Data", + "width": 10 + }, + { + "fieldname": "column_1", + "label": "Column 1", + "fieldtype": "Float", + "width": 10 + }, + { + "fieldname": "column_2", + "label": "Column 2", + "fieldtype": "Float", + "width": 10 + } + ] + + result = [ + { + "parent_column": "Parent 1", + "column_1": 200, + "column_2": 150.50 + }, + { + "parent_column": "Child 1", + "column_1": 100, + "column_2": 75.25, + "parent_value": "Parent 1" + }, + { + "parent_column": "Child 2", + "column_1": 100, + "column_2": 75.25, + "parent_value": "Parent 1" + } + ] + + result = add_total_row(result, columns, meta=None, is_tree=report_settings['tree'], + parent_field=report_settings['parent_field']) + self.assertEqual(result[-1][0], "Total") + self.assertEqual(result[-1][1], 200) + self.assertEqual(result[-1][2], 150.50) diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index 389e18dd4c..f955c29462 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -61,7 +61,7 @@ class Role(Document): def get_info_based_on_role(role, field='email'): ''' Get information of all users that have been assigned this role ''' - users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"}, + users = frappe.get_list("Has Role", filters={"role": role}, parent_doctype="User", fields=["parent as user_name"]) return get_user_info(users, field) diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index d8c945fb6d..b5f3ba7168 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -34,19 +34,7 @@ def run_server_script_for_doc_event(doc, event): if scripts: # run all scripts for this doctype + event for script_name in scripts: - 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) + frappe.get_doc('Server Script', script_name).execute_doc(doc) 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 bc92061f42..aa4507b858 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -112,7 +112,10 @@ class TestServerScript(unittest.TestCase): self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello') def test_permission_query(self): - self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False)) + if frappe.conf.db_type == "mariadb": + self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False)) + else: + self.assertTrue('where (1 = \'1\')' in frappe.db.get_list('ToDo', run=False)) self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list)) def test_attribute_error(self): @@ -139,3 +142,42 @@ class TestServerScript(unittest.TestCase): server_script.disabled = 1 server_script.save() + + def test_restricted_qb(self): + todo = frappe.get_doc(doctype="ToDo", description="QbScriptTestNote") + todo.insert() + + script = frappe.get_doc( + doctype='Server Script', + name='test_qb_restrictions', + script_type = 'API', + api_method = 'test_qb_restrictions', + allow_guest = 1, + # whitelisted update + script = f''' +frappe.db.set_value("ToDo", "{todo.name}", "description", "safe") +''' + ) + script.insert() + script.execute_method() + + todo.reload() + self.assertEqual(todo.description, "safe") + + # unsafe update + script.script = f""" +todo = frappe.qb.DocType("ToDo") +frappe.qb.update(todo).set(todo.description, "unsafe").where(todo.name == "{todo.name}").run() +""" + script.save() + self.assertRaises(frappe.PermissionError, script.execute_method) + todo.reload() + self.assertEqual(todo.description, "safe") + + # safe select + script.script = f""" +todo = frappe.qb.DocType("ToDo") +frappe.qb.from_(todo).select(todo.name).where(todo.name == "{todo.name}").run() +""" + script.save() + script.execute_method() diff --git a/frappe/core/doctype/test/test.py b/frappe/core/doctype/test/test.py index 4cb088c117..ab6fcb6de4 100644 --- a/frappe/core/doctype/test/test.py +++ b/frappe/core/doctype/test/test.py @@ -31,4 +31,15 @@ class test(Document): def get_value(self, fields, filters, **kwargs): # return [] with open("data_file.json", "r") as read_file: - return [json.load(read_file)] \ No newline at end of file + return [json.load(read_file)] + + def get_count(self, args): + # return [] + with open("data_file.json", "r") as read_file: + return [json.load(read_file)] + + def get_stats(self, args): + # return [] + with open("data_file.json", "r") as read_file: + return [json.load(read_file)] + diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index d1291acfc4..3e6e1ec7e2 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -355,7 +355,11 @@ class TestUser(unittest.TestCase): test_user.reload() self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/") update_password(old_password, old_password=new_password) - self.assertEqual(json.loads(frappe.message_log[0]), {"message": "Password reset instructions have been sent to your email"}) + self.assertEqual( + json.loads(frappe.message_log[0]).get("message"), + "Password reset instructions have been sent to your email" + ) + sendmail.assert_called_once() self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com") diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index a47f539466..9e9529cd5e 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -668,8 +668,7 @@ "link_fieldname": "user" } ], - "max_attachments": 5, - "modified": "2022-01-03 11:53:25.250822", + "modified": "2022-03-09 01:47:56.745069", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index b674ea6891..1ad977547c 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -253,8 +253,8 @@ class User(Document): self.email_new_password(new_password) except frappe.OutgoingEmailError: - print(frappe.get_traceback()) - pass # email server not set, don't send email + # email server not set, don't send email + frappe.log_error(frappe.get_traceback()) @Document.hook def validate_reset_password(self): @@ -344,7 +344,7 @@ class User(Document): frappe.sendmail(recipients=self.email, sender=sender, subject=subject, template=template, args=args, header=[subject, "green"], - delayed=(not now) if now!=None else self.flags.delay_emails, retry=3) + delayed=(not now) if now is not None else self.flags.delay_emails, retry=3) def a_system_manager_should_exist(self): if not self.get_other_system_managers(): @@ -756,7 +756,7 @@ def verify_password(password): @frappe.whitelist(allow_guest=True) def sign_up(email, full_name, redirect_to): if is_signup_disabled(): - frappe.throw(_('Sign Up is disabled'), title='Not Allowed') + frappe.throw(_("Sign Up is disabled"), title=_("Not Allowed")) user = frappe.db.get("User", {"email": email}) if user: @@ -810,8 +810,10 @@ def reset_password(user): user.validate_reset_password() user.reset_password(send_email=True) - return frappe.msgprint(_("Password reset instructions have been sent to your email")) - + return frappe.msgprint( + msg=_("Password reset instructions have been sent to your email"), + title=_("Password Email Sent") + ) except frappe.DoesNotExistError: frappe.local.response['http_status_code'] = 400 frappe.clear_messages() diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index cf905c2ce2..d4a9d68fd5 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -3,6 +3,7 @@ from frappe.core.doctype.user_permission.user_permission import add_user_permissions, remove_applicable from frappe.permissions import has_user_permission from frappe.core.doctype.doctype.test_doctype import new_doctype +from frappe.website.doctype.blog_post.test_blog_post import make_test_blog import frappe import unittest @@ -31,6 +32,18 @@ class TestUserPermission(unittest.TestCase): param = get_params(user, 'User', perm_user.name, is_default=1) self.assertRaises(frappe.ValidationError, add_user_permissions, param) + def test_default_user_permission_corectness(self): + user = create_user('test_default_corectness_permission_1@example.com') + param = get_params(user, 'User', user.name, is_default=1, hide_descendants= 1) + add_user_permissions(param) + #create a duplicate entry with default + perm_user = create_user('test_default_corectness2@example.com') + test_blog = make_test_blog() + param = get_params(perm_user, 'Blog Post', test_blog.name, is_default=1, hide_descendants= 1) + add_user_permissions(param) + frappe.db.delete('User Permission', filters={'for_value': test_blog.name}) + frappe.delete_doc('Blog Post', test_blog.name) + def test_default_user_permission(self): frappe.set_user('Administrator') user = create_user('test_user_perm1@example.com', 'Website Manager') diff --git a/frappe/core/doctype/user_permission/user_permission.js b/frappe/core/doctype/user_permission/user_permission.js index 8d5c5c1a23..f6989db5d8 100644 --- a/frappe/core/doctype/user_permission/user_permission.js +++ b/frappe/core/doctype/user_permission/user_permission.js @@ -44,8 +44,9 @@ 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 && frm.doc.applicable_for) { - frm.set_value('applicable_for', null); + frm.set_value('applicable_for', null, null, true); } }, diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 1366ace115..fb658481b2 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -48,7 +48,6 @@ class UserPermission(Document): }, or_filters={ 'applicable_for': cstr(self.applicable_for), 'apply_to_all_doctypes': 1, - 'hide_descendants': cstr(self.hide_descendants) }, limit=1) if overlap_exists: ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name) diff --git a/frappe/core/doctype/user_type/test_user_type.py b/frappe/core/doctype/user_type/test_user_type.py index 7080e1830b..6807f8fc9e 100644 --- a/frappe/core/doctype/user_type/test_user_type.py +++ b/frappe/core/doctype/user_type/test_user_type.py @@ -1,8 +1,68 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors # License: MIT. See LICENSE -# import frappe +import frappe import unittest +from frappe.installer import update_site_config + class TestUserType(unittest.TestCase): - pass + def setUp(self): + create_role() + + def test_add_select_perm_doctypes(self): + user_type = create_user_type('Test User Type') + + # select perms added for all link fields + doc = frappe.get_meta('Contact') + link_fields = doc.get_link_fields() + select_doctypes = frappe.get_all('User Select Document Type', {'parent': user_type.name}, pluck='document_type') + + for entry in link_fields: + self.assertTrue(entry.options in select_doctypes) + + # select perms added for all child table link fields + link_fields = [] + for child_table in doc.get_table_fields(): + child_doc = frappe.get_meta(child_table.options) + link_fields.extend(child_doc.get_link_fields()) + + for entry in link_fields: + self.assertTrue(entry.options in select_doctypes) + + def tearDown(self): + frappe.db.rollback() + + +def create_user_type(user_type): + if frappe.db.exists('User Type', user_type): + frappe.delete_doc('User Type', user_type) + + user_type_limit = {frappe.scrub(user_type): 1} + update_site_config('user_type_doctype_limit', user_type_limit) + + doc = frappe.get_doc({ + 'doctype': 'User Type', + 'name': user_type, + 'role': '_Test User Type', + 'user_id_field': 'user', + 'apply_user_permission_on': 'User' + }) + + doc.append('user_doctypes', { + 'document_type': 'Contact', + 'read': 1, + 'write': 1 + }) + + return doc.insert() + + +def create_role(): + if not frappe.db.exists('Role', '_Test User Type'): + frappe.get_doc({ + 'doctype': 'Role', + 'role_name': '_Test User Type', + 'desk_access': 1, + 'is_custom': 1 + }).insert() \ No newline at end of file diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 661ac932e7..c0dfd2e597 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -121,7 +121,7 @@ class UserType(Document): for child_table in doc.get_table_fields(): child_doc = frappe.get_meta(child_table.options) - if not child_doc.istable: + if child_doc: self.prepare_select_perm_doctypes(child_doc, user_doctypes, select_doctypes) if select_doctypes: @@ -193,7 +193,7 @@ def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters ['DocType', 'read_only', '=', 0], ['DocType', 'name', 'like', '%{0}%'.format(txt)]] doctypes = frappe.get_all('DocType', fields = ['`tabDocType`.`name`'], filters=filters, - order_by = '`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1) + order_by='`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1) custom_dt_filters = [['Custom Field', 'dt', 'like', '%{0}%'.format(txt)], ['Custom Field', 'options', '=', 'User'], ['Custom Field', 'fieldtype', '=', 'Link']] diff --git a/frappe/core/notifications.py b/frappe/core/notifications.py index be3e723af6..5f41f217f0 100644 --- a/frappe/core/notifications.py +++ b/frappe/core/notifications.py @@ -39,43 +39,3 @@ def get_todays_events(as_list=False): today = nowdate() events = get_events(today, today) return events if as_list else len(events) - -def get_unseen_likes(): - """Returns count of unseen likes""" - - 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 count of unread emails for a user" - - 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) - ) - ) diff --git a/frappe/core/page/dashboard_view/dashboard_view.js b/frappe/core/page/dashboard_view/dashboard_view.js index e8e9cc9502..bf9fb2a286 100644 --- a/frappe/core/page/dashboard_view/dashboard_view.js +++ b/frappe/core/page/dashboard_view/dashboard_view.js @@ -30,6 +30,7 @@ class Dashboard { show() { this.route = frappe.get_route(); + this.set_breadcrumbs(); if (this.route.length > 1) { // from route this.show_dashboard(this.route.slice(-1)[0]); @@ -75,6 +76,10 @@ class Dashboard { frappe.last_dashboard = current_dashboard_name; } + set_breadcrumbs() { + frappe.breadcrumbs.add("Desk", "Dashboard"); + } + refresh() { frappe.run_serially([ () => this.render_cards(), diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js index 6b427fdebf..cb218b2eae 100644 --- a/frappe/core/page/permission_manager/permission_manager.js +++ b/frappe/core/page/permission_manager/permission_manager.js @@ -347,6 +347,7 @@ frappe.PermissionEngine = class PermissionEngine { } add_check_events() { + let me = this; this.body.on("click", ".show-user-permissions", () => { frappe.route_options = { allow: this.get_doctype() || "" }; frappe.set_route('List', 'User Permission'); @@ -373,7 +374,7 @@ frappe.PermissionEngine = class PermissionEngine { // exception: reverse chk.prop("checked", !chk.prop("checked")); } else { - this.get_perm(args.role)[args.ptype] = args.value; + me.get_perm(args.role)[args.ptype] = args.value; } } }); diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json index aabb4f9d1c..c1c506ae3a 100644 --- a/frappe/core/workspace/build/build.json +++ b/frappe/core/workspace/build/build.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Workspace\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Elements\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Workspace\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Elements\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}}]", "creation": "2021-01-02 10:51:16.579957", "docstatus": 0, "doctype": "Workspace", @@ -222,7 +222,7 @@ "type": "Link" } ], - "modified": "2021-09-05 21:14:52.384816", + "modified": "2022-01-13 17:26:02.736366", "modified_by": "Administrator", "module": "Core", "name": "Build", @@ -231,7 +231,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 5, + "sequence_id": 5.0, "shortcuts": [ { "doc_view": "", diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json index 917ce2cbdc..5aadbc42d5 100644 --- a/frappe/core/workspace/settings/settings.json +++ b/frappe/core/workspace/settings/settings.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\":\"header\",\"data\": {\"text\":\"Settings\",\"level\": 4,\"col\": 12}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"System Settings\",\"col\": 4}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"Print Settings\",\"col\": 4}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"Website Settings\",\"col\": 4}}, {\"type\":\"spacer\",\"data\": {\"col\": 12}}, {\"type\":\"header\",\"data\": {\"text\":\"Reports & Masters\",\"level\": 4,\"col\": 12}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Data\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Email / Notifications\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Website\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Core\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Printing\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Workflow\",\"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Settings\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"System Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Print Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Website Settings\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email / Notifications\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Website\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Core\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Workflow\",\"col\":4}}]", "creation": "2020-03-02 15:09:40.527211", "docstatus": 0, "doctype": "Workspace", @@ -367,7 +367,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:03.456174", + "modified": "2022-01-13 17:49:59.586909", "modified_by": "Administrator", "module": "Core", "name": "Settings", @@ -376,7 +376,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 29, + "sequence_id": 29.0, "shortcuts": [ { "icon": "setting", diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json index 85c110151b..5741c54eeb 100644 --- a/frappe/core/workspace/users/users.json +++ b/frappe/core/workspace/users/users.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Permission Manager\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Profile\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Type\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Users\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Logs\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Permissions\", \"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Permission Manager\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Profile\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Type\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Users\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Permissions\",\"col\":4}}]", "creation": "2020-03-02 15:12:16.754449", "docstatus": 0, "doctype": "Workspace", @@ -145,7 +145,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:03.010205", + "modified": "2022-01-13 17:49:08.912772", "modified_by": "Administrator", "module": "Core", "name": "Users", @@ -154,7 +154,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 27, + "sequence_id": 27.0, "shortcuts": [ { "label": "User", diff --git a/frappe/coverage.py b/frappe/coverage.py index 1969cae141..5f89800deb 100644 --- a/frappe/coverage.py +++ b/frappe/coverage.py @@ -29,6 +29,7 @@ FRAPPE_EXCLUSIONS = [ "*/commands/*", "*/frappe/change_log/*", "*/frappe/exceptions*", + "*/frappe/coverage.py", "*frappe/setup.py", "*/doctype/*/*_dashboard.py", "*/patches/*", diff --git a/frappe/custom/doctype/client_script/client_script.js b/frappe/custom/doctype/client_script/client_script.js index ad9c9e4e42..18786c62cf 100644 --- a/frappe/custom/doctype/client_script/client_script.js +++ b/frappe/custom/doctype/client_script/client_script.js @@ -2,6 +2,9 @@ // For license information, please see license.txt frappe.ui.form.on('Client Script', { + setup(frm) { + frm.get_field("sample").html(SAMPLE_HTML); + }, refresh(frm) { if (frm.doc.dt && frm.doc.script) { frm.add_custom_button(__('Go to {0}', [frm.doc.dt]), @@ -97,3 +100,56 @@ frappe.ui.form.on('${doctype}', { frm.set_value('script', script + boilerplate); } }); + +const SAMPLE_HTML = `

Client Script Help

+

Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started

+

+
+// fetch local_tax_no on selection of customer
+// cur_frm.add_fetch(link_field,  source_fieldname,  target_fieldname);
+cur_frm.add_fetch("customer",  "local_tax_no',  'local_tax_no');
+
+// additional validation on dates
+frappe.ui.form.on('Task',  'validate',  function(frm) {
+    if (frm.doc.from_date < get_today()) {
+        msgprint('You can not select past date in From Date');
+        validated = false;
+    }
+});
+
+// make a field read-only after saving
+frappe.ui.form.on('Task',  {
+    refresh: function(frm) {
+        // use the __islocal value of doc,  to check if the doc is saved or not
+        frm.set_df_property('myfield',  'read_only',  frm.doc.__islocal ? 0 : 1);
+    }
+});
+
+// additional permission check
+frappe.ui.form.on('Task',  {
+    validate: function(frm) {
+        if(user=='user1@example.com' && frm.doc.purpose!='Material Receipt') {
+            msgprint('You are only allowed Material Receipt');
+            validated = false;
+        }
+    }
+});
+
+// calculate sales incentive
+frappe.ui.form.on('Sales Invoice',  {
+    validate: function(frm) {
+        // calculate incentives for each person on the deal
+        total_incentive = 0
+        $.each(frm.doc.sales_team,  function(i,  d) {
+            // calculate incentive
+            var incentive_percent = 2;
+            if(frm.doc.base_grand_total > 400) incentive_percent = 4;
+            // actual incentive
+            d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;
+            total_incentive += flt(d.incentives)
+        });
+        frm.doc.total_incentive = total_incentive;
+    }
+})
+
+
`; diff --git a/frappe/custom/doctype/client_script/client_script.json b/frappe/custom/doctype/client_script/client_script.json index 50f6bf3cc4..eca84b4dec 100644 --- a/frappe/custom/doctype/client_script/client_script.json +++ b/frappe/custom/doctype/client_script/client_script.json @@ -40,8 +40,7 @@ { "fieldname": "sample", "fieldtype": "HTML", - "label": "Sample", - "options": "

Client Script Help

\n

Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started

\n
\n\n// fetch local_tax_no on selection of customer \n// cur_frm.add_fetch(link_field,  source_fieldname,  target_fieldname); \ncur_frm.add_fetch('customer',  'local_tax_no',  'local_tax_no');\n\n// additional validation on dates \nfrappe.ui.form.on('Task',  'validate',  function(frm) {\n    if (frm.doc.from_date < get_today()) {\n        msgprint('You can not select past date in From Date');\n        validated = false;\n    } \n});\n\n// make a field read-only after saving \nfrappe.ui.form.on('Task',  {\n    refresh: function(frm) {\n        // use the __islocal value of doc,  to check if the doc is saved or not\n        frm.set_df_property('myfield',  'read_only',  frm.doc.__islocal ? 0 : 1);\n    } \n});\n\n// additional permission check\nfrappe.ui.form.on('Task',  {\n    validate: function(frm) {\n        if(user=='user1@example.com' && frm.doc.purpose!='Material Receipt') {\n            msgprint('You are only allowed Material Receipt');\n            validated = false;\n        }\n    } \n});\n\n// calculate sales incentive\nfrappe.ui.form.on('Sales Invoice',  {\n    validate: function(frm) {\n        // calculate incentives for each person on the deal\n        total_incentive = 0\n        $.each(frm.doc.sales_team,  function(i,  d) {\n            // calculate incentive\n            var incentive_percent = 2;\n            if(frm.doc.base_grand_total > 400) incentive_percent = 4;\n            // actual incentive\n            d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;\n            total_incentive += flt(d.incentives)\n        });\n        frm.doc.total_incentive = total_incentive;\n    } \n})\n\n
" + "label": "Sample" }, { "default": "0", @@ -76,7 +75,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-09-04 12:03:27.029815", + "modified": "2022-02-18 00:43:33.941466", "modified_by": "Administrator", "module": "Custom", "name": "Client Script", @@ -107,5 +106,6 @@ ], "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 516daf3521..e54e955819 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -1,458 +1,468 @@ { - "actions": [], - "allow_import": 1, - "creation": "2013-01-10 16:34:01", - "description": "Adds a custom field to a DocType", - "doctype": "DocType", - "document_type": "Setup", - "engine": "InnoDB", - "field_order": [ - "dt", - "module", - "label", - "label_help", - "fieldname", - "insert_after", - "length", - "column_break_6", - "fieldtype", - "precision", - "hide_seconds", - "hide_days", - "options", - "fetch_from", - "fetch_if_empty", - "options_help", - "section_break_11", - "collapsible", - "collapsible_depends_on", - "default", - "depends_on", - "mandatory_depends_on", - "read_only_depends_on", - "properties", - "non_negative", - "reqd", - "unique", - "read_only", - "ignore_user_permissions", - "hidden", - "print_hide", - "print_hide_if_no_value", - "print_width", - "no_copy", - "allow_on_submit", - "in_list_view", - "in_standard_filter", - "in_global_search", - "in_preview", - "bold", - "report_hide", - "search_index", - "allow_in_quick_entry", - "ignore_xss_filter", - "translatable", - "hide_border", - "description", - "permlevel", - "width", - "columns" - ], - "fields": [{ - "bold": 1, - "fieldname": "dt", - "fieldtype": "Link", - "in_filter": 1, - "in_list_view": 1, - "label": "Document", - "oldfieldname": "dt", - "oldfieldtype": "Link", - "options": "DocType", - "reqd": 1, - "search_index": 1 - }, - { - "bold": 1, - "fieldname": "label", - "fieldtype": "Data", - "in_filter": 1, - "label": "Label", - "no_copy": 1, - "oldfieldname": "label", - "oldfieldtype": "Data" - }, - { - "fieldname": "label_help", - "fieldtype": "HTML", - "label": "Label Help", - "oldfieldtype": "HTML" - }, - { - "fieldname": "fieldname", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Fieldname", - "no_copy": 1, - "oldfieldname": "fieldname", - "oldfieldtype": "Data", - "read_only": 1 - }, - { - "description": "Select the label after which you want to insert new field.", - "fieldname": "insert_after", - "fieldtype": "Select", - "label": "Insert After", - "no_copy": 1, - "oldfieldname": "insert_after", - "oldfieldtype": "Select" - }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break" - }, - { - "bold": 1, - "default": "Data", - "fieldname": "fieldtype", - "fieldtype": "Select", - "in_filter": 1, - "in_list_view": 1, - "label": "Field 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", - "reqd": 1 - }, - { - "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", - "description": "Set non-standard precision for a Float or Currency field", - "fieldname": "precision", - "fieldtype": "Select", - "label": "Precision", - "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" - }, - { - "fieldname": "options", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Options", - "oldfieldname": "options", - "oldfieldtype": "Text" - }, - { - "fieldname": "fetch_from", - "fieldtype": "Small Text", - "label": "Fetch From" - }, - { - "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", - "fieldname": "fetch_if_empty", - "fieldtype": "Check", - "label": "Fetch If Empty" - }, - { - "fieldname": "options_help", - "fieldtype": "HTML", - "label": "Options Help", - "oldfieldtype": "HTML" - }, - { - "fieldname": "section_break_11", - "fieldtype": "Section Break" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible", - "fieldtype": "Check", - "label": "Collapsible" - }, - { - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible_depends_on", - "fieldtype": "Code", - "label": "Collapsible Depends On" - }, - { - "fieldname": "default", - "fieldtype": "Text", - "label": "Default Value", - "oldfieldname": "default", - "oldfieldtype": "Text" - }, - { - "fieldname": "depends_on", - "fieldtype": "Code", - "label": "Depends On", - "length": 255 - }, - { - "fieldname": "description", - "fieldtype": "Text", - "label": "Field Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "print_width": "300px", - "width": "300px" - }, - { - "default": "0", - "fieldname": "permlevel", - "fieldtype": "Int", - "label": "Permission Level", - "oldfieldname": "permlevel", - "oldfieldtype": "Int" - }, - { - "fieldname": "width", - "fieldtype": "Data", - "label": "Width", - "oldfieldname": "width", - "oldfieldtype": "Data" - }, - { - "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", - "fieldname": "columns", - "fieldtype": "Int", - "label": "Columns" - }, - { - "fieldname": "properties", - "fieldtype": "Column Break", - "oldfieldtype": "Column Break", - "print_width": "50%", - "width": "50%" - }, - { - "default": "0", - "fieldname": "reqd", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Is Mandatory Field", - "oldfieldname": "reqd", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "unique", - "fieldtype": "Check", - "label": "Unique" - }, - { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "Read Only" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype===\"Link\"", - "fieldname": "ignore_user_permissions", - "fieldtype": "Check", - "label": "Ignore User Permissions" - }, - { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "label": "Hidden" - }, - { - "default": "0", - "fieldname": "print_hide", - "fieldtype": "Check", - "label": "Print Hide", - "oldfieldname": "print_hide", - "oldfieldtype": "Check" - }, - { - "default": "0", - "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", - "fieldname": "print_hide_if_no_value", - "fieldtype": "Check", - "label": "Print Hide If No Value" - }, - { - "fieldname": "print_width", - "fieldtype": "Data", - "hidden": 1, - "label": "Print Width", - "no_copy": 1, - "print_hide": 1 - }, - { - "default": "0", - "fieldname": "no_copy", - "fieldtype": "Check", - "label": "No Copy", - "oldfieldname": "no_copy", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_on_submit", - "fieldtype": "Check", - "label": "Allow on Submit", - "oldfieldname": "allow_on_submit", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "in_list_view", - "fieldtype": "Check", - "label": "In List View" - }, - { - "default": "0", - "fieldname": "in_standard_filter", - "fieldtype": "Check", - "label": "In Standard Filter" - }, - { - "default": "0", - "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", - "fieldname": "in_global_search", - "fieldtype": "Check", - "label": "In Global Search" - }, - { - "default": "0", - "fieldname": "bold", - "fieldtype": "Check", - "label": "Bold" - }, - { - "default": "0", - "fieldname": "report_hide", - "fieldtype": "Check", - "label": "Report Hide", - "oldfieldname": "report_hide", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "search_index", - "fieldtype": "Check", - "hidden": 1, - "label": "Index", - "no_copy": 1, - "print_hide": 1 - }, - { - "default": "0", - "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", - "fieldname": "ignore_xss_filter", - "fieldtype": "Check", - "label": "Ignore XSS Filter" - }, - { - "default": "1", - "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", - "fieldname": "translatable", - "fieldtype": "Check", - "label": "Translatable" - }, - { - "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", - "fieldname": "length", - "fieldtype": "Int", - "label": "Length" - }, - { - "fieldname": "mandatory_depends_on", - "fieldtype": "Code", - "label": "Mandatory Depends On", - "length": 255 - }, - { - "fieldname": "read_only_depends_on", - "fieldtype": "Code", - "label": "Read Only Depends On", - "length": 255 - }, - { - "default": "0", - "fieldname": "allow_in_quick_entry", - "fieldtype": "Check", - "label": "Allow in Quick Entry" - }, - { - "default": "0", - "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", - "fieldname": "in_preview", - "fieldtype": "Check", - "label": "In Preview" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_seconds", - "fieldtype": "Check", - "label": "Hide Seconds" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_days", - "fieldtype": "Check", - "label": "Hide Days" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Section Break'", - "fieldname": "hide_border", - "fieldtype": "Check", - "label": "Hide Border" - }, - { - "default": "0", - "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", - "fieldname": "non_negative", - "fieldtype": "Check", - "label": "Non Negative" - }, - { - "fieldname": "module", - "fieldtype": "Link", - "label": "Module (for export)", - "options": "Module Def" - } - ], - "icon": "fa fa-glass", - "idx": 1, - "index_web_pages_for_search": 1, - "links": [], - "modified": "2021-09-04 12:45:23.810120", - "modified_by": "Administrator", - "module": "Custom", - "name": "Custom Field", - "owner": "Administrator", - "permissions": [{ - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Administrator", - "share": 1, - "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "search_fields": "dt,label,fieldtype,options", - "sort_field": "modified", - "sort_order": "ASC", - "track_changes": 1 -} + "actions": [], + "allow_import": 1, + "creation": "2013-01-10 16:34:01", + "description": "Adds a custom field to a DocType", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "dt", + "module", + "label", + "label_help", + "fieldname", + "insert_after", + "length", + "column_break_6", + "fieldtype", + "precision", + "hide_seconds", + "hide_days", + "options", + "fetch_from", + "fetch_if_empty", + "options_help", + "section_break_11", + "collapsible", + "collapsible_depends_on", + "default", + "depends_on", + "mandatory_depends_on", + "read_only_depends_on", + "properties", + "non_negative", + "reqd", + "unique", + "is_virtual", + "read_only", + "ignore_user_permissions", + "hidden", + "print_hide", + "print_hide_if_no_value", + "print_width", + "no_copy", + "allow_on_submit", + "in_list_view", + "in_standard_filter", + "in_global_search", + "in_preview", + "bold", + "report_hide", + "search_index", + "allow_in_quick_entry", + "ignore_xss_filter", + "translatable", + "hide_border", + "description", + "permlevel", + "width", + "columns" + ], + "fields": [ + { + "bold": 1, + "fieldname": "dt", + "fieldtype": "Link", + "in_filter": 1, + "in_list_view": 1, + "label": "Document", + "oldfieldname": "dt", + "oldfieldtype": "Link", + "options": "DocType", + "reqd": 1, + "search_index": 1 + }, + { + "bold": 1, + "fieldname": "label", + "fieldtype": "Data", + "in_filter": 1, + "label": "Label", + "no_copy": 1, + "oldfieldname": "label", + "oldfieldtype": "Data" + }, + { + "fieldname": "label_help", + "fieldtype": "HTML", + "label": "Label Help", + "oldfieldtype": "HTML" + }, + { + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Fieldname", + "no_copy": 1, + "oldfieldname": "fieldname", + "oldfieldtype": "Data", + "read_only": 1 + }, + { + "description": "Select the label after which you want to insert new field.", + "fieldname": "insert_after", + "fieldtype": "Select", + "label": "Insert After", + "no_copy": 1, + "oldfieldname": "insert_after", + "oldfieldtype": "Select" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "bold": 1, + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_filter": 1, + "in_list_view": 1, + "label": "Field Type", + "oldfieldname": "fieldtype", + "oldfieldtype": "Select", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\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", + "reqd": 1 + }, + { + "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", + "description": "Set non-standard precision for a Float or Currency field", + "fieldname": "precision", + "fieldtype": "Select", + "label": "Precision", + "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" + }, + { + "fieldname": "options", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Options", + "oldfieldname": "options", + "oldfieldtype": "Text" + }, + { + "fieldname": "fetch_from", + "fieldtype": "Small Text", + "label": "Fetch From" + }, + { + "default": "0", + "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", + "fieldname": "fetch_if_empty", + "fieldtype": "Check", + "label": "Fetch If Empty" + }, + { + "fieldname": "options_help", + "fieldtype": "HTML", + "label": "Options Help", + "oldfieldtype": "HTML" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible", + "fieldtype": "Check", + "label": "Collapsible" + }, + { + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible_depends_on", + "fieldtype": "Code", + "label": "Collapsible Depends On" + }, + { + "fieldname": "default", + "fieldtype": "Text", + "label": "Default Value", + "oldfieldname": "default", + "oldfieldtype": "Text" + }, + { + "fieldname": "depends_on", + "fieldtype": "Code", + "label": "Depends On", + "length": 255 + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Field Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "width": "300px" + }, + { + "default": "0", + "fieldname": "permlevel", + "fieldtype": "Int", + "label": "Permission Level", + "oldfieldname": "permlevel", + "oldfieldtype": "Int" + }, + { + "fieldname": "width", + "fieldtype": "Data", + "label": "Width", + "oldfieldname": "width", + "oldfieldtype": "Data" + }, + { + "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", + "fieldname": "columns", + "fieldtype": "Int", + "label": "Columns" + }, + { + "fieldname": "properties", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break", + "print_width": "50%", + "width": "50%" + }, + { + "default": "0", + "fieldname": "reqd", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Mandatory Field", + "oldfieldname": "reqd", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "unique", + "fieldtype": "Check", + "label": "Unique" + }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Is Virtual" + }, + { + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "Read Only" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Link\"", + "fieldname": "ignore_user_permissions", + "fieldtype": "Check", + "label": "Ignore User Permissions" + }, + { + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden" + }, + { + "default": "0", + "fieldname": "print_hide", + "fieldtype": "Check", + "label": "Print Hide", + "oldfieldname": "print_hide", + "oldfieldtype": "Check" + }, + { + "default": "0", + "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", + "fieldname": "print_hide_if_no_value", + "fieldtype": "Check", + "label": "Print Hide If No Value" + }, + { + "fieldname": "print_width", + "fieldtype": "Data", + "hidden": 1, + "label": "Print Width", + "no_copy": 1, + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "no_copy", + "fieldtype": "Check", + "label": "No Copy", + "oldfieldname": "no_copy", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "allow_on_submit", + "fieldtype": "Check", + "label": "Allow on Submit", + "oldfieldname": "allow_on_submit", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "in_list_view", + "fieldtype": "Check", + "label": "In List View" + }, + { + "default": "0", + "fieldname": "in_standard_filter", + "fieldtype": "Check", + "label": "In Standard Filter" + }, + { + "default": "0", + "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", + "fieldname": "in_global_search", + "fieldtype": "Check", + "label": "In Global Search" + }, + { + "default": "0", + "fieldname": "bold", + "fieldtype": "Check", + "label": "Bold" + }, + { + "default": "0", + "fieldname": "report_hide", + "fieldtype": "Check", + "label": "Report Hide", + "oldfieldname": "report_hide", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "search_index", + "fieldtype": "Check", + "hidden": 1, + "label": "Index", + "no_copy": 1, + "print_hide": 1 + }, + { + "default": "0", + "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", + "fieldname": "ignore_xss_filter", + "fieldtype": "Check", + "label": "Ignore XSS Filter" + }, + { + "default": "1", + "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", + "fieldname": "translatable", + "fieldtype": "Check", + "label": "Translatable" + }, + { + "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", + "fieldname": "length", + "fieldtype": "Int", + "label": "Length" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "length": 255 + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "length": 255 + }, + { + "default": "0", + "fieldname": "allow_in_quick_entry", + "fieldtype": "Check", + "label": "Allow in Quick Entry" + }, + { + "default": "0", + "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", + "fieldname": "in_preview", + "fieldtype": "Check", + "label": "In Preview" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", + "fieldname": "non_negative", + "fieldtype": "Check", + "label": "Non Negative" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "label": "Module (for export)", + "options": "Module Def" + } + ], + "icon": "fa fa-glass", + "idx": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2022-03-22 03:47:27.097911", + "modified_by": "Administrator", + "module": "Custom", + "name": "Custom Field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "dt,label,fieldtype,options", + "sort_field": "modified", + "sort_order": "ASC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 8f7b21dd24..cb1ea2c54d 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -54,7 +54,7 @@ class CustomField(Document): old_fieldtype = self.db_get('fieldtype') is_fieldtype_changed = (not self.is_new()) and (old_fieldtype != self.fieldtype) - if is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype): + if not self.is_virtual and is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype): frappe.throw(_("Fieldtype cannot be changed from {0} to {1}").format(old_fieldtype, self.fieldtype)) if not self.fieldname: @@ -65,7 +65,7 @@ class CustomField(Document): if not self.flags.ignore_validate: from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts - check_fieldname_conflicts(self.dt, self.fieldname) + check_fieldname_conflicts(self) def on_update(self): if not frappe.flags.in_setup_wizard: diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 4862185b99..9cfe315e44 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -14,7 +14,6 @@ frappe.ui.form.on("Customize Form", { }, onload: function(frm) { - frm.disable_save(); frm.set_query("doc_type", function() { return { translate_values: false, @@ -110,7 +109,7 @@ frappe.ui.form.on("Customize Form", { }, refresh: function(frm) { - frm.disable_save(); + frm.disable_save(true); frm.page.clear_icons(); if (frm.doc.doc_type) { @@ -169,7 +168,7 @@ frappe.ui.form.on("Customize Form", { doc_type = localStorage.getItem("customize_doctype"); } if (doc_type) { - setTimeout(() => frm.set_value("doc_type", doc_type), 1000); + setTimeout(() => frm.set_value("doc_type", doc_type, false, true), 1000); } }, @@ -341,11 +340,11 @@ frappe.customize_form.confirm = function(msg, frm) { } frappe.customize_form.clear_locals_and_refresh = function(frm) { + delete frm.doc.__unsaved; // clear doctype from locals frappe.model.clear_doc("DocType", frm.doc.doc_type); delete frappe.meta.docfield_copy[frm.doc.doc_type]; - 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 bdf95ad351..1ee9d4a02a 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -27,6 +27,7 @@ "autoname", "view_settings_section", "title_field", + "show_title_field_in_link", "image_field", "default_print_format", "column_break_29", @@ -296,6 +297,12 @@ "fieldtype": "Table", "label": "States", "options": "DocType State" + }, + { + "default": "0", + "fieldname": "show_title_field_in_link", + "fieldtype": "Check", + "label": "Show Title in Link Fields" } ], "hide_toolbar": 1, @@ -304,7 +311,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-12-14 16:45:04.308690", + "modified": "2022-01-07 16:07:06.196534", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 24a5d1358b..81cd38ff87 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -107,20 +107,26 @@ class CustomizeForm(Document): def set_name_translation(self): '''Create, update custom translation for this doctype''' current = self.get_name_translation() - if current: - if self.label and current.translated_text != self.label: - frappe.db.set_value('Translation', current.name, 'translated_text', self.label) - frappe.translate.clear_cache() - else: + if not self.label: + if current: # clear translation frappe.delete_doc('Translation', current.name) + return - else: - if self.label: - frappe.get_doc(dict(doctype='Translation', - source_text=self.doc_type, - translated_text=self.label, - language_code=frappe.local.lang or 'en')).insert() + if not current: + frappe.get_doc( + { + "doctype": 'Translation', + "source_text": self.doc_type, + "translated_text": self.label, + "language_code": frappe.local.lang or 'en' + } + ).insert() + return + + if self.label != current.translated_text: + frappe.db.set_value('Translation', current.name, 'translated_text', self.label) + frappe.translate.clear_cache() def clear_existing_doc(self): doc_type = self.doc_type @@ -377,7 +383,7 @@ class CustomizeForm(Document): def make_property_setter(self, prop, value, property_type, fieldname=None, apply_on=None, row_name = None): - delete_property_setter(self.doc_type, prop, fieldname) + delete_property_setter(self.doc_type, prop, fieldname, row_name) property_value = self.get_existing_property_value(prop, fieldname) @@ -412,6 +418,9 @@ class CustomizeForm(Document): return property_value def validate_fieldtype_change(self, df, old_value, new_value): + if df.is_virtual: + return + allowed = self.allow_fieldtype_change(old_value, new_value) if allowed: old_value_length = cint(frappe.db.type_map.get(old_value)[1]) @@ -424,7 +433,8 @@ class CustomizeForm(Document): self.validate_fieldtype_length() else: self.flags.update_db = True - if not allowed: + + else: frappe.throw(_("Fieldtype cannot be changed from {0} to {1} in row {2}").format(old_value, new_value, df.idx)) def validate_fieldtype_length(self): @@ -506,7 +516,8 @@ doctype_properties = { 'email_append_to': 'Check', 'subject_field': 'Data', 'sender_field': 'Data', - 'autoname': 'Data' + 'autoname': 'Data', + 'show_title_field_in_link': 'Check' } docfield_properties = { @@ -529,6 +540,7 @@ docfield_properties = { 'in_global_search': 'Check', 'in_preview': 'Check', 'bold': 'Check', + 'no_copy': 'Check', 'hidden': 'Check', 'collapsible': 'Check', 'collapsible_depends_on': 'Data', @@ -552,7 +564,8 @@ docfield_properties = { 'allow_in_quick_entry': 'Check', 'hide_border': 'Check', 'hide_days': 'Check', - 'hide_seconds': 'Check' + 'hide_seconds': 'Check', + 'is_virtual': 'Check', } doctype_link_properties = { @@ -587,4 +600,4 @@ ALLOWED_FIELDTYPE_CHANGE = ( ('Code', 'Geolocation'), ('Table', 'Table MultiSelect')) -ALLOWED_OPTIONS_CHANGE = ('Read Only', 'HTML', 'Select', 'Data') +ALLOWED_OPTIONS_CHANGE = ('Read Only', 'HTML', 'Data') diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index 8a287b17e8..2cae69ca21 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -97,13 +97,18 @@ class TestCustomizeForm(unittest.TestCase): custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0] custom_field.reqd = 1 + custom_field.no_copy = 1 d.run_method("save_customization") self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1) + self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 1) custom_field = d.get("fields", {"is_custom_field": True})[0] custom_field.reqd = 0 + custom_field.no_copy = 0 d.run_method("save_customization") self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0) + self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 0) + def test_save_customization_new_field(self): d = self.get_customize_form("Event") @@ -257,7 +262,7 @@ class TestCustomizeForm(unittest.TestCase): frappe.clear_cache() d = self.get_customize_form("User Group") - d.append('links', dict(link_doctype='User Group Member', parent_doctype='User', + d.append('links', dict(link_doctype='User Group Member', parent_doctype='User Group', link_fieldname='user', table_fieldname='user_group_members', group='Tests', custom=1)) d.run_method("save_customization") @@ -267,7 +272,7 @@ class TestCustomizeForm(unittest.TestCase): # check links exist self.assertTrue([d.name for d in user_group.links if d.link_doctype == 'User Group Member']) - self.assertTrue([d.name for d in user_group.links if d.parent_doctype == 'User']) + self.assertTrue([d.name for d in user_group.links if d.parent_doctype == 'User Group']) # remove the link d = self.get_customize_form("User Group") @@ -304,3 +309,25 @@ class TestCustomizeForm(unittest.TestCase): action = [d for d in event.actions if d.label=='Test Action'] self.assertEqual(len(action), 0) + + def test_custom_label(self): + d = self.get_customize_form("Event") + + # add label + d.label = "Test Rename" + d.run_method("save_customization") + self.assertEqual(d.label, "Test Rename") + + # change label + d.label = "Test Rename 2" + d.run_method("save_customization") + self.assertEqual(d.label, "Test Rename 2") + + # saving again to make sure existing label persists + d.run_method("save_customization") + self.assertEqual(d.label, "Test Rename 2") + + # clear label + d.label = "" + d.run_method("save_customization") + self.assertEqual(d.label, "") 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 bb856f6805..5c8fe399f7 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -14,11 +14,13 @@ "non_negative", "reqd", "unique", + "is_virtual", "in_list_view", "in_standard_filter", "in_global_search", "in_preview", "bold", + "no_copy", "allow_in_quick_entry", "translatable", "column_break_7", @@ -83,7 +85,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\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -115,6 +117,12 @@ "fieldtype": "Check", "label": "Unique" }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Is Virtual" + }, { "default": "0", "fieldname": "in_list_view", @@ -430,13 +438,19 @@ "fieldname": "show_dashboard", "fieldtype": "Check", "label": "Show Dashboard" + }, + { + "default": "0", + "fieldname": "no_copy", + "fieldtype": "Check", + "label": "No Copy" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-03 14:50:32.035768", + "modified": "2022-02-25 16:01:12.616736", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py index 7f40be9725..a86cf5efd6 100644 --- a/frappe/custom/doctype/property_setter/property_setter.py +++ b/frappe/custom/doctype/property_setter/property_setter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import frappe @@ -18,53 +18,19 @@ class PropertySetter(Document): def validate(self): self.validate_fieldtype_change() - if self.is_new(): - delete_property_setter(self.doc_type, self.property, self.field_name) - # clear cache + if self.is_new(): + delete_property_setter(self.doc_type, self.property, self.field_name, self.row_name) frappe.clear_cache(doctype = self.doc_type) def validate_fieldtype_change(self): - if self.field_name in not_allowed_fieldtype_change and \ - self.property == 'fieldtype': - frappe.throw(_("Field type cannot be changed for {0}").format(self.field_name)) - - def get_property_list(self, dt): - return frappe.db.get_all('DocField', - fields=['fieldname', 'label', 'fieldtype'], - filters={ - 'parent': dt, - 'fieldtype': ['not in', ('Section Break', 'Column Break', 'Tab Break', 'HTML', 'Read Only', 'Fold') + frappe.model.table_fields], - 'fieldname': ['!=', ''] - }, - order_by='label asc', - as_dict=1 - ) - - def get_setup_data(self): - return { - '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.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.get_all("DocType", filters={"name": self.doc_type}, fields="*")[0] - else: - return frappe.db.get_values( - "DocField", - filters={"fieldname": self.field_name, "parent": self.doc_type}, - fieldname="*", - )[0] + if ( + self.property == 'fieldtype' + and self.field_name in not_allowed_fieldtype_change + ): + frappe.throw( + _("Field type cannot be changed for {0}").format(self.field_name) + ) def on_update(self): if frappe.flags.in_patch: @@ -74,6 +40,7 @@ class PropertySetter(Document): from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype validate_fields_for_doctype(self.doc_type) + def make_property_setter(doctype, fieldname, property, value, property_type, for_doctype = False, validate_fields_for_doctype=True): # WARNING: Ignores Permissions @@ -91,11 +58,13 @@ def make_property_setter(doctype, fieldname, property, value, property_type, for property_setter.insert() return property_setter -def delete_property_setter(doc_type, property, field_name=None): + +def delete_property_setter(doc_type, property, field_name=None, row_name=None): """delete other property setters on this, if this is new""" - filters = dict(doc_type = doc_type, property=property) + filters = dict(doc_type=doc_type, property=property) if field_name: filters['field_name'] = field_name + if row_name: + filters["row_name"] = row_name frappe.db.delete('Property Setter', filters) - diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json index 8938bdec9c..1756abcb1d 100644 --- a/frappe/custom/workspace/customization/customization.json +++ b/frappe/custom/workspace/customization/customization.json @@ -1,6 +1,6 @@ { "charts": [], - "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}}]", + "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Custom Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"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-11-24 16:20:03.500885", + "modified": "2022-01-13 17:28:08.345794", "modified_by": "Administrator", "module": "Custom", "name": "Customization", @@ -132,7 +132,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 8, + "sequence_id": 8.0, "shortcuts": [ { "label": "Customize Form", diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py index 7b26ac31b3..5db0537ed7 100644 --- a/frappe/database/__init__.py +++ b/frappe/database/__init__.py @@ -18,7 +18,8 @@ def setup_database(force, source_sql=None, verbose=None, no_mariadb_socket=False def drop_user_and_database(db_name, root_login=None, root_password=None): import frappe if frappe.conf.db_type == 'postgres': - pass + import frappe.database.postgres.setup_db + return frappe.database.postgres.setup_db.drop_user_and_database(db_name, root_login, root_password) else: import frappe.database.mariadb.setup_db return frappe.database.mariadb.setup_db.drop_user_and_database(db_name, root_login, root_password) diff --git a/frappe/database/database.py b/frappe/database/database.py index 65242e0419..82a7e6f919 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -10,19 +10,20 @@ import re import string from contextlib import contextmanager from time import time -from typing import Dict, List, Union, Tuple +from typing import Dict, List, Tuple, Union + +from pypika.terms import Criterion, NullValue, PseudoColumn import frappe import frappe.defaults import frappe.model.meta from frappe import _ -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 frappe.query_builder.utils import DocType +from frappe.utils import cast, get_datetime, getdate, now, sbool + from .query import Query -from pypika.terms import Criterion, PseudoColumn class Database(object): @@ -36,9 +37,9 @@ class Database(object): OPTIONAL_COLUMNS = ["_user_tags", "_comments", "_assign", "_liked_by"] DEFAULT_SHORTCUTS = ['_Login', '__user', '_Full Name', 'Today', '__today', "now", "Now"] - STANDARD_VARCHAR_COLUMNS = ('name', 'owner', 'modified_by', 'parent', 'parentfield', 'parenttype') - DEFAULT_COLUMNS = ['name', 'creation', 'modified', 'modified_by', 'owner', 'docstatus', 'parent', - 'parentfield', 'parenttype', 'idx'] + STANDARD_VARCHAR_COLUMNS = ('name', 'owner', 'modified_by') + DEFAULT_COLUMNS = ['name', 'creation', 'modified', 'modified_by', 'owner', 'docstatus', 'idx'] + CHILD_TABLE_COLUMNS = ('parent', 'parenttype', 'parentfield') MAX_WRITES_PER_TRANSACTION = 200_000 class InvalidColumnName(frappe.ValidationError): pass @@ -118,6 +119,9 @@ class Database(object): if not run: return query + # remove \n \t from start and end of query + query = re.sub(r'^\s*|\s*$', '', query) + if re.search(r'ifnull\(', query, flags=re.IGNORECASE): # replaces ifnull in query with coalesce query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE) @@ -141,8 +145,6 @@ class Database(object): self.log_query(query, values, debug, explain) if values!=(): - if isinstance(values, dict): - values = dict(values) # MySQL-python==1.2.5 hack! if not isinstance(values, (dict, tuple, list)): @@ -176,9 +178,11 @@ class Database(object): raise frappe.QueryTimeoutError(e) elif frappe.conf.db_type == 'postgres': + # TODO: added temporarily + print(e) raise - if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): + if ignore_ddl and (self.is_missing_column(e) or self.is_table_missing(e) or self.cant_drop_field_or_key(e)): pass else: raise @@ -278,7 +282,9 @@ class Database(object): if self.auto_commit_on_many_writes: self.commit() else: - frappe.throw(_("Too many writes in one request. Please send smaller requests"), frappe.ValidationError) + msg = "

" + _("Too many changes to database in single action.") + "
" + msg += _("The changes have been reverted.") + "
" + raise frappe.TooManyWritesError(msg) def check_implicit_commit(self, query): if self.transaction_writes and \ @@ -381,7 +387,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, pluck=pluck, distinct=distinct) + order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct, limit=1) if not run: return ret @@ -390,7 +396,7 @@ class Database(object): def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False, - run=True, pluck=False, distinct=False): + run=True, pluck=False, distinct=False, limit=None): """Returns multiple document properties. :param doctype: DocType name. @@ -420,40 +426,40 @@ class Database(object): if isinstance(filters, list): out = self._get_value_for_many_names( - doctype, - filters, - fieldname, - order_by, + doctype=doctype, + names=filters, + field=fieldname, + order_by=order_by, debug=debug, run=run, pluck=pluck, distinct=distinct, + limit=limit, ) else: fields = fieldname - if fieldname!="*": + if fieldname != "*": if isinstance(fieldname, str): fields = [fieldname] - else: - fields = fieldname if (filters is not None) and (filters!=doctype or doctype=="DocType"): try: 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, + fields=fields, + filters=filters, + doctype=doctype, + as_dict=as_dict, + debug=debug, + order_by=order_by, + update=update, for_update=for_update, run=run, pluck=pluck, - distinct=distinct + distinct=distinct, + limit=limit, ) except Exception as e: if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)): @@ -555,7 +561,21 @@ class Database(object): def get_list(*args, **kwargs): return frappe.get_list(*args, **kwargs) - def get_single_value(self, doctype, fieldname, cache=False): + def set_single_value(self, doctype, fieldname, value, *args, **kwargs): + """Set field value of Single DocType. + + :param doctype: DocType of the single object + :param fieldname: `fieldname` of the property + :param value: `value` of the property + + Example: + + # Update the `deny_multiple_sessions` field in System Settings DocType. + company = frappe.db.set_single_value("System Settings", "deny_multiple_sessions", True) + """ + return self.set_value(doctype, doctype, fieldname, value, *args, **kwargs) + + def get_single_value(self, doctype, fieldname, cache=True): """Get property of Single DocType. Cache locally by default :param doctype: DocType of the single object whose value is requested @@ -567,10 +587,10 @@ class Database(object): company = frappe.db.get_single_value('Global Defaults', 'default_company') """ - if not doctype in self.value_cache: + if doctype not in self.value_cache: self.value_cache[doctype] = {} - if fieldname in self.value_cache[doctype]: + if cache and fieldname in self.value_cache[doctype]: return self.value_cache[doctype][fieldname] val = self.query.get_sql( @@ -608,6 +628,7 @@ class Database(object): run=True, pluck=False, distinct=False, + limit=None, ): field_objects = [] @@ -626,6 +647,7 @@ class Database(object): field_objects=field_objects, fields=fields, distinct=distinct, + limit=limit, ) if ( fields == "*" @@ -639,7 +661,7 @@ class Database(object): ) return r - def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False): + def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False, limit=None): names = list(filter(None, names)) if names: return self.get_all( @@ -652,6 +674,7 @@ class Database(object): as_list=1, run=run, distinct=distinct, + limit_page_length=limit ) else: return {} @@ -677,53 +700,55 @@ class Database(object): :param debug: Print the query in the developer / js console. :param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit. """ - if not modified: - modified = now() - if not modified_by: - modified_by = frappe.session.user + is_single_doctype = not (dn and dt != dn) + to_update = field if isinstance(field, dict) else {field: val} - to_update = {} if update_modified: - to_update = {"modified": modified, "modified_by": modified_by} + modified = modified or now() + modified_by = modified_by or frappe.session.user + to_update.update({"modified": modified, "modified_by": modified_by}) + + if is_single_doctype: + frappe.db.delete( + "Singles", + filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug + ) + + singles_data = ((dt, key, sbool(value)) for key, value in to_update.items()) + query = ( + frappe.qb.into("Singles") + .columns("doctype", "field", "value") + .insert(*singles_data) + ).run(debug=debug) + frappe.clear_document_cache(dt, dt) - if isinstance(field, dict): - to_update.update(field) else: - to_update.update({field: val}) + table = DocType(dt) - if dn and dt!=dn: - # with table - set_values = [] - for key in to_update: - set_values.append('`{0}`=%({0})s'.format(key)) + if for_update: + docnames = tuple( + self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True) + ) or (NullValue(),) + query = frappe.qb.update(table).where(table.name.isin(docnames)) - for name in self.get_values(dt, dn, 'name', for_update=for_update, debug=debug): - values = dict(name=name[0]) - values.update(to_update) + for docname in docnames: + frappe.clear_document_cache(dt, docname) - self.sql("""update `tab{0}` - set {1} where name=%(name)s""".format(dt, ', '.join(set_values)), - values, debug=debug) + else: + query = self.query.build_conditions(table=dt, filters=dn, update=True) + # TODO: Fix this; doesn't work rn - gavin@frappe.io + # frappe.cache().hdel_keys(dt, "document_cache") + # Workaround: clear all document caches + frappe.cache().delete_value('document_cache') - frappe.clear_document_cache(dt, values['name']) - else: - # for singles - keys = list(to_update) - self.sql(''' - delete from `tabSingles` - where field in ({0}) and - doctype=%s'''.format(', '.join(['%s']*len(keys))), - list(keys) + [dt], debug=debug) - for key, value in to_update.items(): - self.sql('''insert into `tabSingles` (doctype, field, value) values (%s, %s, %s)''', - (dt, key, value), debug=debug) + for column, value in to_update.items(): + query = query.set(column, value) - frappe.clear_document_cache(dt, dn) + query.run(debug=debug) if dt in self.value_cache: del self.value_cache[dt] - @staticmethod def set(doc, field, val): """Set value in document. **Avoid**""" @@ -865,27 +890,39 @@ class Database(object): return self.sql("select name from `tab{doctype}` limit 1".format(doctype=doctype)) def exists(self, dt, dn=None, cache=False): - """Returns true if document exists. + """Return the document name of a matching document, or None. - :param dt: DocType name. - :param dn: Document name or filter dict.""" - if isinstance(dt, str): - if dt!="DocType" and dt==dn: - return True # single always exists (!) - try: - return self.get_value(dt, dn, "name", cache=cache) - except Exception: - return None + Note: `cache` only works if `dt` and `dn` are of type `str`. - elif isinstance(dt, dict) and dt.get('doctype'): - try: - conditions = [] - for d in dt: - if d == 'doctype': continue - conditions.append([d, '=', dt[d]]) - return self.get_all(dt['doctype'], filters=conditions, as_list=1) - except Exception: - return None + ## Examples + + Pass doctype and docname (only in this case we can cache the result) + + ``` + exists("User", "jane@example.org", cache=True) + ``` + + Pass a dict of filters including the `"doctype"` key: + + ``` + exists({"doctype": "User", "full_name": "Jane Doe"}) + ``` + + Pass the doctype and a dict of filters: + + ``` + exists("User", {"full_name": "Jane Doe"}) + ``` + """ + if dt != "DocType" and dt == dn: + # single always exists (!) + return dn + + if isinstance(dt, dict): + dt = dt.copy() # don't modify the original dict + dt, dn = dt.pop("doctype"), dt + + return self.get_value(dt, dn, ignore=True, cache=cache) def count(self, dt, filters=None, debug=False, cache=False): """Returns `COUNT(*)` for given DocType and filters.""" @@ -1009,7 +1046,7 @@ class Database(object): return [] def is_missing_table_or_column(self, e): - return self.is_missing_column(e) or self.is_missing_table(e) + return self.is_missing_column(e) or self.is_table_missing(e) def multisql(self, sql_dict, values=(), **kwargs): current_dialect = frappe.db.db_type or 'mariadb' diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index fde86a7876..8d95932a4f 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -53,7 +53,8 @@ class MariaDBDatabase(Database): 'Geolocation': ('longtext', ''), 'Duration': ('decimal', '21,9'), 'Icon': ('varchar', self.VARCHAR_LEN), - 'Phone': ('varchar', self.VARCHAR_LEN) + 'Phone': ('varchar', self.VARCHAR_LEN), + 'Autocomplete': ('varchar', self.VARCHAR_LEN), } def get_connection(self): @@ -154,6 +155,10 @@ class MariaDBDatabase(Database): def is_table_missing(e): return e.args[0] == ER.NO_SUCH_TABLE + @staticmethod + def is_missing_table(e): + return MariaDBDatabase.is_table_missing(e) + @staticmethod def is_missing_column(e): return e.args[0] == ER.BAD_FIELD_ERROR @@ -246,9 +251,16 @@ class MariaDBDatabase(Database): column_name as 'name', column_type as 'type', column_default as 'default', - column_key = 'MUL' as 'index', + COALESCE( + (select 1 + from information_schema.statistics + where table_name="{table_name}" + and column_name=columns.column_name + and NON_UNIQUE=1 + limit 1 + ), 0) as 'index', column_key = 'UNI' as 'unique' - from information_schema.columns + from information_schema.columns as columns where table_name = '{table_name}' '''.format(table_name=table_name), as_dict=1) def has_index(self, table_name, index_name): diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index cfb4e243a2..f2a1206c7c 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -171,9 +171,6 @@ CREATE TABLE `tabDocType` ( `modified_by` varchar(255) DEFAULT NULL, `owner` varchar(255) DEFAULT NULL, `docstatus` int(1) NOT NULL DEFAULT 0, - `parent` varchar(255) DEFAULT NULL, - `parentfield` varchar(255) DEFAULT NULL, - `parenttype` varchar(255) DEFAULT NULL, `idx` int(8) NOT NULL DEFAULT 0, `search_fields` varchar(255) DEFAULT NULL, `issingle` int(1) NOT NULL DEFAULT 0, @@ -227,9 +224,9 @@ CREATE TABLE `tabDocType` ( `email_append_to` int(1) NOT NULL DEFAULT 0, `subject_field` varchar(255) DEFAULT NULL, `sender_field` varchar(255) DEFAULT NULL, + `show_title_field_in_link` int(1) NOT NULL DEFAULT 0, `migration_hash` varchar(255) DEFAULT NULL, - PRIMARY KEY (`name`), - KEY `parent` (`parent`) + PRIMARY KEY (`name`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 5768a2f23d..3b7aa443f2 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -1,12 +1,16 @@ import frappe from frappe import _ from frappe.database.schema import DBTable +from frappe.database.sequence import create_sequence +from frappe.model import log_types + class MariaDBTable(DBTable): def create(self): additional_definitions = "" engine = self.meta.get("engine") or "InnoDB" varchar_len = frappe.db.VARCHAR_LEN + name_column = f"name varchar({varchar_len}) primary key" # columns column_defs = self.get_column_definitions() @@ -18,20 +22,45 @@ class MariaDBTable(DBTable): if index_defs: additional_definitions += ',\n'.join(index_defs) + ',\n' + # child table columns + if self.meta.get("istable") or 0: + additional_definitions += ',\n'.join( + ( + f"parent varchar({varchar_len})", + f"parentfield varchar({varchar_len})", + f"parenttype varchar({varchar_len})", + "index parent(parent)" + ) + ) + ',\n' + + # creating sequence(s) + if (not self.meta.issingle and self.meta.autoname == "autoincrement")\ + or self.doctype in log_types: + + # NOTE: using a very small cache - as during backup, if the sequence was used in anyform, + # it drops the cache and uses the next non cached value in setval func and + # puts that in the backup file, which will start the counter + # from that value when inserting any new record in the doctype. + # By default the cache is 1000 which will mess up the sequence when + # using the system after a restore. + # issue link: https://jira.mariadb.org/browse/MDEV-21786 + create_sequence(self.doctype, check_not_exists=True, cache=50) + + # NOTE: not used nextval func as default as the ability to restore + # database with sequences has bugs in mariadb and gives a scary error. + # issue link: https://jira.mariadb.org/browse/MDEV-21786 + name_column = "name bigint primary key" + # create table query = f"""create table `{self.table_name}` ( - name varchar({varchar_len}) not null primary key, + {name_column}, creation datetime(6), modified datetime(6), modified_by varchar({varchar_len}), owner varchar({varchar_len}), docstatus int(1) not null default '0', - parent varchar({varchar_len}), - parentfield varchar({varchar_len}), - parenttype varchar({varchar_len}), idx int(8) not null default '0', {additional_definitions} - index parent(parent), index modified(modified)) ENGINE={engine} ROW_FORMAT=DYNAMIC @@ -58,18 +87,34 @@ class MariaDBTable(DBTable): modify_column_query.append("MODIFY `{}` {}".format(col.fieldname, col.get_definition())) for col in self.add_index: - # if index key not exists - if not frappe.db.sql("SHOW INDEX FROM `%s` WHERE key_name = %s" % - (self.table_name, '%s'), col.fieldname): - add_index_query.append("ADD INDEX `{}`(`{}`)".format(col.fieldname, col.fieldname)) + # if index key does not exists + if not frappe.db.has_index(self.table_name, col.fieldname + '_index'): + add_index_query.append("ADD INDEX `{}_index`(`{}`)".format(col.fieldname, col.fieldname)) - for col in self.drop_index: + for col in self.drop_index + self.drop_unique: if col.fieldname != 'name': # primary key + current_column = self.current_columns.get(col.fieldname.lower()) + unique_constraint_changed = current_column.unique != col.unique + if unique_constraint_changed and not col.unique: + # nosemgrep + unique_index_record = frappe.db.sql(""" + SHOW INDEX FROM `{0}` + WHERE Key_name=%s + AND Non_unique=0 + """.format(self.table_name), (col.fieldname), as_dict=1) + if unique_index_record: + drop_index_query.append("DROP INDEX `{}`".format(unique_index_record[0].Key_name)) + index_constraint_changed = current_column.index != col.set_index # if index key exists - if frappe.db.sql("""SHOW INDEX FROM `{0}` - WHERE key_name=%s - AND Non_unique=%s""".format(self.table_name), (col.fieldname, col.unique)): - drop_index_query.append("drop index `{}`".format(col.fieldname)) + if index_constraint_changed and not col.set_index: + # nosemgrep + index_record = frappe.db.sql(""" + SHOW INDEX FROM `{0}` + WHERE Key_name=%s + AND Non_unique=1 + """.format(self.table_name), (col.fieldname + '_index'), as_dict=1) + if index_record: + drop_index_query.append("DROP INDEX `{}`".format(index_record[0].Key_name)) try: for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]: diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 1ff2c0251a..5da5bffe4a 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -63,7 +63,8 @@ class PostgresDatabase(Database): 'Geolocation': ('text', ''), 'Duration': ('decimal', '21,9'), 'Icon': ('varchar', self.VARCHAR_LEN), - 'Phone': ('varchar', self.VARCHAR_LEN) + 'Phone': ('varchar', self.VARCHAR_LEN), + 'Autocomplete': ('varchar', self.VARCHAR_LEN), } def get_connection(self): @@ -78,11 +79,11 @@ class PostgresDatabase(Database): """Escape quotes and percent in given string.""" if isinstance(s, bytes): s = s.decode('utf-8') - + # MariaDB's driver treats None as an empty string # So Postgres should do the same - if s is None: + if s is None: s = '' if percent: @@ -99,16 +100,13 @@ class PostgresDatabase(Database): return db_size[0].get('database_size') # pylint: disable=W0221 - def sql(self, *args, **kwargs): - if args: - # since tuple is immutable - args = list(args) - args[0] = modify_query(args[0]) - args = tuple(args) - elif kwargs.get('query'): - kwargs['query'] = modify_query(kwargs.get('query')) - - return super(PostgresDatabase, self).sql(*args, **kwargs) + def sql(self, query, values=(), *args, **kwargs): + return super(PostgresDatabase, self).sql( + modify_query(query), + modify_values(values), + *args, + **kwargs + ) def get_tables(self, cached=True): return [d[0] for d in self.sql("""select table_name @@ -153,6 +151,10 @@ class PostgresDatabase(Database): def is_table_missing(e): return getattr(e, 'pgcode', None) == '42P01' + @staticmethod + def is_missing_table(e): + return PostgresDatabase.is_table_missing(e) + @staticmethod def is_missing_column(e): return getattr(e, 'pgcode', None) == '42703' @@ -171,11 +173,11 @@ class PostgresDatabase(Database): @staticmethod def is_primary_key_violation(e): - return e.pgcode == '23505' and '_pkey' in cstr(e.args[0]) + return getattr(e, "pgcode", None) == '23505' and '_pkey' in cstr(e.args[0]) @staticmethod def is_unique_key_violation(e): - return e.pgcode == '23505' and '_key' in cstr(e.args[0]) + return getattr(e, "pgcode", None) == '23505' and '_key' in cstr(e.args[0]) @staticmethod def is_duplicate_fieldname(e): @@ -309,18 +311,20 @@ class PostgresDatabase(Database): WHEN 'timestamp without time zone' THEN 'timestamp' ELSE a.data_type END AS type, - COUNT(b.indexdef) AS Index, + BOOL_OR(b.index) AS index, SPLIT_PART(COALESCE(a.column_default, NULL), '::', 1) AS default, BOOL_OR(b.unique) AS unique FROM information_schema.columns a LEFT JOIN - (SELECT indexdef, tablename, indexdef LIKE '%UNIQUE INDEX%' AS unique + (SELECT indexdef, tablename, + indexdef LIKE '%UNIQUE INDEX%' AS unique, + indexdef NOT LIKE '%UNIQUE INDEX%' AS index FROM pg_indexes WHERE tablename='{table_name}') b - ON SUBSTRING(b.indexdef, '\(.*\)') LIKE CONCAT('%', a.column_name, '%') + ON SUBSTRING(b.indexdef, '(.*)') LIKE CONCAT('%', a.column_name, '%') WHERE a.table_name = '{table_name}' - GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length;''' - .format(table_name=table_name), as_dict=1) + GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length; + '''.format(table_name=table_name), as_dict=1) def get_database_list(self, target): return [d[0] for d in self.sql("SELECT datname FROM pg_database;")] @@ -333,12 +337,47 @@ def modify_query(query): query = replace_locate_with_strpos(query) # select from requires "" if re.search('from tab', query, flags=re.IGNORECASE): - query = re.sub('from tab([a-zA-Z]*)', r'from "tab\1"', query, flags=re.IGNORECASE) + query = re.sub(r'from tab([\w-]*)', r'from "tab\1"', query, flags=re.IGNORECASE) + # only find int (with/without signs), ignore decimals (with/without signs), ignore hashes (which start with numbers), + # drop .0 from decimals and add quotes around them + # + # >>> query = "c='abcd' , a >= 45, b = -45.0, c = 40, d=4500.0, e=3500.53, f=40psdfsd, g=9092094312, h=12.00023" + # >>> re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query) + # "c='abcd' , a >= '45', b = '-45', c = '40', d= '4500', e=3500.53, f=40psdfsd, g= '9092094312', h=12.00023 + + query = re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query) return query +def modify_values(values): + def stringify_value(value): + if isinstance(value, int): + value = str(value) + elif isinstance(value, float): + truncated_float = int(value) + if value == truncated_float: + value = str(truncated_float) + + return value + + if not values: + return values + + if isinstance(values, dict): + for k, v in values.items(): + values[k] = stringify_value(v) + elif isinstance(values, (tuple, list)): + new_values = [] + for val in values: + new_values.append(stringify_value(val)) + values = new_values + else: + values = stringify_value(values) + + return values + def replace_locate_with_strpos(query): # strpos is the locate equivalent in postgres if re.search(r'locate\(', query, flags=re.IGNORECASE): - query = re.sub(r'locate\(([^,]+),([^)]+)\)', r'strpos(\2, \1)', query, flags=re.IGNORECASE) + query = re.sub(r'locate\(([^,]+),([^)]+)(\)?)\)', r'strpos(\2\3, \1)', query, flags=re.IGNORECASE) return query diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index f911e34650..1e79bf67d8 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -176,9 +176,6 @@ CREATE TABLE "tabDocType" ( "modified_by" varchar(255) DEFAULT NULL, "owner" varchar(255) DEFAULT NULL, "docstatus" smallint NOT NULL DEFAULT 0, - "parent" varchar(255) DEFAULT NULL, - "parentfield" varchar(255) DEFAULT NULL, - "parenttype" varchar(255) DEFAULT NULL, "idx" bigint NOT NULL DEFAULT 0, "search_fields" varchar(255) DEFAULT NULL, "issingle" smallint NOT NULL DEFAULT 0, @@ -232,6 +229,7 @@ CREATE TABLE "tabDocType" ( "email_append_to" smallint NOT NULL DEFAULT 0, "subject_field" varchar(255) DEFAULT NULL, "sender_field" varchar(255) DEFAULT NULL, + "show_title_field_in_link" smallint NOT NULL DEFAULT 0, "migration_hash" varchar(255) DEFAULT NULL, PRIMARY KEY ("name") ) ; diff --git a/frappe/database/postgres/schema.py b/frappe/database/postgres/schema.py index 58153ca6ce..b09f73300e 100644 --- a/frappe/database/postgres/schema.py +++ b/frappe/database/postgres/schema.py @@ -2,34 +2,78 @@ import frappe from frappe import _ from frappe.utils import cint, flt from frappe.database.schema import DBTable, get_definition +from frappe.database.sequence import create_sequence +from frappe.model import log_types + class PostgresTable(DBTable): def create(self): - add_text = '' + varchar_len = frappe.db.VARCHAR_LEN + name_column = f"name varchar({varchar_len}) primary key" + additional_definitions = "" # columns column_defs = self.get_column_definitions() - if column_defs: add_text += ',\n'.join(column_defs) + if column_defs: + additional_definitions += ",\n".join(column_defs) + + # child table columns + if self.meta.get("istable") or 0: + if column_defs: + additional_definitions += ",\n" + + additional_definitions += ",\n".join( + ( + f"parent varchar({varchar_len})", + f"parentfield varchar({varchar_len})", + f"parenttype varchar({varchar_len})", + ) + ) + + # creating sequence(s) + if (not self.meta.issingle and self.meta.autoname == "autoincrement")\ + or self.doctype in log_types: + + # The sequence cache is per connection. + # Since we're opening and closing connections for every transaction this results in skipping the cache + # to the next non-cached value hence not using cache in postgres. + # ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers + create_sequence(self.doctype, check_not_exists=True) + name_column = "name bigint primary key" - # index - # index_defs = self.get_index_definitions() # TODO: set docstatus length # create table - frappe.db.sql("""create table `%s` ( - name varchar({varchar_len}) not null primary key, + frappe.db.sql(f"""create table `{self.table_name}` ( + {name_column}, creation timestamp(6), modified timestamp(6), modified_by varchar({varchar_len}), owner varchar({varchar_len}), docstatus smallint not null default '0', - parent varchar({varchar_len}), - parentfield varchar({varchar_len}), - parenttype varchar({varchar_len}), idx bigint not null default '0', - %s)""".format(varchar_len=frappe.db.VARCHAR_LEN) % (self.table_name, add_text)) + {additional_definitions} + )""" + ) + self.create_indexes() frappe.db.commit() + def create_indexes(self): + create_index_query = "" + for key, col in self.columns.items(): + if (col.set_index + and col.fieldtype in frappe.db.type_map + and frappe.db.type_map.get(col.fieldtype)[0] + not in ('text', 'longtext')): + create_index_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format( + index_name=col.fieldname, + table_name=self.table_name, + field=col.fieldname + ) + if create_index_query: + # nosemgrep + frappe.db.sql(create_index_query) + def alter(self): for col in self.columns.values(): col.build_for_alter_table(self.current_columns.get(col.fieldname.lower())) @@ -52,8 +96,8 @@ class PostgresTable(DBTable): query.append("ALTER COLUMN `{0}` TYPE {1} {2}".format( col.fieldname, get_definition(col.fieldtype, precision=col.precision, length=col.length), - using_clause) - ) + using_clause + )) for col in self.set_default: if col.fieldname=="name": @@ -73,37 +117,54 @@ class PostgresTable(DBTable): query.append("ALTER COLUMN `{}` SET DEFAULT {}".format(col.fieldname, col_default)) - create_index_query = "" + create_contraint_query = "" for col in self.add_index: # if index key not exists - create_index_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format( + create_contraint_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format( index_name=col.fieldname, table_name=self.table_name, field=col.fieldname) - drop_index_query = "" + for col in self.add_unique: + # if index key not exists + create_contraint_query += 'CREATE UNIQUE INDEX IF NOT EXISTS "unique_{index_name}" ON `{table_name}`(`{field}`);'.format( + index_name=col.fieldname, + table_name=self.table_name, + field=col.fieldname + ) + + drop_contraint_query = "" for col in self.drop_index: # primary key if col.fieldname != 'name': # if index key exists - if not frappe.db.has_index(self.table_name, col.fieldname): - drop_index_query += 'DROP INDEX IF EXISTS "{}" ;'.format(col.fieldname) + drop_contraint_query += 'DROP INDEX IF EXISTS "{}" ;'.format(col.fieldname) - if query: - try: + for col in self.drop_unique: + # primary key + if col.fieldname != 'name': + # if index key exists + drop_contraint_query += 'DROP INDEX IF EXISTS "unique_{}" ;'.format(col.fieldname) + try: + if query: final_alter_query = "ALTER TABLE `{}` {}".format(self.table_name, ", ".join(query)) - if final_alter_query: frappe.db.sql(final_alter_query) - if create_index_query: frappe.db.sql(create_index_query) - if drop_index_query: frappe.db.sql(drop_index_query) - except Exception as e: - # sanitize - if frappe.db.is_duplicate_fieldname(e): - frappe.throw(str(e)) - elif frappe.db.is_duplicate_entry(e): - fieldname = str(e).split("'")[-2] - frappe.throw(_("""{0} field cannot be set as unique in {1}, - as there are non-unique existing values""".format( - fieldname, self.table_name))) - raise e - else: - raise e + # nosemgrep + frappe.db.sql(final_alter_query) + if create_contraint_query: + # nosemgrep + frappe.db.sql(create_contraint_query) + if drop_contraint_query: + # nosemgrep + frappe.db.sql(drop_contraint_query) + except Exception as e: + # sanitize + if frappe.db.is_duplicate_fieldname(e): + frappe.throw(str(e)) + elif frappe.db.is_duplicate_entry(e): + fieldname = str(e).split("'")[-2] + frappe.throw( + _("{0} field cannot be set as unique in {1}, as there are non-unique existing values") + .format(fieldname, self.table_name) + ) + else: + raise e diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index 19ba681237..4b265e7660 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -4,7 +4,7 @@ import frappe def setup_database(force, source_sql=None, verbose=False): - root_conn = get_root_connection() + root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password) root_conn.commit() root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(frappe.conf.db_name)) root_conn.sql("DROP USER IF EXISTS {0}".format(frappe.conf.db_name)) @@ -70,7 +70,7 @@ def import_db_from_sql(source_sql=None, verbose=False): print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}") def setup_help_database(help_db_name): - root_conn = get_root_connection() + root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password) root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(help_db_name)) root_conn.sql("DROP USER IF EXISTS {0}".format(help_db_name)) root_conn.sql("CREATE DATABASE `{0}`".format(help_db_name)) @@ -95,3 +95,11 @@ def get_root_connection(root_login=None, root_password=None): frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password) return frappe.local.flags.root_connection + + +def drop_user_and_database(db_name, root_login, root_password): + root_conn = get_root_connection(frappe.flags.root_login or root_login, frappe.flags.root_password or root_password) + root_conn.commit() + root_conn.sql(f"SELECT pg_terminate_backend (pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = %s", (db_name, )) + root_conn.sql(f"DROP DATABASE IF EXISTS {db_name}") + root_conn.sql(f"DROP USER IF EXISTS {db_name}") diff --git a/frappe/database/query.py b/frappe/database/query.py index 6d2be5fa25..15ab85ff56 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -244,7 +244,13 @@ class Query: _operator = OPERATOR_MAP[value[0]] conditions = conditions.where(_operator(Field(key), value[1])) else: - conditions = conditions.where(_operator(Field(key), value)) + if value is not None: + conditions = conditions.where(_operator(Field(key), value)) + else: + _table = conditions._from[0] + field = getattr(_table, key) + conditions = conditions.where(field.isnull()) + conditions = self.add_conditions(conditions, **kwargs) return conditions @@ -308,7 +314,7 @@ class Permission: doctype = [doctype] for dt in doctype: - dt = re.sub("tab", "", dt) + dt = re.sub("^tab", "", dt) if not frappe.has_permission( dt, "select", diff --git a/frappe/database/schema.py b/frappe/database/schema.py index 10582eff8f..7cab8d42b2 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -21,6 +21,7 @@ class DBTable: self.change_name = [] self.add_unique = [] self.add_index = [] + self.drop_unique = [] self.drop_index = [] self.set_default = [] @@ -66,7 +67,7 @@ class DBTable: """ get columns from docfields and custom fields """ - fields = self.meta.get_fieldnames_with_value(True) + fields = self.meta.get_fieldnames_with_value(with_field_meta=True) # optional fields like _comments if not self.meta.get('istable'): @@ -84,6 +85,9 @@ class DBTable: }) for field in fields: + if field.get("is_virtual"): + continue + self.columns[field.get('fieldname')] = DbColumn( self, field.get('fieldname'), @@ -105,6 +109,9 @@ class DBTable: columns = [frappe._dict({"fieldname": f, "fieldtype": "Data"}) for f in frappe.db.STANDARD_VARCHAR_COLUMNS] + if self.meta.get("istable"): + columns += [frappe._dict({"fieldname": f, "fieldtype": "Data"}) for f in + frappe.db.CHILD_TABLE_COLUMNS] columns += self.columns.values() for col in columns: @@ -219,8 +226,10 @@ class DbColumn: self.table.change_type.append(self) # unique - if((self.unique and not current_def['unique']) and column_type not in ('text', 'longtext')): + if ((self.unique and not current_def['unique']) and column_type not in ('text', 'longtext')): self.table.add_unique.append(self) + elif (current_def['unique'] and not self.unique) and column_type not in ('text', 'longtext'): + self.table.drop_unique.append(self) # default if (self.default_changed(current_def) @@ -230,9 +239,7 @@ class DbColumn: self.table.set_default.append(self) # index should be applied or dropped irrespective of type change - if ((current_def['index'] and not self.set_index and not self.unique) - or (current_def['unique'] and not self.unique)): - # to drop unique you have to drop index + if (current_def['index'] and not self.set_index) and column_type not in ('text', 'longtext'): self.table.drop_index.append(self) elif (not current_def['index'] and self.set_index) and not (column_type in ('text', 'longtext')): @@ -299,11 +306,12 @@ def validate_column_length(fieldname): def get_definition(fieldtype, precision=None, length=None): d = frappe.db.type_map.get(fieldtype) - # convert int to long int if the length of the int is greater than 11 - if fieldtype == "Int" and length and length > 11: - d = frappe.db.type_map.get("Long Int") + if not d: + return - if not d: return + if fieldtype == "Int" and length and length > 11: + # convert int to long int if the length of the int is greater than 11 + d = frappe.db.type_map.get("Long Int") coltype = d[0] size = d[1] if d[1] else None @@ -314,19 +322,44 @@ def get_definition(fieldtype, precision=None, length=None): if fieldtype in ["Float", "Currency", "Percent"] and cint(precision) > 6: size = '21,9' - if coltype == "varchar" and length: - size = length + if length: + if coltype == "varchar": + size = length + elif coltype == "int" and length < 11: + # allow setting custom length for int if length provided is less than 11 + # NOTE: this will only be applicable for mariadb as frappe implements int + # in postgres as bigint (as seen in type_map) + size = length if size is not None: coltype = "{coltype}({size})".format(coltype=coltype, size=size) return coltype -def add_column(doctype, column_name, fieldtype, precision=None): +def add_column( + doctype, + column_name, + fieldtype, + precision=None, + length=None, + default=None, + not_null=False +): if column_name in frappe.db.get_table_columns(doctype): # already exists return frappe.db.commit() - frappe.db.sql("alter table `tab%s` add column %s %s" % (doctype, - column_name, get_definition(fieldtype, precision))) + + query = "alter table `tab%s` add column %s %s" % ( + doctype, + column_name, + get_definition(fieldtype, precision, length) + ) + + if not_null: + query += " not null" + if default: + query += f" default '{default}'" + + frappe.db.sql(query) diff --git a/frappe/database/sequence.py b/frappe/database/sequence.py new file mode 100644 index 0000000000..334fd3d71e --- /dev/null +++ b/frappe/database/sequence.py @@ -0,0 +1,80 @@ +from frappe import db, scrub + + +def create_sequence( + doctype_name: str, + *, + slug: str = "_id_seq", + check_not_exists: bool = False, + cycle: bool = False, + cache: int = 0, + start_value: int = 0, + increment_by: int = 0, + min_value: int = 0, + max_value: int = 0 +) -> str: + + query = "create sequence" + sequence_name = scrub(doctype_name + slug) + + if check_not_exists: + query += " if not exists" + + query += f" {sequence_name}" + + if cache: + query += f" cache {cache}" + else: + # in postgres, the default is cache 1 + if db.db_type == "mariadb": + query += " nocache" + + if start_value: + # default is 1 + query += f" start with {start_value}" + + if increment_by: + # default is 1 + query += f" increment by {increment_by}" + + if min_value: + # default is 1 + query += f" min value {min_value}" + + if max_value: + query += f" max value {max_value}" + + if not cycle: + if db.db_type == "mariadb": + query += " nocycle" + else: + query += " cycle" + + db.sql(query) + + return sequence_name + + +def get_next_val(doctype_name: str, slug: str = "_id_seq") -> int: + if db.db_type == "postgres": + return db.sql(f"select nextval(\'\"{scrub(doctype_name + slug)}\"\')")[0][0] + return db.sql(f"select nextval(`{scrub(doctype_name + slug)}`)")[0][0] + + +def set_next_val( + doctype_name: str, + next_val: int, + *, + slug: str = "_id_seq", + is_val_used :bool = False +) -> None: + + if not is_val_used: + is_val_used = 0 if db.db_type == "mariadb" else "f" + else: + is_val_used = 1 if db.db_type == "mariadb" else "t" + + if db.db_type == "postgres": + db.sql(f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, '{is_val_used}')") + else: + db.sql(f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})") diff --git a/frappe/defaults.py b/frappe/defaults.py index eb98db449f..e249ef2099 100644 --- a/frappe/defaults.py +++ b/frappe/defaults.py @@ -126,7 +126,7 @@ def set_default(key, value, parent, parenttype="__default"): "defkey": key, "parent": parent }) - if value != None: + if value is not None: add_default(key, value, parent) else: _clear_cache(parent) @@ -187,7 +187,7 @@ def get_defaults_for(parent="__default"): """get all defaults""" defaults = frappe.cache().hget("defaults", parent) - if defaults==None: + if defaults is None: # sort descending because first default must get precedence table = DocType("DefaultValue") res = frappe.qb.from_(table).where( diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index e1789852f1..4164db679d 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -56,31 +56,6 @@ class Workspace: self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() - def is_page_allowed(self): - cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) - shortcuts = self.doc.shortcuts - - for section in cards: - links = loads(section.get('links')) if isinstance(section.get('links'), str) else section.get('links') - for item in links: - if self.is_item_allowed(item.get('link_to'), item.get('link_type')): - return True - - def _in_active_domains(item): - if not item.restrict_to_domain: - return True - else: - return item.restrict_to_domain in frappe.get_active_domains() - - for item in shortcuts: - if self.is_item_allowed(item.link_to, item.type) and _in_active_domains(item): - return True - - if not shortcuts and not self.doc.links: - return True - - return False - def is_permitted(self): """Returns true if Has Role is not set or the user is allowed.""" from frappe.utils import has_common @@ -346,20 +321,20 @@ def get_desktop_page(page): dict: dictionary of cards, charts and shortcuts to be displayed on website """ try: - wspace = Workspace(loads(page)) - wspace.build_workspace() + workspace = Workspace(loads(page)) + workspace.build_workspace() return { - 'charts': wspace.charts, - 'shortcuts': wspace.shortcuts, - 'cards': wspace.cards, - 'onboardings': wspace.onboardings + 'charts': workspace.charts, + 'shortcuts': workspace.shortcuts, + 'cards': workspace.cards, + 'onboardings': workspace.onboardings } except DoesNotExistError: frappe.log_error(frappe.get_traceback()) return {} @frappe.whitelist() -def get_wspace_sidebar_items(): +def get_workspace_sidebar_items(): """Get list of sidebar items for desk""" has_access = "Workspace Manager" in frappe.get_roles() @@ -385,8 +360,8 @@ def get_wspace_sidebar_items(): # Filter Page based on Permission for page in all_pages: try: - wspace = Workspace(page, True) - if wspace.is_permitted() and wspace.is_page_allowed() or has_access: + workspace = Workspace(page, True) + if has_access or workspace.is_permitted(): if page.public: pages.append(page) elif page.for_user == frappe.session.user: @@ -453,25 +428,24 @@ def get_custom_report_list(module): return out def save_new_widget(doc, page, blocks, new_widgets): + if loads(new_widgets): + widgets = _dict(loads(new_widgets)) - widgets = _dict(loads(new_widgets)) - - if widgets.chart: - doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts")) - if widgets.shortcut: - doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts")) - if widgets.card: - doc.build_links_table_from_card(widgets.card) + if widgets.chart: + doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts")) + if widgets.shortcut: + doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts")) + if widgets.card: + doc.build_links_table_from_card(widgets.card) # remove duplicate and unwanted widgets - if widgets: - clean_up(doc, blocks) + clean_up(doc, blocks) try: doc.save(ignore_permissions=True) except (ValidationError, TypeError) as e: # Create a json string to log - json_config = dumps(widgets, sort_keys=True, indent=4) + json_config = widgets and dumps(widgets, sort_keys=True, indent=4) # Error log body log = \ diff --git a/frappe/desk/doctype/bulk_update/bulk_update.py b/frappe/desk/doctype/bulk_update/bulk_update.py index a0523d90cd..20887f8886 100644 --- a/frappe/desk/doctype/bulk_update/bulk_update.py +++ b/frappe/desk/doctype/bulk_update/bulk_update.py @@ -42,13 +42,13 @@ def submit_cancel_or_update_docs(doctype, docnames, action='submit', data=None): doc = frappe.get_doc(doctype, d) try: message = '' - if action == 'submit' and doc.docstatus==0: + if action == 'submit' and doc.docstatus.is_draft(): doc.submit() message = _('Submiting {0}').format(doctype) - elif action == 'cancel' and doc.docstatus==1: + elif action == 'cancel' and doc.docstatus.is_submitted(): doc.cancel() message = _('Cancelling {0}').format(doctype) - elif action == 'update' and doc.docstatus < 2: + elif action == 'update' and not doc.docstatus.is_cancelled(): doc.update(data) doc.save() message = _('Updating {0}').format(doctype) diff --git a/frappe/desk/doctype/dashboard/dashboard_list.js b/frappe/desk/doctype/dashboard/dashboard_list.js new file mode 100644 index 0000000000..d60a324048 --- /dev/null +++ b/frappe/desk/doctype/dashboard/dashboard_list.js @@ -0,0 +1,16 @@ +frappe.listview_settings['Dashboard'] = { + button: { + show(doc) { + return doc.name; + }, + get_label() { + return frappe.utils.icon("dashboard-list", "sm"); + }, + get_description(doc) { + return __('View {0}', [`${doc.name}`]); + }, + action(doc) { + frappe.set_route('dashboard-view', doc.name); + } + }, +}; \ No newline at end of file diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index e0d2cab8ef..0b93786e8e 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -495,7 +495,7 @@ frappe.ui.form.on('Dashboard Chart', { set_parent_document_type: async function(frm) { let document_type = frm.doc.document_type; - let doc_is_table = 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); diff --git a/frappe/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js index 6a7c736fac..3f3fc0ff8a 100644 --- a/frappe/desk/doctype/form_tour/form_tour.js +++ b/frappe/desk/doctype/form_tour/form_tour.js @@ -15,12 +15,12 @@ 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) { + const name = await get_first_document(frm.doc.reference_doctype); route_changed = frappe.set_route('Form', frm.doc.reference_doctype, name); } else { route_changed = frappe.set_route('Form', frm.doc.reference_doctype, 'new'); @@ -53,73 +53,69 @@ frappe.ui.form.on('Form Tour', { }; }); - frm.set_query("field", "steps", function() { - return { - query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", - filters: { - doctype: frm.doc.reference_doctype, - hidden: 0 - } - }; - }); - - frm.set_query("parent_field", "steps", function() { - return { - query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", - filters: { - doctype: frm.doc.reference_doctype, - fieldtype: "Table", - hidden: 0, - } - }; - }); - frm.trigger('reference_doctype'); }, reference_doctype(frm) { if (!frm.doc.reference_doctype) return; - frappe.db.get_list('DocField', { - filters: { - parent: frm.doc.reference_doctype, - parenttype: 'DocType', - fieldtype: 'Table' - }, - fields: ['options'] - }).then(res => { - if (Array.isArray(res)) { - frm.child_doctypes = res.map(r => r.options); - } + frm.set_fields_as_options( + "fieldname", + frm.doc.reference_doctype, + df => !df.hidden + ).then(options => { + frm.fields_dict.steps.grid.update_docfield_property( + "fieldname", + "options", + [""].concat(options) + ); + }); + + frm.set_fields_as_options( + 'parent_fieldname', + frm.doc.reference_doctype, + (df) => df.fieldtype == "Table" && !df.hidden, + ).then(options => { + frm.fields_dict.steps.grid.update_docfield_property( + "parent_fieldname", + "options", + [""].concat(options) + ); }); } }); frappe.ui.form.on('Form Tour Step', { - parent_field(frm, cdt, cdn) { + form_render(frm, cdt, cdn) { + if (locals[cdt][cdn].is_table_field) { + frm.trigger('parent_fieldname', cdt, cdn); + } + }, + parent_fieldname(frm, cdt, cdn) { const child_row = locals[cdt][cdn]; - frappe.model.set_value(cdt, cdn, 'field', ''); - const field_control = get_child_field("steps", cdn, "field"); - field_control.get_query = function() { - return { - query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", - filters: { - doctype: child_row.child_doctype, - hidden: 0 - } - }; - }; + + const parent_fieldname_df = frappe + .get_meta(frm.doc.reference_doctype) + .fields.find(df => df.fieldname == child_row.parent_fieldname); + + frm.set_fields_as_options( + 'fieldname', + parent_fieldname_df.options, + (df) => !df.hidden, + ).then(options => { + frm.fields_dict.steps.grid.update_docfield_property( + "fieldname", + "options", + [""].concat(options) + ); + if (child_row.fieldname) { + frappe.model.set_value(cdt, cdn, 'fieldname', child_row.fieldname); + } + }); } }); -function get_child_field(child_table, child_name, fieldname) { - // gets the field from grid row form - const grid = cur_frm.fields_dict[child_table].grid; - const grid_row = grid.grid_rows_by_docname[child_name]; - return grid_row.grid_form.fields_dict[fieldname]; -} - async function check_if_single(doctype) { const { message } = await frappe.db.get_value('DocType', doctype, 'issingle'); return message.issingle || 0; diff --git a/frappe/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py index 82d47224dd..6248b43e62 100644 --- a/frappe/desk/doctype/form_tour/form_tour.py +++ b/frappe/desk/doctype/form_tour/form_tour.py @@ -5,58 +5,23 @@ import frappe from frappe.model.document import Document from frappe.modules.export_file import export_to_files + class FormTour(Document): - def before_insert(self): - if not self.is_standard: - return + def before_save(self): + meta = frappe.get_meta(self.reference_doctype) + for step in self.steps: + if step.is_table_field and step.parent_fieldname: + parent_field_df = meta.get_field(step.parent_fieldname) + step.child_doctype = parent_field_df.options - # while syncing, set proper docfield reference - for d in self.steps: - if not frappe.db.exists('DocField', d.field): - d.field = frappe.db.get_value('DocField', { - 'fieldname': d.fieldname, 'parent': self.reference_doctype, 'fieldtype': d.fieldtype - }, "name") - - if d.is_table_field and not frappe.db.exists('DocField', d.parent_field): - d.parent_field = frappe.db.get_value('DocField', { - 'fieldname': d.parent_fieldname, 'parent': self.reference_doctype, 'fieldtype': 'Table' - }, "name") + field_df = frappe.get_meta(step.child_doctype).get_field(step.fieldname) + step.label = field_df.label + step.fieldtype = field_df.fieldtype + else: + field_df = meta.get_field(step.fieldname) + step.label = field_df.label + step.fieldtype = field_df.fieldtype def on_update(self): if frappe.conf.developer_mode and self.is_standard: - export_to_files([['Form Tour', self.name]], self.module) - - def before_export(self, doc): - for d in doc.steps: - d.field = "" - d.parent_field = "" - -@frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def get_docfield_list(doctype, txt, searchfield, start, page_len, filters): - or_filters = [ - ['fieldname', 'like', '%' + txt + '%'], - ['label', 'like', '%' + txt + '%'], - ['fieldtype', 'like', '%' + txt + '%'] - ] - - parent_doctype = filters.get('doctype') - fieldtype = filters.get('fieldtype') - if not fieldtype: - excluded_fieldtypes = ['Column Break'] - excluded_fieldtypes += filters.get('excluded_fieldtypes', []) - fieldtype_filter = ['not in', excluded_fieldtypes] - else: - fieldtype_filter = fieldtype - - docfields = frappe.get_all( - doctype, - fields=["name as value", "label", "fieldtype"], - filters={'parent': parent_doctype, 'fieldtype': fieldtype_filter}, - or_filters=or_filters, - limit_start=start, - limit_page_length=page_len, - order_by="idx", - as_list=1, - ) - return docfields + export_to_files([["Form Tour", self.name]], self.module) diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.json b/frappe/desk/doctype/form_tour_step/form_tour_step.json index 3b6c91a208..7eb6eab223 100644 --- a/frappe/desk/doctype/form_tour_step/form_tour_step.json +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.json @@ -6,19 +6,17 @@ "field_order": [ "is_table_field", "section_break_2", - "parent_field", - "field", + "parent_fieldname", + "fieldname", "title", "description", "column_break_2", "position", "label", + "fieldtype", "has_next_condition", "next_step_condition", "section_break_13", - "fieldname", - "parent_fieldname", - "fieldtype", "child_doctype" ], "fields": [ @@ -38,23 +36,13 @@ "reqd": 1 }, { - "depends_on": "eval: (!doc.is_table_field || (doc.is_table_field && doc.parent_field))", - "fieldname": "field", - "fieldtype": "Link", - "label": "Field", - "options": "DocField", + "depends_on": "eval: (!doc.is_table_field || (doc.is_table_field && doc.parent_fieldname))", + "fieldname": "fieldname", + "fieldtype": "Select", + "label": "Fieldname", "reqd": 1 }, { - "fetch_from": "field.fieldname", - "fieldname": "fieldname", - "fieldtype": "Data", - "hidden": 1, - "label": "Fieldname", - "read_only": 1 - }, - { - "fetch_from": "field.label", "fieldname": "label", "fieldtype": "Data", "in_list_view": 1, @@ -88,10 +76,8 @@ }, { "default": "0", - "fetch_from": "field.fieldtype", "fieldname": "fieldtype", "fieldtype": "Data", - "hidden": 1, "label": "Fieldtype", "read_only": 1 }, @@ -105,14 +91,6 @@ "fieldname": "section_break_2", "fieldtype": "Section Break" }, - { - "depends_on": "is_table_field", - "fieldname": "parent_field", - "fieldtype": "Link", - "label": "Parent Field", - "mandatory_depends_on": "is_table_field", - "options": "DocField" - }, { "fieldname": "section_break_13", "fieldtype": "Section Break", @@ -120,7 +98,6 @@ "label": "Hidden Fields" }, { - "fetch_from": "parent_field.options", "fieldname": "child_doctype", "fieldtype": "Data", "hidden": 1, @@ -128,18 +105,17 @@ "read_only": 1 }, { - "fetch_from": "parent_field.fieldname", + "depends_on": "is_table_field", "fieldname": "parent_fieldname", - "fieldtype": "Data", - "hidden": 1, - "label": "Parent Fieldname", - "read_only": 1 + "fieldtype": "Select", + "label": "Parent Field", + "mandatory_depends_on": "is_table_field" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-06-06 20:52:21.076972", + "modified": "2022-01-27 15:18:36.481801", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour Step", @@ -147,5 +123,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index 155a925fcf..97f529a061 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -76,26 +76,6 @@ def archive_restore_column(board_name, column_title, status): return doc.columns -@frappe.whitelist() -def update_doc(doc): - '''Updates the doc when card is edited''' - doc = json.loads(doc) - - try: - to_update = doc - doctype = doc['doctype'] - docname = doc['name'] - doc = frappe.get_doc(doctype, docname) - doc.update(to_update) - doc.save() - except: - return { - 'doc': doc, - 'exc': frappe.utils.get_traceback() - } - return doc - - @frappe.whitelist() def update_order(board_name, order): '''Save the order of cards in columns''' diff --git a/frappe/desk/doctype/number_card/number_card.js b/frappe/desk/doctype/number_card/number_card.js index 6d1454a2cb..f548388a99 100644 --- a/frappe/desk/doctype/number_card/number_card.js +++ b/frappe/desk/doctype/number_card/number_card.js @@ -28,6 +28,7 @@ frappe.ui.form.on('Number Card', { frm.trigger('render_filters_table'); } frm.trigger('create_add_to_dashboard_button'); + frm.trigger('set_parent_document_type'); }, create_add_to_dashboard_button: function(frm) { @@ -141,7 +142,9 @@ frappe.ui.form.on('Number Card', { frm.set_value('filters_json', '[]'); frm.set_value('dynamic_filters_json', '[]'); frm.set_value('aggregate_function_based_on', ''); + frm.set_value('parent_document_type', ''); frm.trigger('set_options'); + frm.trigger('set_parent_document_type'); }, set_options: function(frm) { @@ -317,6 +320,7 @@ frappe.ui.form.on('Number Card', { 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: () => {}, }); filters && frm.filter_group.add_filters_to_filter_group(filters); @@ -436,6 +440,36 @@ frappe.ui.form.on('Number Card', { 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/number_card/number_card.json b/frappe/desk/doctype/number_card/number_card.json index d3e9598eb7..7975d878ba 100644 --- a/frappe/desk/doctype/number_card/number_card.json +++ b/frappe/desk/doctype/number_card/number_card.json @@ -16,6 +16,7 @@ "aggregate_function_based_on", "column_break_2", "document_type", + "parent_document_type", "report_field", "report_function", "is_public", @@ -188,10 +189,17 @@ "label": "Function", "mandatory_depends_on": "eval: doc.type == 'Report'", "options": "Sum\nAverage\nMinimum\nMaximum" + }, + { + "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:11:03.391719", + "modified": "2022-03-10 15:34:38.210910", "modified_by": "Administrator", "module": "Desk", "name": "Number Card", @@ -234,6 +242,7 @@ "search_fields": "label, document_type", "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "label", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 5662523a9d..784f46bb19 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -3,6 +3,7 @@ # License: MIT. See LICENSE import frappe +from frappe import _ from frappe.model.document import Document from frappe.utils import cint from frappe.model.naming import append_number_if_name_exists @@ -17,6 +18,13 @@ class NumberCard(Document): if frappe.db.exists("Number Card", self.name): self.name = append_number_if_name_exists('Number Card', self.name) + def validate(self): + if not self.document_type: + frappe.throw(_("Document type is required to create a number card")) + + if self.document_type and frappe.get_meta(self.document_type).istable and not self.parent_document_type: + frappe.throw(_("Parent document type is required to create a number card")) + def on_update(self): if frappe.conf.developer_mode and self.is_standard: export_to_files(record_list=[['Number Card', self.name]], record_module=self.module) diff --git a/frappe/desk/doctype/route_history/route_history.py b/frappe/desk/doctype/route_history/route_history.py index a49d5d5418..f0aa867c8a 100644 --- a/frappe/desk/doctype/route_history/route_history.py +++ b/frappe/desk/doctype/route_history/route_history.py @@ -52,3 +52,9 @@ def deferred_insert(routes): ] _deferred_insert("Route History", json.dumps(routes)) + +@frappe.whitelist() +def frequently_visited_links(): + return frappe.get_all('Route History', fields=['route', 'count(name) as count'], filters={ + 'user': frappe.session.user + }, group_by="route", order_by="count desc", limit=5) diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js index fc83069fd2..7751ffe860 100644 --- a/frappe/desk/doctype/system_console/system_console.js +++ b/frappe/desk/doctype/system_console/system_console.js @@ -88,15 +88,16 @@ frappe.ui.form.on('System Console', { ${row.Progress} ` } + frm.get_field('processlist').html(`

Requested on: ${timestamp}

- ${rows}`); }); diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py index 107ab2f932..bf0925e2d7 100644 --- a/frappe/desk/doctype/system_console/system_console.py +++ b/frappe/desk/doctype/system_console/system_console.py @@ -41,4 +41,14 @@ def execute_code(doc): @frappe.whitelist() def show_processlist(): frappe.only_for('System Manager') - return frappe.db.sql('show full processlist', as_dict=1) + + return frappe.db.multisql({ + "postgres": """ + SELECT pid AS "Id", + query_start AS "Time", + state AS "State", + query AS "Info", + wait_event AS "Progress" + FROM pg_stat_activity""", + "mariadb": "show full processlist" + }, as_dict=True) diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index 381c24a765..d44c481210 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -148,8 +148,6 @@ def update_tags(doc, tags): "doctype": "Tag Link", "document_type": doc.doctype, "document_name": doc.name, - "parenttype": doc.doctype, - "parent": doc.name, "title": doc.get_title() or '', "tag": tag }).insert(ignore_permissions=True) diff --git a/frappe/desk/doctype/todo/todo_calendar.js b/frappe/desk/doctype/todo/todo_calendar.js index 4545846cf9..8ba020fac1 100644 --- a/frappe/desk/doctype/todo/todo_calendar.js +++ b/frappe/desk/doctype/todo/todo_calendar.js @@ -24,7 +24,7 @@ frappe.views.calendar["ToDo"] = { "options": "reference_type", "label": __("Task") } - + ], get_events_method: "frappe.desk.calendar.get_events" }; diff --git a/frappe/desk/doctype/workspace/workspace.js b/frappe/desk/doctype/workspace/workspace.js index 5377470343..3f912127fc 100644 --- a/frappe/desk/doctype/workspace/workspace.js +++ b/frappe/desk/doctype/workspace/workspace.js @@ -9,7 +9,7 @@ frappe.ui.form.on('Workspace', { refresh: function(frm) { frm.enable_save(); - if (frm.doc.for_user || (frm.doc.public && !frm.has_perm('write') && + if (frm.doc.for_user || (frm.doc.public && !frm.has_perm('write') && !frappe.user.has_role('Workspace Manager'))) { frm.trigger('disable_form'); } diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 04975c69e3..fa8b81f5fd 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_rename": 1, "autoname": "field:label", "beta": 1, "creation": "2020-01-23 13:45:59.470592", @@ -19,13 +20,13 @@ "hide_custom", "public", "content", - "section_break_2", + "tab_break_2", "charts", - "section_break_15", + "tab_break_15", "shortcuts", - "section_break_18", + "tab_break_18", "links", - "roles_section", + "roles_tab", "roles" ], "fields": [ @@ -39,8 +40,8 @@ { "collapsible": 1, "collapsible_depends_on": "charts", - "fieldname": "section_break_2", - "fieldtype": "Section Break", + "fieldname": "tab_break_2", + "fieldtype": "Tab Break", "label": "Dashboards" }, { @@ -77,15 +78,15 @@ { "collapsible": 1, "collapsible_depends_on": "shortcuts", - "fieldname": "section_break_15", - "fieldtype": "Section Break", + "fieldname": "tab_break_15", + "fieldtype": "Tab Break", "label": "Shortcuts" }, { "collapsible": 1, "collapsible_depends_on": "links", - "fieldname": "section_break_18", - "fieldtype": "Section Break", + "fieldname": "tab_break_18", + "fieldtype": "Tab Break", "label": "Link Cards" }, { @@ -141,7 +142,7 @@ }, { "fieldname": "sequence_id", - "fieldtype": "Int", + "fieldtype": "Float", "label": "Sequence Id" }, { @@ -151,14 +152,14 @@ "options": "Has Role" }, { - "fieldname": "roles_section", - "fieldtype": "Section Break", + "fieldname": "roles_tab", + "fieldtype": "Tab Break", "label": "Roles" } ], "in_create": 1, "links": [], - "modified": "2021-09-16 12:01:06.450622", + "modified": "2022-01-27 12:06:13.111743", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 94114e3918..ba3319b591 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.modules.export_file import export_to_files from frappe.model.document import Document +from frappe.model.rename_doc import rename_doc from frappe.desk.desktop import save_new_widget from frappe.desk.utils import validate_route_conflict @@ -121,84 +122,186 @@ def get_report_type(report): report_type = frappe.get_value("Report", report, "report_type") return report_type in ["Query Report", "Script Report", "Custom Report"] +@frappe.whitelist() +def new_page(new_page): + if not loads(new_page): + return + + page = loads(new_page) + + if page.get("public") and not is_workspace_manager(): + return + + doc = frappe.new_doc('Workspace') + doc.title = page.get('title') + doc.icon = page.get('icon') + doc.content = page.get('content') + doc.parent_page = page.get('parent_page') + doc.label = page.get('label') + doc.for_user = page.get('for_user') + doc.public = page.get('public') + doc.sequence_id = last_sequence_id(doc) + 1 + doc.save(ignore_permissions=True) + + return doc @frappe.whitelist() -def save_page(title, icon, parent, public, sb_public_items, sb_private_items, deleted_pages, new_widgets, blocks, save): - save = frappe.parse_json(save) +def save_page(title, public, new_widgets, blocks): public = frappe.parse_json(public) - if save: - doc = frappe.new_doc('Workspace') - doc.title = title - doc.icon = icon - doc.content = blocks - doc.parent_page = parent - if public: - doc.label = title - doc.public = 1 - else: - doc.label = title + "-" + frappe.session.user - doc.for_user = frappe.session.user - doc.save(ignore_permissions=True) - else: - if public: - filters = { - 'public': public, - 'label': title - } - else: - filters = { - 'for_user': frappe.session.user, - 'label': title + "-" + frappe.session.user - } - pages = frappe.get_list("Workspace", filters=filters) - if pages: - doc = frappe.get_doc("Workspace", pages[0]) + filters = { + 'public': public, + 'label': title + } - doc.content = blocks - doc.save(ignore_permissions=True) + if not public: + filters = { + 'for_user': frappe.session.user, + 'label': title + "-" + frappe.session.user + } + pages = frappe.get_list("Workspace", filters=filters) + if pages: + doc = frappe.get_doc("Workspace", pages[0]) - if loads(new_widgets): - save_new_widget(doc, title, blocks, new_widgets) + doc.content = blocks + doc.save(ignore_permissions=True) - if loads(sb_public_items) or loads(sb_private_items): - sort_pages(loads(sb_public_items), loads(sb_private_items)) - - if loads(deleted_pages): - return delete_pages(loads(deleted_pages)) + save_new_widget(doc, title, blocks, new_widgets) return {"name": title, "public": public, "label": doc.label} -def delete_pages(deleted_pages): - for page in deleted_pages: - if page.get("public") and not is_workspace_manager(): - return {"name": page.get("title"), "public": 1, "label": page.get("label")} +@frappe.whitelist() +def update_page(name, title, icon, parent, public): + public = frappe.parse_json(public) - if frappe.db.exists("Workspace", page.get("name")): - frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) + doc = frappe.get_doc("Workspace", name) - return {"name": "Home", "public": 1, "label": "Home"} + filters = { + 'parent_page': doc.title, + 'public': doc.public + } + child_docs = frappe.get_list("Workspace", filters=filters) + if doc: + doc.title = title + doc.icon = icon + doc.parent_page = parent + if doc.public != public: + doc.sequence_id = frappe.db.count('Workspace', {'public':public}, cache=True) + doc.public = public + doc.for_user = '' if public else doc.for_user or frappe.session.user + doc.label = '{0}-{1}'.format(title, doc.for_user) if doc.for_user else title + doc.save(ignore_permissions=True) + + if name != doc.label: + rename_doc("Workspace", name, doc.label, force=True, ignore_permissions=True) + + # update new name and public in child pages + if child_docs: + for child in child_docs: + child_doc = frappe.get_doc("Workspace", child.name) + child_doc.parent_page = doc.title + child_doc.public = doc.public + child_doc.save(ignore_permissions=True) + + return {"name": doc.title, "public": doc.public, "label": doc.label} + +@frappe.whitelist() +def duplicate_page(page_name, new_page): + if not loads(new_page): + return + + new_page = loads(new_page) + + if new_page.get("is_public") and not is_workspace_manager(): + return + + old_doc = frappe.get_doc("Workspace", page_name) + doc = frappe.copy_doc(old_doc) + doc.title = new_page.get('title') + doc.icon = new_page.get('icon') + doc.parent_page = new_page.get('parent') or '' + doc.public = new_page.get('is_public') + doc.for_user = '' + doc.label = doc.title + if not doc.public: + doc.for_user = doc.for_user or frappe.session.user + doc.label = '{0}-{1}'.format(doc.title, doc.for_user) + doc.name = doc.label + if old_doc.public == doc.public: + doc.sequence_id += 0.1 + else: + doc.sequence_id = last_sequence_id(doc) + 1 + doc.insert(ignore_permissions=True) + + return doc + +@frappe.whitelist() +def delete_page(page): + if not loads(page): + return + + page = loads(page) + + if page.get("public") and not is_workspace_manager(): + return + + if frappe.db.exists("Workspace", page.get("name")): + frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) + + return {"name": page.get("name"), "public": page.get("public"), "title": page.get("title")} + +@frappe.whitelist() def sort_pages(sb_public_items, sb_private_items): - wspace_public_pages = get_page_list(['name', 'title'], {'public': 1}) - wspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user}) + if not loads(sb_public_items) and not loads(sb_private_items): + return + + sb_public_items = loads(sb_public_items) + sb_private_items = loads(sb_private_items) + + workspace_public_pages = get_page_list(['name', 'title'], {'public': 1}) + workspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user}) if sb_private_items: - sort_page(wspace_private_pages, sb_private_items) + return sort_page(workspace_private_pages, sb_private_items) if sb_public_items and is_workspace_manager(): - sort_page(wspace_public_pages, sb_public_items) + return sort_page(workspace_public_pages, sb_public_items) -def sort_page(wspace_pages, pages): + return False + +def sort_page(workspace_pages, pages): for seq, d in enumerate(pages): - for page in wspace_pages: + for page in workspace_pages: if page.title == d.get('title'): doc = frappe.get_doc('Workspace', page.name) doc.sequence_id = seq + 1 doc.parent_page = d.get('parent_page') or "" + doc.flags.ignore_links = True doc.save(ignore_permissions=True) break + return True + +def last_sequence_id(doc): + doc_exists = frappe.db.exists({ + 'doctype': 'Workspace', + 'public': doc.public, + 'for_user': doc.for_user + }) + + if not doc_exists: + return 0 + + return frappe.db.get_list('Workspace', + fields=['sequence_id'], + filters={ + 'public': doc.public, + 'for_user': doc.for_user + }, + order_by="sequence_id desc" + )[0].sequence_id + def get_page_list(fields, filters): return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc') diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index cd87c898d8..010d65c95b 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -1,9 +1,10 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE + import json from collections import defaultdict import itertools -from typing import List +from typing import Dict, List, Optional import frappe import frappe.desk.form.load @@ -367,7 +368,7 @@ def get_exempted_doctypes(): @frappe.whitelist() -def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): +def get_linked_docs(doctype: str, name: str, linkinfo: Optional[Dict] = None) -> Dict[str, List]: if isinstance(linkinfo, str): # additional fields are added in linkinfo linkinfo = json.loads(linkinfo) @@ -377,25 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): if not linkinfo: return results - if for_doctype: - links = frappe.get_doc(doctype, name).get_link_filters(for_doctype) - - if links: - linkinfo = links - - if for_doctype in linkinfo: - # only get linked with for this particular doctype - linkinfo = { for_doctype: linkinfo.get(for_doctype) } - else: - return results - - me = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True) - for dt, link in linkinfo.items(): filters = [] link["doctype"] = dt - link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) + try: + link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) + except Exception as e: + if isinstance(e, frappe.DoesNotExistError): + if frappe.local.message_log: + frappe.local.message_log.pop() + continue linkmeta = link_meta_bundle[0] + + if not linkmeta.has_permission(): + continue + if not linkmeta.get("issingle"): fields = [d.fieldname for d in linkmeta.get("fields", { "in_list_view": 1, @@ -413,11 +410,16 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): 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 = None + + # check for child table + if not frappe.get_meta(doctype).istable: + continue + + me = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True) + if me and me.parenttype == dt: ret = frappe.get_all(doctype=dt, fields=fields, filters=[[dt, "name", '=', me.parent]]) - else: - ret = None elif link.get("child_doctype"): or_filters = [[link.get('child_doctype'), link_fieldnames, '=', name] for link_fieldnames in link.get("fieldname")] @@ -453,6 +455,13 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): return results + +@frappe.whitelist() +def get(doctype, docname): + linked_doctypes = get_linked_doctypes(doctype=doctype) + return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes) + + @frappe.whitelist() def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): """add list of doctypes this doctype is 'linked' with. @@ -467,13 +476,14 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): else: return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) + def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): ret = {} # find fields where this doctype is linked ret.update(get_linked_fields(doctype, without_ignore_user_permissions_enabled)) ret.update(get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled)) - filters=[['fieldtype', 'in', frappe.model.table_fields], ['options', '=', doctype]] + filters = [['fieldtype', 'in', frappe.model.table_fields], ['options', '=', doctype]] if without_ignore_user_permissions_enabled: filters.append(['ignore_user_permissions', '!=', 1]) # find links of parents links = frappe.get_all("DocField", fields=["parent as dt"], filters=filters) @@ -496,14 +506,15 @@ def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False) return ret + def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): - filters=[['fieldtype','=', 'Link'], ['options', '=', doctype]] + filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]] if without_ignore_user_permissions_enabled: filters.append(['ignore_user_permissions', '!=', 1]) # find links of parents links = frappe.get_all("DocField", fields=["parent", "fieldname"], filters=filters, as_list=1) - links+= frappe.get_all("Custom Field", fields=["dt as parent", "fieldname"], filters=filters, as_list=1) + links += frappe.get_all("Custom Field", fields=["dt as parent", "fieldname"], filters=filters, as_list=1) ret = {} @@ -526,37 +537,41 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): return ret + def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False): ret = {} - filters=[['fieldtype','=', 'Dynamic Link']] + filters = [['fieldtype','=', 'Dynamic Link']] if without_ignore_user_permissions_enabled: filters.append(['ignore_user_permissions', '!=', 1]) # find dynamic links of parents links = frappe.get_all("DocField", fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], filters=filters) - links+= frappe.get_all("Custom Field", fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], filters=filters) + links += frappe.get_all("Custom Field", fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], filters=filters) for df in links: if is_single(df.doctype): continue - # optimized to get both link exists and parenttype - possible_link = frappe.get_all(df.doctype, filters={df.doctype_fieldname: doctype}, - fields=['parenttype'], distinct=True) + is_child = frappe.get_meta(df.doctype).istable + possible_link = frappe.get_all( + df.doctype, + filters={df.doctype_fieldname: doctype}, + fields=["parenttype"] if is_child else None, + distinct=True + ) if not possible_link: continue - for d in possible_link: - # is child - if d.parenttype: + if is_child: + for d in possible_link: ret[d.parenttype] = { "child_doctype": df.doctype, "fieldname": [df.fieldname], "doctype_fieldname": df.doctype_fieldname } - else: - ret[df.doctype] = { - "fieldname": [df.fieldname], - "doctype_fieldname": df.doctype_fieldname - } + else: + ret[df.doctype] = { + "fieldname": [df.fieldname], + "doctype_fieldname": df.doctype_fieldname + } return ret diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 38b671d629..0140157c9d 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -10,9 +10,12 @@ import frappe.desk.form.meta from frappe.model.utils.user_settings import get_user_settings from frappe.permissions import get_doc_permissions from frappe.desk.form.document_follow import is_document_followed +from frappe.utils.data import cstr from frappe import _ +from frappe import _dict from urllib.parse import quote + @frappe.whitelist() def getdoc(doctype, name, user=None): """ @@ -49,9 +52,12 @@ def getdoc(doctype, name, user=None): raise doc.add_seen() - + set_link_titles(doc) + if frappe.response.docs is None: + frappe.response = _dict({"docs": []}) frappe.response.docs.append(doc) + @frappe.whitelist() def getdoctype(doctype, with_parent=False, cached_timestamp=None): """load doctype""" @@ -91,8 +97,8 @@ def get_docinfo(doc=None, doctype=None, name=None): raise frappe.PermissionError all_communications = _get_communications(doc.doctype, doc.name) - automated_messages = filter(lambda x: x['communication_type'] == 'Automated Message', all_communications) - communications_except_auto_messages = filter(lambda x: x['communication_type'] != 'Automated Message', all_communications) + automated_messages = [msg for msg in all_communications if msg['communication_type'] == 'Automated Message'] + communications_except_auto_messages = [msg for msg in all_communications if msg['communication_type'] != 'Automated Message'] docinfo = frappe._dict(user_info = {}) @@ -350,7 +356,7 @@ def get_document_email(doctype, name): return None email = email.split("@") - return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(name), email[1]) + return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(cstr(name)), email[1]) def get_automatic_email_link(): return frappe.db.get_value("Email Account", {"enable_incoming": 1, "enable_automatic_linking": 1}, "email_id") @@ -366,6 +372,60 @@ def get_additional_timeline_content(doctype, docname): return contents +def set_link_titles(doc): + link_titles = {} + link_titles.update(get_title_values_for_link_and_dynamic_link_fields(doc)) + link_titles.update(get_title_values_for_table_and_multiselect_fields(doc)) + + send_link_titles(link_titles) + +def get_title_values_for_link_and_dynamic_link_fields(doc, link_fields=None): + link_titles = {} + + if not link_fields: + meta = frappe.get_meta(doc.doctype) + link_fields = meta.get_link_fields() + meta.get_dynamic_link_fields() + + for field in link_fields: + if not doc.get(field.fieldname): + continue + + doctype = field.options if field.fieldtype == "Link" else doc.get(field.options) + + meta = frappe.get_meta(doctype) + if not meta or not (meta.title_field and meta.show_title_field_in_link): + continue + + link_title = frappe.db.get_value( + doctype, doc.get(field.fieldname), meta.title_field, cache=True + ) + link_titles.update({doctype + "::" + doc.get(field.fieldname): link_title}) + + return link_titles + +def get_title_values_for_table_and_multiselect_fields(doc, table_fields=None): + link_titles = {} + + if not table_fields: + meta = frappe.get_meta(doc.doctype) + table_fields = meta.get_table_fields() + + for field in table_fields: + if not doc.get(field.fieldname): + continue + + for value in doc.get(field.fieldname): + link_titles.update(get_title_values_for_link_and_dynamic_link_fields(value)) + + return link_titles + +def send_link_titles(link_titles): + """Append link titles dict in `frappe.local.response`.""" + if "_link_titles" not in frappe.local.response: + frappe.local.response["_link_titles"] = {} + + frappe.local.response["_link_titles"].update(link_titles) + def update_user_info(docinfo): for d in docinfo.communications: frappe.utils.add_user_info(d.sender, docinfo.user_info) @@ -386,3 +446,4 @@ def get_user_info_for_viewers(users): frappe.utils.add_user_info(user, user_info) return user_info + diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index b91dd3d481..fa6a1f313b 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -12,6 +12,15 @@ from frappe.translate import extract_messages_from_code, make_dict_from_messages from frappe.utils import get_html_format +ASSET_KEYS = ( + "__js", "__css", "__list_js", "__calendar_js", "__map_js", + "__linked_with", "__messages", "__print_formats", "__workflow_docs", + "__form_grid_templates", "__listview_template", "__tree_js", + "__dashboard", "__kanban_column_fields", '__templates', + '__custom_js', '__custom_list_js' +) + + def get_meta(doctype, cached=True): # don't cache for developer mode as js files, templates may be edited if cached and not frappe.conf.developer_mode: @@ -34,6 +43,12 @@ class FormMeta(Meta): super(FormMeta, self).__init__(doctype) self.load_assets() + def set(self, key, value, *args, **kwargs): + if key in ASSET_KEYS: + self.__dict__[key] = value + else: + super(FormMeta, self).set(key, value, *args, **kwargs) + def load_assets(self): if self.get('__assets_loaded', False): return @@ -55,11 +70,7 @@ class FormMeta(Meta): def as_dict(self, no_nulls=False): d = super(FormMeta, self).as_dict(no_nulls=no_nulls) - for k in ("__js", "__css", "__list_js", "__calendar_js", "__map_js", - "__linked_with", "__messages", "__print_formats", "__workflow_docs", - "__form_grid_templates", "__listview_template", "__tree_js", - "__dashboard", "__kanban_column_fields", '__templates', - '__custom_js', '__custom_list_js'): + for k in ASSET_KEYS: d[k] = self.get(k) # d['fields'] = d.get('fields', []) @@ -172,7 +183,7 @@ class FormMeta(Meta): WHERE doc_type=%s AND docstatus<2 and disabled=0""", (self.name,), as_dict=1, update={"doctype":"Print Format"}) - self.set("__print_formats", print_formats, as_value=True) + self.set("__print_formats", print_formats) def load_workflows(self): # get active workflow @@ -186,7 +197,7 @@ class FormMeta(Meta): for d in workflow.get("states"): workflow_docs.append(frappe.get_doc("Workflow State", d.state)) - self.set("__workflow_docs", workflow_docs, as_value=True) + self.set("__workflow_docs", workflow_docs) def load_templates(self): @@ -208,7 +219,7 @@ class FormMeta(Meta): for content in self.get("__form_grid_templates").values(): messages = extract_messages_from_code(content) messages = make_dict_from_messages(messages) - self.get("__messages").update(messages, as_value=True) + self.get("__messages").update(messages) def load_dashboard(self): self.set('__dashboard', self.get_dashboard_data()) @@ -224,7 +235,7 @@ class FormMeta(Meta): fields = [x['field_name'] for x in values] fields = list(set(fields)) - self.set("__kanban_column_fields", fields, as_value=True) + self.set("__kanban_column_fields", fields) except frappe.PermissionError: # no access to kanban board pass diff --git a/frappe/desk/moduleview.py b/frappe/desk/moduleview.py index e2e2c4c155..7a9c211c3c 100644 --- a/frappe/desk/moduleview.py +++ b/frappe/desk/moduleview.py @@ -524,7 +524,7 @@ def get_last_modified(doctype): raise # hack: save as -1 so that it is cached - if last_modified==None: + if last_modified is None: last_modified = -1 return last_modified diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index b42d8c58b7..0c32e886f4 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -6,7 +6,6 @@ from frappe.utils import strip, cint from frappe.translate import (set_default_language, get_dict, send_translations) from frappe.geo.country_info import get_country_info from frappe.utils.password import update_password -from werkzeug.useragents import UserAgent from . import install_fixtures def get_setup_stages(args): @@ -315,17 +314,10 @@ def prettify_args(args): return pretty_args def email_setup_wizard_exception(traceback, args): - if not frappe.local.conf.setup_wizard_exception_email: + if not frappe.conf.setup_wizard_exception_email: return pretty_args = prettify_args(args) - - if frappe.local.request: - user_agent = UserAgent(frappe.local.request.headers.get('User-Agent', '')) - - else: - user_agent = frappe._dict() - message = """ #### Traceback @@ -349,18 +341,15 @@ def email_setup_wizard_exception(traceback, args): #### Basic Information - **Site:** {site} -- **User:** {user} -- **Browser:** {user_agent.platform} {user_agent.browser} version: {user_agent.version} language: {user_agent.language} -- **Browser Languages**: `{accept_languages}`""".format( +- **User:** {user}""".format( site=frappe.local.site, traceback=traceback, args="\n".join(pretty_args), user=frappe.session.user, - user_agent=user_agent, - headers=frappe.local.request.headers, - accept_languages=", ".join(frappe.local.request.accept_languages.values())) + headers=frappe.request.headers, + ) - frappe.sendmail(recipients=frappe.local.conf.setup_wizard_exception_email, + frappe.sendmail(recipients=frappe.conf.setup_wizard_exception_email, sender=frappe.session.user, subject="Setup failed: {}".format(frappe.local.site), message=message, diff --git a/frappe/desk/page/user_profile/user_profile.py b/frappe/desk/page/user_profile/user_profile.py index 73df6d78cb..0d91fd0d91 100644 --- a/frappe/desk/page/user_profile/user_profile.py +++ b/frappe/desk/page/user_profile/user_profile.py @@ -30,7 +30,7 @@ def get_energy_points_percentage_chart_data(user, field): as_list = True) return { - "labels": [r[0] for r in result if r[0] != None], + "labels": [r[0] for r in result if r[0] is not None], "datasets": [{ "values": [r[1] for r in result] }] diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 97bceeb725..f5f50b14fe 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -73,7 +73,7 @@ def get_report_result(report, filters): return res @frappe.read_only() -def generate_report_result(report, filters=None, user=None, custom_columns=None): +def generate_report_result(report, filters=None, user=None, custom_columns=None, is_tree=False, parent_field=None): user = user or frappe.session.user filters = filters or [] @@ -108,7 +108,7 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None) result = get_filtered_data(report.ref_doctype, columns, result, user) if cint(report.add_total_row) and result and not skip_total_row: - result = add_total_row(result, columns) + result = add_total_row(result, columns, is_tree=is_tree, parent_field=parent_field) return { "result": result, @@ -210,7 +210,7 @@ def get_script(report_name): @frappe.whitelist() @frappe.read_only() -def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None): +def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None, is_tree=False, parent_field=None): report = get_report_doc(report_name) if not user: user = frappe.session.user @@ -238,7 +238,7 @@ def run(report_name, filters=None, user=None, ignore_prepared_report=False, cust dn = "" result = get_prepared_report_result(report, filters, dn, user) else: - result = generate_report_result(report, filters, user, custom_columns) + result = generate_report_result(report, filters, user, custom_columns, is_tree, parent_field) result["add_total_row"] = report.add_total_row and not result.get( "skip_total_row", False @@ -352,14 +352,10 @@ def export_query(): ) return - columns = get_columns_dict(data.columns) - from frappe.utils.xlsxutils import make_xlsx - data["result"] = handle_duration_fieldtype_values( - data.get("result"), data.get("columns") - ) - xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation) + format_duration_fields(data) + xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation) xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths) frappe.response["filename"] = report_name + ".xlsx" @@ -367,46 +363,25 @@ def export_query(): frappe.response["type"] = "binary" -def handle_duration_fieldtype_values(result, columns): - for i, col in enumerate(columns): - fieldtype = None - if isinstance(col, str): - col = col.split(":") - if len(col) > 1: - if col[1]: - fieldtype = col[1] - if "/" in fieldtype: - fieldtype, options = fieldtype.split("/") - else: - fieldtype = "Data" - else: - fieldtype = col.get("fieldtype") +def format_duration_fields(data: frappe._dict) -> None: + for i, col in enumerate(data.columns): + if col.get("fieldtype") != "Duration": + continue - if fieldtype == "Duration": - for entry in range(0, len(result)): - row = result[entry] - if isinstance(row, dict): - val_in_seconds = row[col.fieldname] - if val_in_seconds: - duration_val = format_duration(val_in_seconds) - row[col.fieldname] = duration_val - else: - val_in_seconds = row[i] - if val_in_seconds: - duration_val = format_duration(val_in_seconds) - row[i] = duration_val - - return result + for row in data.result: + index = col.fieldname if isinstance(row, dict) else i + if row[index]: + row[index] = format_duration(row[index]) -def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visible_idx=False): +def build_xlsx_data(data, visible_idx, include_indentation, ignore_visible_idx=False): result = [[]] column_widths = [] for column in data.columns: if column.get("hidden"): continue - result[0].append(column.get("label")) + result[0].append(_(column.get("label"))) column_width = cint(column.get('width', 0)) # to convert into scale accepted by openpyxl column_width /= 10 @@ -435,9 +410,10 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visi return result, column_widths -def add_total_row(result, columns, meta=None): +def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None): total_row = [""] * len(columns) has_percent = [] + for i, col in enumerate(columns): fieldtype, options, fieldname = None, None, None if isinstance(col, str): @@ -464,12 +440,12 @@ def add_total_row(result, columns, meta=None): for row in result: if i >= len(row): continue - cell = row.get(fieldname) if isinstance(row, dict) else row[i] if fieldtype in ["Currency", "Int", "Float", "Percent", "Duration"] and flt( cell ): - total_row[i] = flt(total_row[i]) + flt(cell) + if not (is_tree and row.get(parent_field)): + total_row[i] = flt(total_row[i]) + flt(cell) if fieldtype == "Percent" and i not in has_percent: has_percent.append(i) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index e81ed0767b..1ec8ede62e 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -6,7 +6,7 @@ import frappe, json import frappe.permissions from frappe.model.db_query import DatabaseQuery -from frappe.model import default_fields, optional_fields +from frappe.model import default_fields, optional_fields, child_table_fields from frappe import _ from io import StringIO from frappe.core.doctype.access_log.access_log import make_access_log @@ -19,7 +19,7 @@ from frappe.utils import add_user_info def get(): args = get_form_params() # If virtual doctype get data from controller het_list method - if frappe.db.get_value("DocType", filters={"name": args.doctype}, fieldname="is_virtual"): + if is_virtual_doctype(args.doctype): controller = get_controller(args.doctype) data = compress(controller(args.doctype).get_list(args)) else: @@ -29,17 +29,31 @@ def get(): @frappe.whitelist() @frappe.read_only() def get_list(): - # uncompressed (refactored from frappe.model.db_query.get_list) - return execute(**get_form_params()) + args = get_form_params() + + if is_virtual_doctype(args.doctype): + controller = get_controller(args.doctype) + data = controller(args.doctype).get_list(args) + else: + # uncompressed (refactored from frappe.model.db_query.get_list) + data = execute(**args) + + return data @frappe.whitelist() @frappe.read_only() def get_count(): args = get_form_params() - distinct = 'distinct ' if args.distinct=='true' else '' - args.fields = [f"count({distinct}`tab{args.doctype}`.name) as total_count"] - return execute(**args)[0].get('total_count') + if is_virtual_doctype(args.doctype): + controller = get_controller(args.doctype) + data = controller(args.doctype).get_count(args) + else: + distinct = 'distinct ' if args.distinct=='true' else '' + args.fields = [f"count({distinct}`tab{args.doctype}`.name) as total_count"] + data = execute(**args)[0].get('total_count') + + return data def execute(doctype, *args, **kwargs): return DatabaseQuery(doctype).execute(*args, **kwargs) @@ -142,7 +156,7 @@ def raise_invalid_field(fieldname): def is_standard(fieldname): if '.' in fieldname: parenttype, fieldname = get_parenttype_and_fieldname(fieldname, None) - return fieldname in default_fields or fieldname in optional_fields + return fieldname in default_fields or fieldname in optional_fields or fieldname in child_table_fields def extract_fieldname(field): for text in (',', '/*', '#'): @@ -248,22 +262,66 @@ def compress(data, args=None): } @frappe.whitelist() -def save_report(): - """save report""" +def save_report(name, doctype, report_settings): + """Save reports of type Report Builder from Report View""" - data = frappe.local.form_dict - if frappe.db.exists('Report', data['name']): - d = frappe.get_doc('Report', data['name']) + if frappe.db.exists('Report', name): + report = frappe.get_doc('Report', name) + if report.is_standard == "Yes": + frappe.throw(_("Standard Reports cannot be edited")) + + if report.report_type != "Report Builder": + frappe.throw(_("Only reports of type Report Builder can be edited")) + + if ( + report.owner != frappe.session.user + and not frappe.has_permission("Report", "write") + ): + frappe.throw( + _("Insufficient Permissions for editing Report"), + frappe.PermissionError + ) else: - d = frappe.new_doc('Report') - d.report_name = data['name'] - d.ref_doctype = data['doctype'] + report = frappe.new_doc('Report') + report.report_name = name + report.ref_doctype = doctype - d.report_type = "Report Builder" - d.json = data['json'] - frappe.get_doc(d).save() - frappe.msgprint(_("{0} is saved").format(d.name), alert=True) - return d.name + report.report_type = "Report Builder" + report.json = report_settings + report.save(ignore_permissions=True) + frappe.msgprint( + _("Report {0} saved").format(frappe.bold(report.name)), + indicator="green", + alert=True, + ) + return report.name + +@frappe.whitelist() +def delete_report(name): + """Delete reports of type Report Builder from Report View""" + + report = frappe.get_doc("Report", name) + if report.is_standard == "Yes": + frappe.throw(_("Standard Reports cannot be deleted")) + + if report.report_type != "Report Builder": + frappe.throw(_("Only reports of type Report Builder can be deleted")) + + if ( + report.owner != frappe.session.user + and not frappe.has_permission("Report", "delete") + ): + frappe.throw( + _("Insufficient Permissions for deleting Report"), + frappe.PermissionError + ) + + report.delete(ignore_permissions=True) + frappe.msgprint( + _("Report {0} deleted").format(frappe.bold(report.name)), + indicator="green", + alert=True, + ) @frappe.whitelist() @frappe.read_only() @@ -305,7 +363,7 @@ def export_query(): if add_totals_row: ret = append_totals_row(ret) - data = [['Sr'] + get_labels(db_query.fields, doctype)] + data = [[_('Sr')] + get_labels(db_query.fields, doctype)] for i, row in enumerate(ret): data.append([i+1] + list(row)) @@ -364,7 +422,8 @@ def get_labels(fields, doctype): for key in fields: key = key.split(" as ")[0] - if key.startswith(('count(', 'sum(', 'avg(')): continue + if key.startswith(('count(', 'sum(', 'avg(')): + continue if "." in key: parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`") @@ -372,10 +431,16 @@ def get_labels(fields, doctype): parenttype = doctype fieldname = fieldname.strip("`") - df = frappe.get_meta(parenttype).get_field(fieldname) - label = df.label if df else fieldname.title() - if label in labels: - label = doctype + ": " + label + if parenttype == doctype and fieldname == "name": + label = _("ID", context="Label of name column in report") + else: + df = frappe.get_meta(parenttype).get_field(fieldname) + label = _(df.label if df else fieldname.title()) + if parenttype != doctype: + # If the column is from a child table, append the child doctype. + # For example, "Item Code (Sales Invoice Item)". + label += f" ({ _(parenttype) })" + labels.append(label) return labels @@ -438,7 +503,14 @@ def get_sidebar_stats(stats, doctype, filters=None): if filters is None: filters = [] - return {"stats": get_stats(stats, doctype, filters)} + if is_virtual_doctype(doctype): + controller = get_controller(doctype) + args = {"stats": stats, "filters": filters} + data = controller(doctype).get_stats(args) + else: + data = get_stats(stats, doctype, filters) + + return {"stats": data} @frappe.whitelist() @frappe.read_only() @@ -461,7 +533,8 @@ def get_stats(stats, doctype, filters=None): columns = [] for tag in tags: - if not tag in columns: continue + if tag not in columns: + continue try: tag_count = frappe.get_list(doctype, fields=[tag, "count(*)"], @@ -540,7 +613,7 @@ def scrub_user_tags(tagcount): alltags = t.split(',') for tag in alltags: if tag: - if not tag in rdict: + if tag not in rdict: rdict[tag] = 0 rdict[tag] += tagdict[t] @@ -560,7 +633,7 @@ def get_match_cond(doctype, as_condition=True): return ((' and ' + cond) if cond else "").replace("%", "%%") def build_match_conditions(doctype, user=None, as_condition=True): - match_conditions = DatabaseQuery(doctype, user=user).build_match_conditions(as_condition=as_condition) + match_conditions = DatabaseQuery(doctype, user=user).build_match_conditions(as_condition=as_condition) if as_condition: return match_conditions.replace("%", "%%") else: @@ -598,3 +671,7 @@ def get_filters_cond(doctype, filters, conditions, ignore_permissions=None, with else: cond = '' return cond + +def is_virtual_doctype(doctype): + return frappe.db.get_value("DocType", doctype, "is_virtual") + diff --git a/frappe/desk/search.py b/frappe/desk/search.py index db88e6ec52..b54ea46268 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -49,8 +49,10 @@ def sanitize_searchfield(searchfield): # this is called by the Link Field @frappe.whitelist() def search_link(doctype, txt, query=None, filters=None, page_length=20, searchfield=None, reference_doctype=None, ignore_user_permissions=False): - search_widget(doctype, txt.strip(), query, searchfield=searchfield, page_length=page_length, filters=filters, reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions) - frappe.response['results'] = build_for_autosuggest(frappe.response["values"]) + search_widget(doctype, txt.strip(), query, searchfield=searchfield, page_length=page_length, filters=filters, + reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions) + + frappe.response["results"] = build_for_autosuggest(frappe.response["values"], doctype=doctype) del frappe.response["values"] # this is called by the search box @@ -107,7 +109,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, else: filters.append([doctype, f[0], "=", f[1]]) - if filters==None: + if filters is None: filters = [] or_filters = [] @@ -138,6 +140,12 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, fields = list(set(fields + json.loads(filter_fields))) formatted_fields = ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in fields] + title_field_query = get_title_field_query(meta) + + # Insert title field query after name + if title_field_query: + formatted_fields.insert(1, title_field_query) + # find relevance as location of search term from the beginning of string `name`. used for sorting results. formatted_fields.append("""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype)) @@ -205,11 +213,38 @@ def get_std_fields_list(meta, key): return sflist -def build_for_autosuggest(res): +def get_title_field_query(meta): + title_field = meta.title_field if meta.title_field else None + show_title_field_in_link = meta.show_title_field_in_link if meta.show_title_field_in_link else None + field = None + + if title_field and show_title_field_in_link: + field = "`tab{0}`.{1} as `label`".format(meta.name, title_field) + + return field + +def build_for_autosuggest(res, doctype): results = [] - for r in res: - out = {"value": r[0], "description": ", ".join(unique(cstr(d) for d in r if d)[1:])} - results.append(out) + meta = frappe.get_meta(doctype) + if not (meta.title_field and meta.show_title_field_in_link): + for r in res: + r = list(r) + results.append({ + "value": r[0], + "description": ", ".join(unique(cstr(d) for d in r[1:] if d)) + }) + + else: + title_field_exists = meta.title_field and meta.show_title_field_in_link + _from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists + for r in res: + r = list(r) + results.append({ + "value": r[0], + "label": r[1] if title_field_exists else None, + "description": ", ".join(unique(cstr(d) for d in r[_from:] if d)) + }) + return results def scrub_custom_query(query, key, txt): @@ -222,7 +257,7 @@ def scrub_custom_query(query, key, txt): def relevance_sorter(key, query, as_dict): value = _(key.name if as_dict else key[0]) return ( - value.lower().startswith(query.lower()) is not True, + cstr(value).lower().startswith(query.lower()) is not True, value ) @@ -272,3 +307,12 @@ def get_user_groups(): return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={ 'is_group': True }) + +@frappe.whitelist() +def get_link_title(doctype, docname): + meta = frappe.get_meta(doctype) + + if meta.title_field and meta.show_title_field_in_link: + return frappe.db.get_value(doctype, docname, meta.title_field) + + return docname \ No newline at end of file diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index 7e3efb5d48..5e8fb18fe4 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -15,7 +15,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters): tree_method = frappe.get_attr(tree_method) - if not tree_method in frappe.whitelisted: + if tree_method not in frappe.whitelisted: frappe.throw(_("Not Permitted"), frappe.PermissionError) data = tree_method(doctype, parent, **filters) diff --git a/frappe/desk/utils.py b/frappe/desk/utils.py index 5908277386..3328d47318 100644 --- a/frappe/desk/utils.py +++ b/frappe/desk/utils.py @@ -20,4 +20,4 @@ def validate_route_conflict(doctype, name): raise frappe.NameError def slug(name): - return name.lower().replace(' ', '-') \ No newline at end of file + return name.lower().replace(' ', '-') 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 34728375cd..abeb681a25 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -15,8 +15,6 @@ from frappe.utils.csvutils import to_csv from frappe.utils.xlsxutils import make_xlsx from frappe.desk.query_report import build_xlsx_data -max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 - class AutoEmailReport(Document): def autoname(self): @@ -46,6 +44,8 @@ class AutoEmailReport(Document): def validate_report_count(self): '''check that there are only 3 enabled reports per user''' count = frappe.db.sql('select count(*) from `tabAuto Email Report` where user=%s and enabled=1', self.user)[0][0] + max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 + if count > max_reports_per_user + (-1 if self.flags.in_insert else 0): frappe.throw(_('Only {0} emailed reports are allowed per user').format(max_reports_per_user)) @@ -104,7 +104,7 @@ class AutoEmailReport(Document): report_data['columns'] = columns report_data['result'] = data - xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True) xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths) return xlsx_file.getvalue() @@ -113,7 +113,7 @@ class AutoEmailReport(Document): report_data['columns'] = columns report_data['result'] = data - xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True) return to_csv(xlsx_data) else: @@ -252,7 +252,7 @@ def make_links(columns, data): 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": - doc = frappe.get_doc(col.parent, doc_name) if doc_name and col.parent else None + doc = frappe.get_doc(col.parent, doc_name) if doc_name and col.get("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) return columns, data diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 3f8d399c52..3a1b683398 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -1,5 +1,6 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # License: MIT. See LICENSE + import email.utils import functools import imaplib @@ -7,6 +8,7 @@ import socket import time from datetime import datetime, timedelta from poplib import error_proto +from typing import List import frappe from frappe import _, are_emails_muted, safe_encode @@ -82,9 +84,6 @@ class EmailAccount(Document): if frappe.local.flags.in_patch or frappe.local.flags.in_test: return - #if self.enable_incoming and not self.append_to: - # frappe.throw(_("Append To is mandatory for incoming mails")) - if (not self.awaiting_password and not frappe.local.flags.in_install and not frappe.local.flags.in_patch): if self.password or self.smtp_server in ('127.0.0.1', 'localhost'): @@ -422,10 +421,10 @@ class EmailAccount(Document): def get_failed_attempts_count(self): return cint(frappe.cache().get('{0}:email-account-failed-attempts'.format(self.name))) - def receive(self, test_mails=None): + def receive(self): """Called by scheduler to receive emails from this EMail account using POP3/IMAP.""" exceptions = [] - inbound_mails = self.get_inbound_mails(test_mails=test_mails) + inbound_mails = self.get_inbound_mails() for mail in inbound_mails: try: communication = mail.process() @@ -442,7 +441,7 @@ class EmailAccount(Document): frappe.db.rollback() except Exception: frappe.db.rollback() - frappe.log_error('email_account.receive') + frappe.log_error(title="EmailAccount.receive") if self.use_imap: self.handle_bad_emails(mail.uid, mail.raw_message, frappe.get_traceback()) exceptions.append(frappe.get_traceback()) @@ -458,20 +457,19 @@ class EmailAccount(Document): if exceptions: raise Exception(frappe.as_json(exceptions)) - def get_inbound_mails(self, test_mails=None): + def get_inbound_mails(self) -> List[InboundMail]: """retrive and return inbound mails. """ mails = [] - def process_mail(messages): + def process_mail(messages, append_to=None): 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 []] + seen_status = messages.get('seen_status', {}).get(uid) + if self.email_sync_option != 'UNSEEN' or seen_status != "SEEN": + # only append the emails with status != 'SEEN' if sync option is set to 'UNSEEN' + mails.append(InboundMail(message, self, uid, seen_status, append_to)) if not self.enable_incoming: return [] @@ -482,10 +480,10 @@ class EmailAccount(Document): 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) + if email_server.select_imap_folder(folder.folder_name): + email_server.settings['uid_validity'] = folder.uidvalidity + messages = email_server.get_messages(folder=f'"{folder.folder_name}"') or {} + process_mail(messages, folder.append_to) else: # process the pop3 account messages = email_server.get_messages() or {} @@ -495,7 +493,6 @@ class EmailAccount(Document): except Exception: frappe.log_error(title=_("Error while connecting to email account {0}").format(self.name)) return [] - return mails def handle_bad_emails(self, uid, raw, reason): @@ -625,7 +622,6 @@ class EmailAccount(Document): if frappe.db.exists("Email Account", {"enable_automatic_linking": 1, "name": ('!=', self.name)}): frappe.throw(_("Automatic Linking can be activated only for one Email Account.")) - def append_email_to_sent_folder(self, message): email_server = None try: @@ -643,7 +639,8 @@ class EmailAccount(Document): message = safe_encode(message) email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message) except Exception: - frappe.log_error() + frappe.log_error(title="EmailAccount.append_email_to_sent_folder") + @frappe.whitelist() def get_append_to(doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None): diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py index 6d26f9f070..f609c2947d 100644 --- a/frappe/email/doctype/email_account/test_email_account.py +++ b/frappe/email/doctype/email_account/test_email_account.py @@ -14,11 +14,11 @@ from frappe.core.doctype.communication.email import make from frappe.desk.form.load import get_attachments from frappe.email.doctype.email_account.email_account import notify_unreplied +from unittest.mock import patch + make_test_records("User") make_test_records("Email Account") - - class TestEmailAccount(unittest.TestCase): @classmethod def setUpClass(cls): @@ -45,10 +45,21 @@ class TestEmailAccount(unittest.TestCase): def test_incoming(self): cleanup("test_sender@example.com") - test_mails = [self.get_test_mail('incoming-1.raw')] + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + self.get_test_mail('incoming-1.raw') + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"}) self.assertTrue("test_receiver@example.com" in comm.recipients) @@ -72,11 +83,21 @@ class TestEmailAccount(unittest.TestCase): existing_file = frappe.get_doc({'doctype': 'File', 'file_name': 'erpnext-conf-14.png'}) frappe.delete_doc("File", existing_file.name) - with open(os.path.join(os.path.dirname(__file__), "test_mails", "incoming-2.raw"), "r") as testfile: - test_mails = [testfile.read()] + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + self.get_test_mail('incoming-2.raw') + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"}) self.assertTrue("test_receiver@example.com" in comm.recipients) @@ -93,11 +114,21 @@ class TestEmailAccount(unittest.TestCase): def test_incoming_attached_email_from_outlook_plain_text_only(self): cleanup("test_sender@example.com") - with open(os.path.join(os.path.dirname(__file__), "test_mails", "incoming-3.raw"), "r") as f: - test_mails = [f.read()] + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + self.get_test_mail('incoming-3.raw') + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"}) self.assertTrue("From: "Microsoft Outlook" <test_sender@example.com>" in comm.content) @@ -106,11 +137,21 @@ class TestEmailAccount(unittest.TestCase): def test_incoming_attached_email_from_outlook_layers(self): cleanup("test_sender@example.com") - with open(os.path.join(os.path.dirname(__file__), "test_mails", "incoming-4.raw"), "r") as f: - test_mails = [f.read()] + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + self.get_test_mail('incoming-4.raw') + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"}) self.assertTrue("From: "Microsoft Outlook" <test_sender@example.com>" in comm.content) @@ -151,11 +192,23 @@ class TestEmailAccount(unittest.TestCase): with open(os.path.join(os.path.dirname(__file__), "test_mails", "reply-1.raw"), "r") as f: raw = f.read() raw = raw.replace("<-- in-reply-to -->", sent_mail.get("Message-Id")) - test_mails = [raw] # parse reply + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + raw + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) sent = frappe.get_doc("Communication", sent_name) @@ -173,8 +226,20 @@ class TestEmailAccount(unittest.TestCase): test_mails.append(f.read()) # parse reply + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': test_mails, + 'seen_status': { + 2: 'UNSEEN', + 3: 'UNSEEN' + }, + 'uid_list': [2, 3] + } + } + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) comm_list = frappe.get_all("Communication", filters={"sender":"test_sender@example.com"}, fields=["name", "reference_doctype", "reference_name"]) @@ -197,11 +262,22 @@ class TestEmailAccount(unittest.TestCase): # get test mail with message-id as in-reply-to with open(os.path.join(os.path.dirname(__file__), "test_mails", "reply-4.raw"), "r") as f: - test_mails = [f.read().replace('{{ message_id }}', last_mail.message_id)] + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + f.read().replace('{{ message_id }}', last_mail.message_id) + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } # pull the mail email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) comm_list = frappe.get_all("Communication", filters={"sender":"test_sender@example.com"}, fields=["name", "reference_doctype", "reference_name"]) @@ -213,10 +289,21 @@ class TestEmailAccount(unittest.TestCase): def test_auto_reply(self): cleanup("test_sender@example.com") - test_mails = [self.get_test_mail('incoming-1.raw')] + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + self.get_test_mail('incoming-1.raw') + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } email_account = frappe.get_doc("Email Account", "_Test Email Account 1") - email_account.receive(test_mails=test_mails) + TestEmailAccount.mocked_email_receive(email_account, messages) comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"}) self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": comm.reference_doctype, @@ -246,6 +333,91 @@ class TestEmailAccount(unittest.TestCase): with self.assertRaises(Exception): email_account.validate() + def test_append_to(self): + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") + mail_content = self.get_test_mail(fname="incoming-2.raw") + + inbound_mail = InboundMail(mail_content, email_account, 12345, 1, 'ToDo') + communication = inbound_mail.process() + # the append_to for the email is set to ToDO in "_Test Email Account 1" + self.assertEqual(communication.reference_doctype, 'ToDo') + self.assertTrue(communication.reference_name) + self.assertTrue(frappe.db.exists(communication.reference_doctype, communication.reference_name)) + + def test_append_to_with_imap_folders(self): + mail_content_1 = self.get_test_mail(fname="incoming-1.raw") + mail_content_2 = self.get_test_mail(fname="incoming-2.raw") + mail_content_3 = self.get_test_mail(fname="incoming-3.raw") + + messages = { + # append_to = ToDo + '"INBOX"': { + 'latest_messages': [ + mail_content_1, + mail_content_2 + ], + 'seen_status': { + 0: 'UNSEEN', + 1: 'UNSEEN' + }, + 'uid_list': [0,1] + }, + # append_to = Communication + '"Test Folder"': { + 'latest_messages': [ + mail_content_3 + ], + 'seen_status': { + 2: 'UNSEEN' + }, + 'uid_list': [2] + } + } + + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") + mails = TestEmailAccount.mocked_get_inbound_mails(email_account, messages) + self.assertEqual(len(mails), 3) + + inbox_mails = 0 + test_folder_mails = 0 + + for mail in mails: + communication = mail.process() + if mail.append_to == 'ToDo': + inbox_mails += 1 + self.assertEqual(communication.reference_doctype, 'ToDo') + self.assertTrue(communication.reference_name) + self.assertTrue(frappe.db.exists(communication.reference_doctype, communication.reference_name)) + else: + test_folder_mails += 1 + self.assertEqual(communication.reference_doctype, None) + + self.assertEqual(inbox_mails, 2) + self.assertEqual(test_folder_mails, 1) + + @patch("frappe.email.receive.EmailServer.select_imap_folder", return_value=True) + @patch("frappe.email.receive.EmailServer.logout", side_effect=lambda: None) + def mocked_get_inbound_mails(email_account, messages={}, mocked_logout=None, mocked_select_imap_folder=None): + from frappe.email.receive import EmailServer + + def get_mocked_messages(**kwargs): + return messages.get(kwargs["folder"], {}) + + with patch.object(EmailServer, "get_messages", side_effect=get_mocked_messages): + mails = email_account.get_inbound_mails() + + return mails + + @patch("frappe.email.receive.EmailServer.select_imap_folder", return_value=True) + @patch("frappe.email.receive.EmailServer.logout", side_effect=lambda: None) + def mocked_email_receive(email_account, messages={}, mocked_logout=None, mocked_select_imap_folder=None): + def get_mocked_messages(**kwargs): + return messages.get(kwargs["folder"], {}) + + from frappe.email.receive import EmailServer + with patch.object(EmailServer, "get_messages", side_effect=get_mocked_messages): + email_account.receive() + class TestInboundMail(unittest.TestCase): @classmethod def setUpClass(cls): @@ -313,11 +485,11 @@ class TestInboundMail(unittest.TestCase): email_account = frappe.get_doc("Email Account", "_Test Email Account 1") inbound_mail = InboundMail(mail_content, email_account, 12345, 1) - new_communiction = inbound_mail.process() + new_communication = inbound_mail.process() # Make sure that uid is changed to new uid - self.assertEqual(new_communiction.uid, 12345) - self.assertEqual(communication.name, new_communiction.name) + self.assertEqual(new_communication.uid, 12345) + self.assertEqual(communication.name, new_communication.name) def test_find_parent_email_queue(self): """If the mail is reply to the already sent mail, there will be a email queue record. diff --git a/frappe/email/doctype/email_account/test_records.json b/frappe/email/doctype/email_account/test_records.json index 450895d7a6..66eb5a9b2e 100644 --- a/frappe/email/doctype/email_account/test_records.json +++ b/frappe/email/doctype/email_account/test_records.json @@ -20,7 +20,7 @@ "pop3_server": "pop.test.example.com", "no_remaining":"0", "append_to": "ToDo", - "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}], + "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}, {"folder_name": "Test Folder", "append_to": "Communication"}], "track_email_status": 1 }, { diff --git a/frappe/email/doctype/email_domain/test_email_domain.py b/frappe/email/doctype/email_domain/test_email_domain.py index 1064c7684a..7522dd5282 100644 --- a/frappe/email/doctype/email_domain/test_email_domain.py +++ b/frappe/email/doctype/email_domain/test_email_domain.py @@ -20,11 +20,13 @@ class TestDomain(unittest.TestCase): mail_domain = frappe.get_doc("Email Domain", "test.com") mail_account = frappe.get_doc("Email Account", "Test") - # Initially, incoming_port is different in domain and account - self.assertNotEqual(mail_account.incoming_port, mail_domain.incoming_port) + # Ensure a different port + mail_account.incoming_port = int(mail_domain.incoming_port) + 5 + mail_account.save() # Trigger update of accounts using this domain mail_domain.on_update() - mail_account = frappe.get_doc("Email Account", "Test") + + mail_account.reload() # After update, incoming_port in account should match the domain self.assertEqual(mail_account.incoming_port, mail_domain.incoming_port) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 9730004065..9b4f3b984c 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -11,7 +11,6 @@ import quopri from email.parser import Parser from email.policy import SMTPUTF8 from html2text import html2text -from six.moves import html_parser as HTMLParser import frappe from frappe import _, safe_encode, task @@ -20,6 +19,7 @@ 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, get_hook_method from frappe.email.doctype.email_account.email_account import EmailAccount +from frappe.query_builder.utils import DocType MAX_RETRY_COUNT = 3 @@ -444,7 +444,7 @@ class QueueBuilder: try: text_content = html2text(self._message) - except HTMLParser.HTMLParseError: + except Exception: text_content = "See html attachment" return text_content + unsubscribe_text_message @@ -477,18 +477,27 @@ class QueueBuilder: all_ids = list(set(self.recipients + self.cc)) - EmailUnsubscribe = frappe.qb.DocType("Email Unsubscribe") + EmailUnsubscribe = DocType("Email Unsubscribe") + + if len(all_ids) > 0: + unsubscribed = ( + frappe.qb.from_(EmailUnsubscribe).select( + EmailUnsubscribe.email + ).where( + EmailUnsubscribe.email.isin(all_ids) + & ( + ( + (EmailUnsubscribe.reference_doctype == self.reference_doctype) + & (EmailUnsubscribe.reference_name == self.reference_name) + ) | ( + EmailUnsubscribe.global_unsubscribe == 1 + ) + ) + ).distinct() + ).run(pluck=True) + else: + unsubscribed = None - unsubscribed = (frappe.qb.from_(EmailUnsubscribe) - .select(EmailUnsubscribe.email) - .where(EmailUnsubscribe.email.isin(all_ids) & - ( - ( - (EmailUnsubscribe.reference_doctype == self.reference_doctype) & (EmailUnsubscribe.reference_name == self.reference_name) - ) | EmailUnsubscribe.global_unsubscribe == 1 - ) - ).distinct() - ).run(pluck=True) self._unsubscribed_user_emails = unsubscribed or [] return self._unsubscribed_user_emails diff --git a/frappe/email/doctype/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json index baabd4991e..b42f4755cb 100644 --- a/frappe/email/doctype/newsletter/newsletter.json +++ b/frappe/email/doctype/newsletter/newsletter.json @@ -236,8 +236,7 @@ "index_web_pages_for_search": 1, "is_published_field": "published", "links": [], - "max_attachments": 3, - "modified": "2021-12-06 20:09:37.963141", + "modified": "2022-03-09 01:48:16.741603", "modified_by": "Administrator", "module": "Email", "name": "Newsletter", diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 8c1f803a46..b091c31c74 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -51,7 +51,7 @@ class TestNewsletterMixin: "reference_name": newsletter, }) frappe.delete_doc("Newsletter", newsletter) - frappe.db.delete("Newsletter Email Group", newsletter) + frappe.db.delete("Newsletter Email Group", {"parent": newsletter}) newsletters.remove(newsletter) def setup_email_group(self): diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 77979f9735..bad32fb68f 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -61,7 +61,7 @@ def get_context(context): """) def validate_standard(self): - if self.is_standard and not frappe.conf.developer_mode: + if self.is_standard and self.enabled and not frappe.conf.developer_mode: frappe.throw(_('Cannot edit Standard Notification. To edit, please disable this and duplicate it')) def validate_condition(self): @@ -137,7 +137,7 @@ def get_context(context): if self.set_property_after_alert: allow_update = True - if doc.docstatus == 1 and not doc.meta.get_field(self.set_property_after_alert).allow_on_submit: + if doc.docstatus.is_submitted() and not doc.meta.get_field(self.set_property_after_alert).allow_on_submit: allow_update = False try: if allow_update and not doc.flags.in_notification_update: @@ -186,7 +186,7 @@ def get_context(context): def send_an_email(self, doc, context): from email.utils import formataddr - from frappe.core.doctype.communication.email import make as make_communication + from frappe.core.doctype.communication.email import _make as make_communication subject = self.subject if "{" in subject: subject = frappe.render_template(self.subject, context) @@ -216,7 +216,8 @@ def get_context(context): # Add mail notification to communication list # No need to add if it is already a communication. if doc.doctype != 'Communication': - make_communication(doctype=doc.doctype, + make_communication( + doctype=doc.doctype, name=doc.name, content=message, subject=subject, @@ -228,7 +229,7 @@ def get_context(context): cc=cc, bcc=bcc, communication_type='Automated Message', - ignore_permissions=True) + ) def send_a_slack_msg(self, doc, context): send_slack_message( diff --git a/frappe/email/doctype/notification/test_notification.py b/frappe/email/doctype/notification/test_notification.py index f05d35be3e..f6f216ada2 100644 --- a/frappe/email/doctype/notification/test_notification.py +++ b/frappe/email/doctype/notification/test_notification.py @@ -240,7 +240,7 @@ class TestNotification(unittest.TestCase): self.assertTrue(email_queue) # check if description is changed after alert since set_property_after_alert is set - self.assertEquals(todo.description, 'Changed by Notification') + self.assertEqual(todo.description, 'Changed by Notification') recipients = [d.recipient for d in email_queue.recipients] self.assertTrue('test2@example.com' in recipients) diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index c25e996bd3..0f45e42aac 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -259,17 +259,12 @@ def get_formatted_html(subject, message, footer=None, print_html=None, email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) - signature = None - if "" not in message: - signature = get_signature(email_account) - rendered_email = frappe.get_template("templates/emails/standard.html").render({ "brand_logo": get_brand_logo(email_account) if with_container or header else None, "with_container": with_container, "site_url": get_url(), "header": get_header(header), "content": message, - "signature": signature, "footer": get_footer(email_account, footer), "title": subject, "print_html": print_html, @@ -281,8 +276,7 @@ def get_formatted_html(subject, message, footer=None, print_html=None, if unsubscribe_link: html = html.replace("", unsubscribe_link.html) - html = inline_style_in_html(html) - return html + return inline_style_in_html(html) @frappe.whitelist() def get_email_html(template, args, subject, header=None, with_container=False): diff --git a/frappe/email/receive.py b/frappe/email/receive.py index dd64d0df80..8aa32fc1a5 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -108,7 +108,8 @@ class EmailServer: raise def select_imap_folder(self, folder): - self.imap.select(folder) + res = self.imap.select(f'"{folder}"') + return res[0] == 'OK' # The folder exsits TODO: handle other resoponses too def logout(self): if cint(self.settings.use_imap): @@ -582,10 +583,11 @@ class Email: class InboundMail(Email): """Class representation of incoming mail along with mail handlers. """ - def __init__(self, content, email_account, uid=None, seen_status=None): + def __init__(self, content, email_account, uid=None, seen_status=None, append_to=None): super().__init__(content) self.email_account = email_account self.uid = uid or -1 + self.append_to = append_to self.seen_status = seen_status or 0 # System documents related to this mail @@ -623,15 +625,18 @@ class InboundMail(Email): if self.parent_communication(): data['in_reply_to'] = self.parent_communication().name + append_to = self.append_to if self.email_account.use_imap else self.email_account.append_to + if self.reference_document(): data['reference_doctype'] = self.reference_document().doctype data['reference_name'] = self.reference_document().name - elif self.email_account.append_to and self.email_account.append_to != 'Communication': - reference_doc = self._create_reference_document(self.email_account.append_to) - if reference_doc: - data['reference_doctype'] = reference_doc.doctype - data['reference_name'] = reference_doc.name - data['is_first'] = True + else: + if append_to and append_to != 'Communication': + reference_doc = self._create_reference_document(append_to) + if reference_doc: + data['reference_doctype'] = reference_doc.doctype + data['reference_name'] = reference_doc.name + data['is_first'] = True if self.is_notification(): # Disable notifications for notification. diff --git a/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py b/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py index 8f1e5504da..0565b3219d 100644 --- a/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py +++ b/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py @@ -5,7 +5,7 @@ import frappe import json from frappe import _ from frappe.model.document import Document -from frappe.model import default_fields +from frappe.model import default_fields, child_table_fields class DocumentTypeMapping(Document): def validate(self): @@ -14,7 +14,7 @@ class DocumentTypeMapping(Document): def validate_inner_mapping(self): meta = frappe.get_meta(self.local_doctype) for field_map in self.field_mapping: - if field_map.local_fieldname not in default_fields: + if field_map.local_fieldname not in (default_fields + child_table_fields): field = meta.get_field(field_map.local_fieldname) if not field: frappe.throw(_('Row #{0}: Invalid Local Fieldname').format(field_map.idx)) diff --git a/frappe/event_streaming/doctype/event_update_log/event_update_log.py b/frappe/event_streaming/doctype/event_update_log/event_update_log.py index f4871be312..cd5100623c 100644 --- a/frappe/event_streaming/doctype/event_update_log/event_update_log.py +++ b/frappe/event_streaming/doctype/event_update_log/event_update_log.py @@ -203,12 +203,17 @@ def get_unread_update_logs(consumer_name, dt, dn): SELECT update_log.name FROM `tabEvent Update Log` update_log - JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = update_log.name + JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = %(log_name)s WHERE consumer.consumer = %(consumer)s AND update_log.ref_doctype = %(dt)s AND update_log.docname = %(dn)s - """, {'consumer': consumer_name, "dt": dt, "dn": dn}, as_dict=0)] + """, { + "consumer": consumer_name, + "dt": dt, + "dn": dn, + "log_name": "update_log.name" if frappe.conf.db_type == "mariadb" else "CAST(update_log.name AS VARCHAR)" + }, as_dict=0)] logs = frappe.get_all( 'Event Update Log', diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 8449425bc1..6ee72b5f81 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -103,6 +103,7 @@ class DocumentAlreadyRestored(ValidationError): pass class AttachmentLimitReached(ValidationError): pass class QueryTimeoutError(Exception): pass class QueryDeadlockError(Exception): pass +class TooManyWritesError(Exception): pass # OAuth exceptions class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index 59db38584c..7a1587aae0 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -7,6 +7,7 @@ import json import requests import frappe +from frappe.utils.data import cstr class AuthError(Exception): @@ -122,7 +123,7 @@ class FrappeClient(object): '''Update a remote document :param doc: dict or Document object to be updated remotely. `name` is mandatory for this''' - url = self.url + "/api/resource/" + doc.get("doctype") + "/" + doc.get("name") + url = self.url + "/api/resource/" + doc.get("doctype") + "/" + cstr(doc.get("name")) res = self.session.put(url, data={"data":frappe.as_json(doc)}, verify=self.verify, headers=self.headers) return frappe._dict(self.post_process(res)) @@ -207,7 +208,7 @@ class FrappeClient(object): if fields: params["fields"] = json.dumps(fields) - res = self.session.get(self.url + "/api/resource/" + doctype + "/" + name, + res = self.session.get(self.url + "/api/resource/" + doctype + "/" + cstr(name), params=params, verify=self.verify, headers=self.headers) return self.post_process(res) diff --git a/frappe/handler.py b/frappe/handler.py old mode 100755 new mode 100644 index 3fd1c096e4..ebc72da937 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -225,11 +225,10 @@ def ping(): def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): """run a whitelisted controller method""" - import json - import inspect + from inspect import getfullargspec - if not args: - args = arg or "" + if not args and arg: + args = arg if dt: # not called from a doctype (from a page) if not dn: @@ -237,9 +236,7 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): doc = frappe.get_doc(dt, dn) else: - if isinstance(docs, str): - docs = json.loads(docs) - + docs = frappe.parse_json(docs) doc = frappe.get_doc(docs) doc._original_modified = doc.modified doc.check_if_latest() @@ -248,16 +245,16 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): throw_permission_error() try: - args = json.loads(args) + args = frappe.parse_json(args) except ValueError: - args = args + pass method_obj = getattr(doc, method) fn = getattr(method_obj, '__func__', method_obj) is_whitelisted(fn) is_valid_http_method(fn) - fnargs = inspect.getfullargspec(method_obj).args + fnargs = getfullargspec(method_obj).args if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"): response = doc.run_method(method) diff --git a/frappe/hooks.py b/frappe/hooks.py index 4895c97200..be1b0134c1 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -221,7 +221,8 @@ scheduler_events = { "frappe.deferred_insert.save_to_db", "frappe.desk.form.document_follow.send_hourly_updates", "frappe.integrations.doctype.google_calendar.google_calendar.sync", - "frappe.email.doctype.newsletter.newsletter.send_scheduled_email" + "frappe.email.doctype.newsletter.newsletter.send_scheduled_email", + "frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request" ], "daily": [ "frappe.email.queue.set_expiry_for_email_queue", @@ -240,8 +241,7 @@ scheduler_events = { "frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed", "frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", "frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports", - "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", - "frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request" + "frappe.core.doctype.log_settings.log_settings.run_log_clean_up" ], "daily_long": [ "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", diff --git a/frappe/installer.py b/frappe/installer.py old mode 100755 new mode 100644 index b50fa4a3b5..d10dc78286 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -14,8 +14,8 @@ from frappe.defaults import _clear_cache def _new_site( db_name, site, - mariadb_root_username=None, - mariadb_root_password=None, + db_root_username=None, + db_root_password=None, admin_password=None, verbose=False, install_apps=None, @@ -60,8 +60,8 @@ def _new_site( installing = touch_file(get_site_path("locks", "installing.lock")) install_db( - root_login=mariadb_root_username, - root_password=mariadb_root_password, + root_login=db_root_username, + root_password=db_root_password, db_name=db_name, admin_password=admin_password, verbose=verbose, @@ -92,7 +92,7 @@ def _new_site( print("*** Scheduler is", scheduler_status, "***") -def install_db(root_login="root", root_password=None, db_name=None, source_sql=None, +def install_db(root_login=None, root_password=None, db_name=None, source_sql=None, admin_password=None, verbose=True, force=0, site_config=None, reinstall=False, db_password=None, db_type=None, db_host=None, db_port=None, no_mariadb_socket=False): import frappe.database @@ -101,6 +101,11 @@ def install_db(root_login="root", root_password=None, db_name=None, source_sql=N if not db_type: db_type = frappe.conf.db_type or 'mariadb' + if not root_login and db_type == 'mariadb': + root_login='root' + elif not root_login and db_type == 'postgres': + root_login='postgres' + make_conf(db_name, site_config=site_config, db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port) frappe.flags.in_install_db = True @@ -154,7 +159,7 @@ def install_app(name, verbose=False, set_as_patched=True): for before_install in app_hooks.before_install or []: out = frappe.get_attr(before_install)() - if out==False: + if out is False: return if name != "frappe": @@ -184,7 +189,7 @@ def install_app(name, verbose=False, set_as_patched=True): def add_to_installed_apps(app_name, rebuild_website=True): installed_apps = frappe.get_installed_apps() - if not app_name in installed_apps: + if app_name not in installed_apps: installed_apps.append(app_name) frappe.db.set_global("installed_apps", json.dumps(installed_apps)) frappe.db.commit() @@ -346,14 +351,15 @@ def post_install(rebuild_website=False): def set_all_patches_as_completed(app): - patch_path = os.path.join(frappe.get_pymodule_path(app), "patches.txt") - if os.path.exists(patch_path): - for patch in frappe.get_file_items(patch_path): - frappe.get_doc({ - "doctype": "Patch Log", - "patch": patch - }).insert(ignore_permissions=True) - frappe.db.commit() + from frappe.modules.patch_handler import get_patches_from_app + + patches = get_patches_from_app(app) + for patch in patches: + frappe.get_doc({ + "doctype": "Patch Log", + "patch": patch + }).insert(ignore_permissions=True) + frappe.db.commit() def init_singles(): @@ -528,10 +534,9 @@ def extract_sql_gzip(sql_gz_path): import subprocess try: - # dvf - decompress, verbose, force original_file = sql_gz_path decompressed_file = original_file.rstrip(".gz") - cmd = 'gzip -dvf < {0} > {1}'.format(original_file, decompressed_file) + cmd = 'gzip --decompress --force < {0} > {1}'.format(original_file, decompressed_file) subprocess.check_call(cmd, shell=True) except Exception: raise @@ -606,7 +611,7 @@ def is_downgrade(sql_file_path, verbose=False): downgrade = backup_version > current_version if verbose and downgrade: - print("Your site will be downgraded from Frappe {0} to {1}".format(current_version, backup_version)) + print(f"Your site will be downgraded from Frappe {backup_version} to {current_version}") return downgrade diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.json b/frappe/integrations/doctype/ldap_settings/ldap_settings.json index d915ae2ad6..fd45a71538 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.json +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.json @@ -38,6 +38,7 @@ "local_ca_certs_file", "ldap_custom_settings_section", "ldap_group_objectclass", + "ldap_custom_group_search", "column_break_33", "ldap_group_member_attribute", "ldap_group_mappings_section", @@ -247,6 +248,12 @@ "fieldtype": "Data", "label": "Group Object Class" }, + { + "description": "string value, i.e. {0} or uid={0},ou=users,dc=example,dc=com", + "fieldname": "ldap_custom_group_search", + "fieldtype": "Data", + "label": "Custom Group Search" + }, { "description": "Requires any valid fdn path. i.e. ou=users,dc=example,dc=com", "fieldname": "ldap_search_path_user", diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index 7c9c64ba3c..cfd6e1e133 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -45,10 +45,14 @@ class LDAPSettings(Document): title=_("Misconfigured")) if self.ldap_directory_server.lower() == 'custom': - if not self.ldap_group_member_attribute or not self.ldap_group_mappings_section: - frappe.throw(_("Custom LDAP Directoy Selected, please ensure 'LDAP Group Member attribute' and 'LDAP Group Mappings' are entered"), + if not self.ldap_group_member_attribute or not self.ldap_group_objectclass: + frappe.throw(_("Custom LDAP Directoy Selected, please ensure 'LDAP Group Member attribute' and 'Group Object Class' are entered"), title=_("Misconfigured")) + if self.ldap_custom_group_search and "{0}" not in self.ldap_custom_group_search: + frappe.throw(_("Custom Group Search if filled needs to contain the user placeholder {0}, eg uid={0},ou=users,dc=example,dc=com"), + title=_("Misconfigured")) + else: frappe.throw(_("LDAP Search String must be enclosed in '()' and needs to contian the user placeholder {0}, eg sAMAccountName={0}")) @@ -209,7 +213,8 @@ class LDAPSettings(Document): ldap_object_class = self.ldap_group_objectclass ldap_group_members_attribute = self.ldap_group_member_attribute - user_search_str = getattr(user, self.ldap_username_field).value + ldap_custom_group_search = self.ldap_custom_group_search or "{0}" + user_search_str = ldap_custom_group_search.format(getattr(user, self.ldap_username_field).value) else: # NOTE: depreciate this else path diff --git a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.js b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.js index 1343faecc4..6915c5c582 100644 --- a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.js +++ b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.js @@ -3,6 +3,6 @@ frappe.ui.form.on('Razorpay Settings', { refresh: function(frm) { - + } }); \ No newline at end of file diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index 416d656d90..4242676d94 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -65,10 +65,7 @@ def get_latest_backup_file(with_files=False): return database, config -def get_file_size(file_path, unit): - if not unit: - unit = "MB" - +def get_file_size(file_path, unit='MB'): file_size = os.path.getsize(file_path) memory_size_unit_mapper = {"KB": 1, "MB": 2, "GB": 3, "TB": 4} @@ -99,7 +96,7 @@ def get_chunk_site(file_size): def validate_file_size(): frappe.flags.create_new_backup = True latest_file, site_config = get_latest_backup_file() - file_size = get_file_size(latest_file, unit="GB") + file_size = get_file_size(latest_file, unit="GB") if latest_file else 0 if file_size > 1: frappe.flags.create_new_backup = False diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json index b85056e3ef..bbd2e1199f 100644 --- a/frappe/integrations/workspace/integrations/integrations.json +++ b/frappe/integrations/workspace/integrations/integrations.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Backup\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Google Services\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Authentication\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Payments\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Backup\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Google Services\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Authentication\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Payments\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]", "creation": "2020-03-02 15:16:18.714190", "docstatus": 0, "doctype": "Workspace", @@ -260,7 +260,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:00.355268", + "modified": "2022-01-13 17:39:01.292154", "modified_by": "Administrator", "module": "Integrations", "name": "Integrations", @@ -269,7 +269,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 15, + "sequence_id": 15.0, "shortcuts": [], "title": "Integrations" } \ No newline at end of file diff --git a/frappe/migrate.py b/frappe/migrate.py index 6abc38796f..eabd0ff3e0 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -1,28 +1,54 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import json import os -import sys +from textwrap import dedent + import frappe -import frappe.translate -import frappe.modules.patch_handler import frappe.model.sync -from frappe.utils.fixtures import sync_fixtures +import frappe.modules.patch_handler +import frappe.translate +from frappe.cache_manager import clear_global_cache +from frappe.core.doctype.language.language import sync_languages +from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs +from frappe.database.schema import add_column +from frappe.desk.notifications import clear_notifications +from frappe.modules.patch_handler import PatchType +from frappe.modules.utils import sync_customizations +from frappe.search.website_search import build_index_for_all_routes from frappe.utils.connections import check_connection from frappe.utils.dashboard import sync_dashboards -from frappe.cache_manager import clear_global_cache -from frappe.desk.notifications import clear_notifications +from frappe.utils.fixtures import sync_fixtures from frappe.website.utils import clear_website_cache -from frappe.core.doctype.language.language import sync_languages -from frappe.modules.utils import sync_customizations -from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs -from frappe.search.website_search import build_index_for_all_routes -from frappe.database.schema import add_column + +BENCH_START_MESSAGE = dedent( + """ + Cannot run bench migrate without the services running. + If you are running bench in development mode, make sure that bench is running: + + $ bench start + + Otherwise, check the server logs and ensure that all the required services are running. + """ +) -def migrate(verbose=True, skip_failing=False, skip_search_index=False): - '''Migrate all apps to the current version, will: +def atomic(method): + def wrapper(*args, **kwargs): + try: + ret = method(*args, **kwargs) + frappe.db.commit() + return ret + except Exception: + frappe.db.rollback() + raise + + return wrapper + + +class SiteMigration: + """Migrate all apps to the current version, will: - run before migrate hooks - run patches - sync doctypes (schema) @@ -33,75 +59,117 @@ def migrate(verbose=True, skip_failing=False, skip_search_index=False): - sync languages - sync web pages (from /www) - run after migrate hooks - ''' + """ - service_status = check_connection(redis_services=["redis_cache"]) - if False in service_status.values(): - for service in service_status: - if not service_status.get(service, True): - print("{} service is not running.".format(service)) - print("""Cannot run bench migrate without the services running. -If you are running bench in development mode, make sure that bench is running: + def __init__(self, skip_failing: bool = False, skip_search_index: bool = False) -> None: + self.skip_failing = skip_failing + self.skip_search_index = skip_search_index -$ bench start - -Otherwise, check the server logs and ensure that all the required services are running.""") - sys.exit(1) - - touched_tables_file = frappe.get_site_path('touched_tables.json') - if os.path.exists(touched_tables_file): - os.remove(touched_tables_file) - - try: - add_column(doctype="DocType", column_name="migration_hash", fieldtype="Data") + def setUp(self): + """Complete setup required for site migration + """ frappe.flags.touched_tables = set() - frappe.flags.in_migrate = True - + self.touched_tables_file = frappe.get_site_path("touched_tables.json") + add_column(doctype="DocType", column_name="migration_hash", fieldtype="Data") clear_global_cache() - #run before_migrate hooks + if os.path.exists(self.touched_tables_file): + os.remove(self.touched_tables_file) + + frappe.flags.in_migrate = True + + def tearDown(self): + """Run operations that should be run post schema updation processes + This should be executed irrespective of outcome + """ + frappe.translate.clear_cache() + clear_website_cache() + clear_notifications() + + with open(self.touched_tables_file, "w") as f: + json.dump(list(frappe.flags.touched_tables), f, sort_keys=True, indent=4) + + if not self.skip_search_index: + print(f"Building search index for {frappe.local.site}") + build_index_for_all_routes() + + frappe.publish_realtime("version-update") + frappe.flags.touched_tables.clear() + frappe.flags.in_migrate = False + + @atomic + def pre_schema_updates(self): + """Executes `before_migrate` hooks + """ for app in frappe.get_installed_apps(): - for fn in frappe.get_hooks('before_migrate', app_name=app): + for fn in frappe.get_hooks("before_migrate", app_name=app): frappe.get_attr(fn)() - # run patches - frappe.modules.patch_handler.run_all(skip_failing) - - # sync + @atomic + def run_schema_updates(self): + """Run patches as defined in patches.txt, sync schema changes as defined in the {doctype}.json files + """ + frappe.modules.patch_handler.run_all(skip_failing=self.skip_failing, patch_type=PatchType.pre_model_sync) frappe.model.sync.sync_all() - frappe.translate.clear_cache() + frappe.modules.patch_handler.run_all(skip_failing=self.skip_failing, patch_type=PatchType.post_model_sync) + + @atomic + def post_schema_updates(self): + """Execute pending migration tasks post patches execution & schema sync + This includes: + * Sync `Scheduled Job Type` and scheduler events defined in hooks + * Sync fixtures & custom scripts + * Sync in-Desk Module Dashboards + * Sync customizations: Custom Fields, Property Setters, Custom Permissions + * Sync Frappe's internal language master + * Sync Portal Menu Items + * Sync Installed Applications Version History + * Execute `after_migrate` hooks + """ sync_jobs() sync_fixtures() sync_dashboards() sync_customizations() sync_languages() - frappe.get_doc('Portal Settings', 'Portal Settings').sync_menu() + frappe.get_single("Portal Settings").sync_menu() + frappe.get_single("Installed Applications").update_versions() - # syncs statics - clear_website_cache() - - # updating installed applications data - frappe.get_single('Installed Applications').update_versions() - - #run after_migrate hooks for app in frappe.get_installed_apps(): - for fn in frappe.get_hooks('after_migrate', app_name=app): + for fn in frappe.get_hooks("after_migrate", app_name=app): frappe.get_attr(fn)() - # build web_routes index - if not skip_search_index: - # Run this last as it updates the current session - print('Building search index for {}'.format(frappe.local.site)) - build_index_for_all_routes() + def required_services_running(self) -> bool: + """Returns True if all required services are running. Returns False and prints + instructions to stdout when required services are not available. + """ + service_status = check_connection(redis_services=["redis_cache"]) + are_services_running = all(service_status.values()) - frappe.db.commit() + if not are_services_running: + for service in service_status: + if not service_status.get(service, True): + print(f"Service {service} is not running.") + print(BENCH_START_MESSAGE) - clear_notifications() + return are_services_running - frappe.publish_realtime("version-update") - frappe.flags.in_migrate = False - finally: - with open(touched_tables_file, 'w') as f: - json.dump(list(frappe.flags.touched_tables), f, sort_keys=True, indent=4) - frappe.flags.touched_tables.clear() + def run(self, site: str): + """Run Migrate operation on site specified. This method initializes + and destroys connections to the site database. + """ + if not self.required_services_running(): + raise SystemExit(1) + + if site: + frappe.init(site=site) + frappe.connect() + + self.setUp() + try: + self.pre_schema_updates() + self.run_schema_updates() + finally: + self.post_schema_updates() + self.tearDown() + frappe.destroy() diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 66f0bdc997..bbd2e2c556 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -36,7 +36,8 @@ data_fieldtypes = ( 'Geolocation', 'Duration', 'Icon', - 'Phone' + 'Phone', + 'Autocomplete', ) attachment_fieldtypes = ( @@ -91,11 +92,14 @@ default_fields = ( 'creation', 'modified', 'modified_by', + 'docstatus', + 'idx' +) + +child_table_fields = ( 'parent', 'parentfield', - 'parenttype', - 'idx', - 'docstatus' + 'parenttype' ) optional_fields = ( diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 86ae326f58..74a10110d7 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -1,16 +1,16 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe import datetime + +import frappe from frappe import _ -from frappe.model import default_fields, table_fields +from frappe.model import child_table_fields, default_fields, display_fieldtypes, table_fields from frappe.model.naming import set_new_name from frappe.model.utils.link_count import notify_link_count from frappe.modules import load_doctype_module -from frappe.model import display_fieldtypes -from frappe.utils import (cint, flt, now, cstr, strip_html, - sanitize_html, sanitize_email, cast_fieldtype) +from frappe.utils import cast_fieldtype, cint, cstr, flt, now, sanitize_html, strip_html from frappe.utils.html_utils import unescape_html +from frappe.model.docstatus import DocStatus max_positive_value = { 'smallint': 2 ** 15, @@ -20,6 +20,7 @@ max_positive_value = { DOCTYPES_FOR_DOCTYPE = ('DocType', 'DocField', 'DocPerm', 'DocType Action', 'DocType Link') + def get_controller(doctype): """Returns the **class** object of the given DocType. For `custom` type, returns `frappe.model.document.Document`. @@ -32,13 +33,12 @@ def get_controller(doctype): module_name, custom = frappe.db.get_value( "DocType", doctype, ("module", "custom"), cache=True - ) or ["Core", False] + ) or ("Core", False) if custom: - if frappe.db.field_exists("DocType", "is_tree"): - is_tree = frappe.db.get_value("DocType", doctype, "is_tree", cache=True) - else: - is_tree = False + is_tree = frappe.db.get_value( + "DocType", doctype, "is_tree", ignore=True, cache=True + ) _class = NestedSet if is_tree else Document else: class_overrides = frappe.get_hooks('override_doctype_class') @@ -72,9 +72,12 @@ def get_controller(doctype): return site_controllers[doctype] class BaseDocument(object): - ignore_in_getter = ("doctype", "_meta", "meta", "_table_fields", "_valid_columns") + ignore_in_setter = ("doctype", "_meta", "meta", "_table_fields", "_valid_columns") def __init__(self, d): + if d.get("doctype"): + self.doctype = d["doctype"] + self.update(d) self.dont_update_if_missing = [] @@ -101,10 +104,10 @@ class BaseDocument(object): "balance": 42000 }) """ - # first set default field values of base document - for key in default_fields: - if key in d: - self.set(key, d[key]) + + # set name first, as it is used a reference in child document + if "name" in d: + self.name = d["name"] for key, value in d.items(): self.set(key, value) @@ -112,14 +115,18 @@ class BaseDocument(object): return self def update_if_missing(self, d): + """Set default values for fields without existing values""" if isinstance(d, BaseDocument): d = d.get_valid_dict() - if "doctype" in d: - self.set("doctype", d.get("doctype")) for key, value in d.items(): - # dont_update_if_missing is a list of fieldnames, for which, you don't want to set default value - if (self.get(key) is None) and (value is not None) and (key not in self.dont_update_if_missing): + if ( + value is not None + and self.get(key) is None + # dont_update_if_missing is a list of fieldnames + # for which you don't want to set default value + and key not in self.dont_update_if_missing + ): self.set(key, value) def get_db_value(self, key): @@ -139,10 +146,14 @@ class BaseDocument(object): else: value = self.__dict__.get(key, default) - if value is None and key not in self.ignore_in_getter \ - and key in (d.fieldname for d in self.meta.get_table_fields()): - self.set(key, []) - value = self.__dict__.get(key) + if value is None and key in ( + d.fieldname for d in self.meta.get_table_fields() + ): + value = [] + self.set(key, value) + + if limit and isinstance(value, (list, tuple)) and len(value) > limit: + value = value[:limit] return value else: @@ -152,6 +163,9 @@ class BaseDocument(object): return self.get(key, filters=filters, limit=1)[0] def set(self, key, value, as_value=False): + if key in self.ignore_in_setter: + return + if isinstance(value, list) and not as_value: self.__dict__[key] = [] self.extend(key, value) @@ -172,11 +186,12 @@ class BaseDocument(object): ... }) """ - if value==None: + if value is None: value={} if isinstance(value, (dict, BaseDocument)): if not self.__dict__.get(key): self.__dict__[key] = [] + value = self._init_child(value, key) self.__dict__[key].append(value) @@ -205,16 +220,19 @@ class BaseDocument(object): raise ValueError def remove(self, doc): - self.get(doc.parentfield).remove(doc) + # Usage: from the parent doc, pass the child table doc + # to remove that child doc from the child table, thus removing it from the parent doc + if doc.get("parentfield"): + self.get(doc.parentfield).remove(doc) def _init_child(self, value, key): if not self.doctype: return value + if not isinstance(value, BaseDocument): - if "doctype" not in value or value['doctype'] is None: - value["doctype"] = self.get_table_field_doctype(key) - if not value["doctype"]: - raise AttributeError(key) + value["doctype"] = self.get_table_field_doctype(key) + if not value["doctype"]: + raise AttributeError(key) value = get_controller(value["doctype"])(value) value.init_valid_columns() @@ -224,7 +242,7 @@ class BaseDocument(object): value.parentfield = key if value.docstatus is None: - value.docstatus = 0 + value.docstatus = DocStatus.draft() if not getattr(value, "idx", None): value.idx = len(self.get(key) or []) + 1 @@ -234,7 +252,7 @@ class BaseDocument(object): return value - def get_valid_dict(self, sanitize=True, convert_dates_to_str=False, ignore_nulls = False): + def get_valid_dict(self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False): d = frappe._dict() for fieldname in self.meta.get_valid_columns(): d[fieldname] = self.get(fieldname) @@ -244,7 +262,26 @@ class BaseDocument(object): continue df = self.meta.get_field(fieldname) - if df: + + if df and df.get("is_virtual"): + if ignore_virtual: + del d[fieldname] + continue + + from frappe.utils.safe_exec import get_safe_globals + + if d[fieldname] is None: + if df.get("options"): + d[fieldname] = frappe.safe_eval( + code=df.get("options"), + eval_globals=get_safe_globals(), + eval_locals={"doc": self}, + ) + else: + _val = getattr(self, fieldname, None) + if _val and not callable(_val): + d[fieldname] = _val + elif df: if df.fieldtype=="Check": d[fieldname] = 1 if cint(d[fieldname]) else 0 @@ -272,7 +309,7 @@ class BaseDocument(object): )): d[fieldname] = str(d[fieldname]) - if d[fieldname] == None and ignore_nulls: + if d[fieldname] is None and ignore_nulls: del d[fieldname] return d @@ -282,8 +319,11 @@ class BaseDocument(object): if key not in self.__dict__: self.__dict__[key] = None - if key in ("idx", "docstatus") and self.__dict__[key] is None: - self.__dict__[key] = 0 + if self.__dict__[key] is None: + if key == "docstatus": + self.docstatus = DocStatus.draft() + elif key == "idx": + self.__dict__[key] = 0 for key in self.get_valid_columns(): if key not in self.__dict__: @@ -304,12 +344,28 @@ class BaseDocument(object): def is_new(self): return self.get("__islocal") - def as_dict(self, no_nulls=False, no_default_fields=False, convert_dates_to_str=False): + @property + def docstatus(self): + return DocStatus(self.get("docstatus")) + + @docstatus.setter + def docstatus(self, value): + self.__dict__["docstatus"] = DocStatus(cint(value)) + + def as_dict(self, no_nulls=False, no_default_fields=False, convert_dates_to_str=False, no_child_table_fields=False): doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str) doc["doctype"] = self.doctype + for df in self.meta.get_table_fields(): children = self.get(df.fieldname) or [] - doc[df.fieldname] = [d.as_dict(convert_dates_to_str=convert_dates_to_str, no_nulls=no_nulls, no_default_fields=no_default_fields) for d in children] + doc[df.fieldname] = [ + d.as_dict( + convert_dates_to_str=convert_dates_to_str, + no_nulls=no_nulls, + no_default_fields=no_default_fields, + no_child_table_fields=no_child_table_fields + ) for d in children + ] if no_nulls: for k in list(doc): @@ -321,6 +377,11 @@ class BaseDocument(object): if k in default_fields: del doc[k] + if no_child_table_fields: + for k in list(doc): + if k in child_table_fields: + del doc[k] + for key in ("_user_tags", "__islocal", "__onload", "_liked_by", "__run_link_triggers", "__unsaved"): if self.get(key): doc[key] = self.get(key) @@ -342,26 +403,43 @@ class BaseDocument(object): fieldname = [df.fieldname for df in self.meta.get_table_fields() if df.options==doctype] return fieldname[0] if fieldname else None - def db_insert(self): - """INSERT the document (with valid columns) in the database.""" + def db_insert(self, ignore_if_duplicate=False): + """INSERT the document (with valid columns) in the database. + + args: + ignore_if_duplicate: ignore primary key collision + at database level (postgres) + in python (mariadb) + """ if not self.name: # name will be set by document class in most cases set_new_name(self) + conflict_handler = "" + # On postgres we can't implcitly ignore PK collision + # So instruct pg to ignore `name` field conflicts + if ignore_if_duplicate and frappe.db.db_type == "postgres": + conflict_handler = "on conflict (name) do nothing" + if not self.creation: self.creation = self.modified = now() self.created_by = self.modified_by = frappe.session.user # if doctype is "DocType", don't insert null values as we don't know who is valid yet - d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in DOCTYPES_FOR_DOCTYPE) + d = self.get_valid_dict( + convert_dates_to_str=True, + ignore_nulls=self.doctype in DOCTYPES_FOR_DOCTYPE, + ignore_virtual=True, + ) columns = list(d) try: frappe.db.sql("""INSERT INTO `tab{doctype}` ({columns}) - VALUES ({values})""".format( - doctype = self.doctype, - columns = ", ".join("`"+c+"`" for c in columns), - values = ", ".join(["%s"] * len(columns)) + VALUES ({values}) {conflict_handler}""".format( + doctype=self.doctype, + columns=", ".join("`"+c+"`" for c in columns), + values=", ".join(["%s"] * len(columns)), + conflict_handler=conflict_handler ), list(d.values())) except Exception as e: if frappe.db.is_primary_key_violation(e): @@ -374,8 +452,11 @@ class BaseDocument(object): self.db_insert() return - frappe.msgprint(_("{0} {1} already exists").format(self.doctype, frappe.bold(self.name)), title=_("Duplicate Name"), indicator="red") - raise frappe.DuplicateEntryError(self.doctype, self.name, e) + if not ignore_if_duplicate: + frappe.msgprint(_("{0} {1} already exists") + .format(self.doctype, frappe.bold(self.name)), + title=_("Duplicate Name"), indicator="red") + raise frappe.DuplicateEntryError(self.doctype, self.name, e) elif frappe.db.is_unique_key_violation(e): # unique constraint @@ -394,7 +475,7 @@ class BaseDocument(object): d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in DOCTYPES_FOR_DOCTYPE) # don't update name, as case might've been changed - name = d['name'] + name = cstr(d['name']) del d['name'] columns = list(d) @@ -492,7 +573,7 @@ class BaseDocument(object): self.set(df.fieldname, flt(self.get(df.fieldname))) if self.docstatus is not None: - self.docstatus = cint(self.docstatus) + self.docstatus = DocStatus(cint(self.docstatus)) def _get_missing_mandatory_fields(self): """Get mandatory fields that do not have any values""" @@ -500,12 +581,12 @@ class BaseDocument(object): if df.fieldtype in table_fields: return "{}: {}: {}".format(_("Error"), _("Data missing in table"), _(df.label)) - elif self.parentfield: + # check if parentfield exists (only applicable for child table doctype) + elif self.get("parentfield"): return "{}: {} {} #{}: {}: {}".format(_("Error"), frappe.bold(_(self.doctype)), _("Row"), self.idx, _("Value missing for"), _(df.label)) - else: - return _("Error: Value missing for {0}: {1}").format(_(df.parent), _(df.label)) + return _("Error: Value missing for {0}: {1}").format(_(df.parent), _(df.label)) missing = [] @@ -524,10 +605,11 @@ class BaseDocument(object): def get_invalid_links(self, is_submittable=False): """Returns list of invalid links and also updates fetch values if not set""" def get_msg(df, docname): - if self.parentfield: + # check if parentfield exists (only applicable for child table doctype) + if self.get("parentfield"): return "{} #{}: {}: {}".format(_("Row"), self.idx, _(df.label), docname) - else: - return "{}: {}".format(_(df.label), docname) + + return "{}: {}".format(_(df.label), docname) invalid_links = [] cancelled_links = [] @@ -581,7 +663,7 @@ class BaseDocument(object): setattr(self, df.fieldname, values.name) for _df in fields_to_fetch: - if self.is_new() or self.docstatus != 1 or _df.allow_on_submit: + if self.is_new() or not self.docstatus.is_submitted() or _df.allow_on_submit: self.set_fetch_from_value(doctype, _df, values) notify_link_count(doctype, docname) @@ -591,7 +673,7 @@ class BaseDocument(object): elif (df.fieldname != "amended_from" and (is_submittable or self.meta.is_submittable) and frappe.get_meta(doctype).is_submittable - and cint(frappe.db.get_value(doctype, docname, "docstatus"))==2): + and cint(frappe.db.get_value(doctype, docname, "docstatus")) == DocStatus.cancelled()): cancelled_links.append((df.fieldname, docname, get_msg(df, docname))) @@ -601,11 +683,8 @@ class BaseDocument(object): fetch_from_fieldname = df.fetch_from.split('.')[-1] value = values[fetch_from_fieldname] if df.fieldtype in ['Small Text', 'Text', 'Data']: - if fetch_from_fieldname in default_fields: - from frappe.model.meta import get_default_df - fetch_from_df = get_default_df(fetch_from_fieldname) - else: - fetch_from_df = frappe.get_meta(doctype).get_field(fetch_from_fieldname) + from frappe.model.meta import get_default_df + fetch_from_df = get_default_df(fetch_from_fieldname) or frappe.get_meta(doctype).get_field(fetch_from_fieldname) if not fetch_from_df: frappe.throw( @@ -709,7 +788,7 @@ class BaseDocument(object): type_map = frappe.db.type_map - for fieldname, value in self.get_valid_dict().items(): + for fieldname, value in self.get_valid_dict(ignore_virtual=True).items(): df = self.meta.get_field(fieldname) if not df or df.fieldtype == 'Check': @@ -744,9 +823,9 @@ class BaseDocument(object): def throw_length_exceeded_error(self, df, max_length, value): - if self.parentfield and self.idx: + # check if parentfield exists (only applicable for child table doctype) + if self.get("parentfield"): reference = _("{0}, Row {1}").format(_(self.doctype), self.idx) - else: reference = "{0} {1}".format(_(self.doctype), self.name) @@ -787,7 +866,7 @@ class BaseDocument(object): if frappe.flags.in_install: return - for fieldname, value in self.get_valid_dict().items(): + for fieldname, value in self.get_valid_dict(ignore_virtual=True).items(): if not value or not isinstance(value, str): continue @@ -809,8 +888,8 @@ class BaseDocument(object): or df.get("fieldtype") in ("Attach", "Attach Image", "Barcode", "Code") # cancelled and submit but not update after submit should be ignored - or self.docstatus==2 - or (self.docstatus==1 and not df.get("allow_on_submit"))): + or self.docstatus.is_cancelled() + or (self.docstatus.is_submitted() and not df.get("allow_on_submit"))): continue else: @@ -857,7 +936,7 @@ class BaseDocument(object): :param parentfield: If fieldname is in child table.""" from frappe.model.meta import get_field_precision - if parentfield and not isinstance(parentfield, str): + if parentfield and not isinstance(parentfield, str) and parentfield.get("parentfield"): parentfield = parentfield.parentfield cache_key = parentfield or "main" @@ -884,11 +963,11 @@ class BaseDocument(object): from frappe.utils.formatters import format_value df = self.meta.get_field(fieldname) - if not df and fieldname in default_fields: + if not df: from frappe.model.meta import get_default_df df = get_default_df(fieldname) - if not currency and df: + if df.fieldtype == "Currency" and not currency: currency = self.get(df.get("options")) if not frappe.db.exists('Currency', currency, cache=True): currency = None diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 51d53c69a5..16056d382a 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -164,7 +164,8 @@ class DatabaseQuery(object): # left join parent, child tables for child in self.tables[1:]: - args.tables += f" {self.join} {child} on ({child}.parent = {self.tables[0]}.name)" + parent_name = self.cast_name(f"{self.tables[0]}.name") + args.tables += f" {self.join} {child} on ({child}.parent = {parent_name})" if self.grouped_or_conditions: self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})") @@ -318,21 +319,60 @@ class DatabaseQuery(object): ] # add tables from fields if self.fields: - for field in self.fields: - if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field): + for i, field in enumerate(self.fields): + # add cast in locate/strpos + func_found = False + for func in sql_functions: + if func in field.lower(): + self.fields[i] = self.cast_name(field, func) + func_found = True + break + + if func_found or not ("tab" in field and "." in field): continue table_name = field.split('.')[0] if table_name.lower().startswith('group_concat('): table_name = table_name[13:] - if table_name.lower().startswith('ifnull('): - table_name = table_name[7:] if not table_name[0]=='`': table_name = f"`{table_name}`" - if not table_name in self.tables: + if table_name not in self.tables: self.append_table(table_name) + def cast_name(self, column: str, sql_function: str = "",) -> str: + if frappe.db.db_type == "postgres": + if "name" in column.lower(): + if "cast(" not in column.lower() or "::" not in column: + if not sql_function: + return f"cast({column} as varchar)" + + elif sql_function == "locate(": + return re.sub( + r'locate\(([^,]+),([^)]+)\)', + r'locate(\1, cast(\2 as varchar))', + column, + flags=re.IGNORECASE + ) + + elif sql_function == "strpos(": + return re.sub( + r'strpos\(([^,]+),([^)]+)\)', + r'strpos(cast(\1 as varchar), \2)', + column, + flags=re.IGNORECASE + ) + + elif sql_function == "ifnull(": + return re.sub( + r"ifnull\(([^,]+)", + r"ifnull(cast(\1 as varchar)", + column, + flags=re.IGNORECASE + ) + + return column + def append_table(self, table_name): self.tables.append(table_name) doctype = table_name[4:-1] @@ -423,24 +463,27 @@ class DatabaseQuery(object): ifnull(`tabDocType`.`fieldname`, fallback) operator "value" """ + # TODO: refactor + from frappe.boot import get_additional_filters_from_hooks additional_filters_config = get_additional_filters_from_hooks() f = get_filter(self.doctype, f, additional_filters_config) tname = ('`tab' + f.doctype + '`') - if not tname in self.tables: + if tname not in self.tables: self.append_table(tname) if 'ifnull(' in f.fieldname: - column_name = f.fieldname + column_name = self.cast_name(f.fieldname, "ifnull(") else: - column_name = f"{tname}.{f.fieldname}" - - can_be_null = True + column_name = self.cast_name(f"{tname}.{f.fieldname}") if f.operator.lower() in additional_filters_config: f.update(get_additional_filter_field(additional_filters_config, f, f.value)) + meta = frappe.get_meta(f.doctype) + can_be_null = True + # prepare in condition if f.operator.lower() in ('ancestors of', 'descendants of', 'not ancestors of', 'not descendants of'): values = f.value or '' @@ -449,12 +492,8 @@ class DatabaseQuery(object): # if not isinstance(values, (list, tuple)): # values = values.split(",") - ref_doctype = f.doctype - - if frappe.get_meta(f.doctype).get_field(f.fieldname) is not None : - ref_doctype = frappe.get_meta(f.doctype).get_field(f.fieldname).options - - result=[] + field = meta.get_field(f.fieldname) + ref_doctype = field.options if field else f.doctype lft, rgt = '', '' if f.value: @@ -474,29 +513,30 @@ class DatabaseQuery(object): }, order_by='`lft` DESC') fallback = "''" - value = [frappe.db.escape((v.name or '').strip(), percent=False) for v in result] + value = [frappe.db.escape((cstr(v.name) or '').strip(), percent=False) for v in result] if len(value): value = f"({', '.join(value)})" else: value = "('')" + # changing operator to IN as the above code fetches all the parent / child values and convert into tuple # which can be directly used with IN operator to query. f.operator = 'not in' if f.operator.lower() in ('not ancestors of', 'not descendants of') else 'in' - elif f.operator.lower() in ('in', 'not in'): values = f.value or '' if isinstance(values, str): values = values.split(",") fallback = "''" - value = [frappe.db.escape((v or '').strip(), percent=False) for v in values] + value = [frappe.db.escape((cstr(v) or '').strip(), percent=False) for v in values] if len(value): value = f"({', '.join(value)})" else: value = "('')" + else: - df = frappe.get_meta(f.doctype).get("fields", {"fieldname": f.fieldname}) + df = meta.get("fields", {"fieldname": f.fieldname}) df = df[0] if df else None if df and df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"): @@ -513,7 +553,8 @@ class DatabaseQuery(object): fallback = "'0001-01-01 00:00:00'" elif f.operator.lower() in ('between') and \ - (f.fieldname in ('creation', 'modified') or (df and (df.fieldtype=="Date" or df.fieldtype=="Datetime"))): + (f.fieldname in ('creation', 'modified') or + (df and (df.fieldtype=="Date" or df.fieldtype=="Datetime"))): value = get_between_date_filter(f.value, df) fallback = "'0001-01-01 00:00:00'" @@ -528,7 +569,7 @@ class DatabaseQuery(object): fallback = "''" can_be_null = True - if 'ifnull' not in column_name: + if 'ifnull' not in column_name.lower(): column_name = f'ifnull({column_name}, {fallback})' elif df and df.fieldtype=="Date": @@ -545,7 +586,7 @@ class DatabaseQuery(object): elif f.operator.lower() in ("like", "not like") or (isinstance(f.value, str) and (not df or df.fieldtype not in ["Float", "Int", "Currency", "Percent", "Check"])): - value = "" if f.value==None else f.value + value = "" if f.value is None else f.value fallback = "''" if f.operator.lower() in ("like", "not like") and isinstance(value, str): @@ -570,7 +611,7 @@ class DatabaseQuery(object): value = f"{tname}.{quote}{f.value.name}{quote}" # escape value - elif isinstance(value, str) and not f.operator.lower() == 'between': + elif isinstance(value, str) and f.operator.lower() != 'between': value = f"{frappe.db.escape(value, percent=False)}" if ( diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 2fddcf9e33..f055cd79d0 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -115,7 +115,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa # All the linked docs should be checked beforehand frappe.enqueue('frappe.model.delete_doc.delete_dynamic_links', doctype=doc.doctype, name=doc.name, - is_async=False if frappe.flags.in_test else True) + now=frappe.flags.in_test) # clear cache for Document doc.clear_cache() @@ -158,7 +158,7 @@ def update_naming_series(doc): and getattr(doc, "naming_series", None): revert_series_if_last(doc.naming_series, doc.name, doc) - elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash"): + elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash", "autoincrement"): revert_series_if_last(doc.meta.autoname, doc.name, doc) def delete_from_table(doctype, name, ignore_doctypes, doc): @@ -212,7 +212,7 @@ def check_permission_and_not_submitted(doc): .format(doc.doctype, doc.name), raise_exception=frappe.PermissionError) # check if submitted - if doc.docstatus == 1: + if doc.docstatus.is_submitted(): frappe.msgprint(_("{0} {1}: Submitted Record cannot be deleted. You must {2} Cancel {3} it first.").format(_(doc.doctype), doc.name, "", ""), raise_exception=True) @@ -222,32 +222,35 @@ def check_if_doc_is_linked(doc, method="Delete"): """ from frappe.model.rename_doc import get_link_fields link_fields = get_link_fields(doc.doctype) - link_fields = [[lf['parent'], lf['fieldname'], lf['issingle']] for lf in link_fields] + ignore_linked_doctypes = doc.get('ignore_linked_doctypes') or [] + + for lf in link_fields: + link_dt, link_field, issingle = lf['parent'], lf['fieldname'], lf['issingle'] - for link_dt, link_field, issingle in link_fields: if not issingle: - for item in frappe.db.get_values(link_dt, {link_field:doc.name}, - ["name", "parent", "parenttype", "docstatus"], as_dict=True): - linked_doctype = item.parenttype if item.parent else link_dt + fields = ["name", "docstatus"] + if frappe.get_meta(link_dt).istable: + fields.extend(["parent", "parenttype"]) - ignore_linked_doctypes = doc.get('ignore_linked_doctypes') or [] + for item in frappe.db.get_values(link_dt, {link_field:doc.name}, fields , as_dict=True): + # available only in child table cases + item_parent = getattr(item, "parent", None) + linked_doctype = item.parenttype if item_parent else link_dt if linked_doctype in doctypes_to_skip or (linked_doctype in ignore_linked_doctypes and method == 'Cancel'): # don't check for communication and todo! continue - if not item: - continue - elif method != "Delete" and (method != "Cancel" or item.docstatus != 1): + if method != "Delete" and (method != "Cancel" or item.docstatus != 1): # don't raise exception if not # linked to a non-cancelled doc when deleting or to a submitted doc when cancelling continue - elif link_dt == doc.doctype and (item.parent or item.name) == doc.name: + elif link_dt == doc.doctype and (item_parent or item.name) == doc.name: # don't raise exception if not # linked to same item or doc having same name as the item continue else: - reference_docname = item.parent or item.name + reference_docname = item_parent or item.name raise_link_exists_exception(doc, linked_doctype, reference_docname) else: diff --git a/frappe/model/docstatus.py b/frappe/model/docstatus.py new file mode 100644 index 0000000000..01aab1e491 --- /dev/null +++ b/frappe/model/docstatus.py @@ -0,0 +1,25 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +class DocStatus(int): + def is_draft(self): + return self == self.draft() + + def is_submitted(self): + return self == self.submitted() + + def is_cancelled(self): + return self == self.cancelled() + + @classmethod + def draft(cls): + return cls(0) + + @classmethod + def submitted(cls): + return cls(1) + + @classmethod + def cancelled(cls): + return cls(2) diff --git a/frappe/model/document.py b/frappe/model/document.py index e055a12950..3c38ff3442 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1,13 +1,16 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe +import hashlib +import json import time +from werkzeug.exceptions import NotFound + +import frappe from frappe import _, msgprint, is_whitelisted from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff from frappe.model.base_document import BaseDocument, get_controller -from frappe.model.naming import set_new_name, gen_new_name_for_cancelled_doc -from werkzeug.exceptions import NotFound, Forbidden -import hashlib, json +from frappe.model.naming import set_new_name, validate_name +from frappe.model.docstatus import DocStatus from frappe.model import optional_fields, table_fields from frappe.model.workflow import validate_workflow from frappe.model.workflow import set_workflow_state_on_action @@ -17,6 +20,7 @@ from frappe.desk.form.document_follow import follow_document from frappe.core.doctype.server_script.server_script_utils import run_server_script_for_doc_event from frappe.utils.data import get_absolute_url + # once_only validation # methods @@ -211,13 +215,13 @@ class Document(BaseDocument): self.flags.notifications_executed = [] - if ignore_permissions!=None: + if ignore_permissions is not None: self.flags.ignore_permissions = ignore_permissions - if ignore_links!=None: + if ignore_links is not None: self.flags.ignore_links = ignore_links - if ignore_mandatory!=None: + if ignore_mandatory is not None: self.flags.ignore_mandatory = ignore_mandatory self.set("__islocal", True) @@ -245,11 +249,7 @@ class Document(BaseDocument): if getattr(self.meta, "issingle", 0): self.update_single(self.get_valid_dict()) else: - try: - self.db_insert() - except frappe.DuplicateEntryError as e: - if not ignore_if_duplicate: - raise e + self.db_insert(ignore_if_duplicate=ignore_if_duplicate) # children for d in self.get_all_children(): @@ -297,7 +297,7 @@ class Document(BaseDocument): self.flags.notifications_executed = [] - if ignore_permissions!=None: + if ignore_permissions is not None: self.flags.ignore_permissions = ignore_permissions self.flags.ignore_version = frappe.flags.in_test if ignore_version is None else ignore_version @@ -307,9 +307,6 @@ class Document(BaseDocument): self.check_permission("write", "save") - if self.docstatus == 2: - self._rename_doc_on_cancel() - self.set_user_and_timestamp() self.set_docstatus() self.check_if_latest() @@ -415,12 +412,12 @@ class Document(BaseDocument): # If autoname has set as Prompt (name) if self.get("__newname"): - self.name = self.get("__newname") + self.name = validate_name(self.doctype, self.get("__newname")) self.flags.name_set = True return if set_name: - self.name = set_name + self.name = validate_name(self.doctype, set_name) else: set_new_name(self) @@ -441,7 +438,7 @@ class Document(BaseDocument): values = self.as_dict() # format values for key, value in values.items(): - if value==None: + if value is None: values[key] = "" return values @@ -474,7 +471,7 @@ class Document(BaseDocument): # We'd probably want the creation and owner to be set via API # or Data import at some point, that'd have to be handled here - if self.is_new(): + if self.is_new() and not (frappe.flags.in_install or frappe.flags.in_patch or frappe.flags.in_migrate): self.creation = self.modified self.owner = self.modified_by @@ -489,8 +486,8 @@ class Document(BaseDocument): frappe.flags.currently_saving.append((self.doctype, self.name)) def set_docstatus(self): - if self.docstatus==None: - self.docstatus=0 + if self.docstatus is None: + self.docstatus = DocStatus.draft() for d in self.get_all_children(): d.docstatus = self.docstatus @@ -526,7 +523,7 @@ class Document(BaseDocument): def _validate_non_negative(self): def get_msg(df): - if self.parentfield: + if self.get("parentfield"): return "{} {} #{}: {} {}".format(frappe.bold(_(self.doctype)), _("Row"), self.idx, _("Value cannot be negative for"), frappe.bold(_(df.label))) else: @@ -720,6 +717,7 @@ class Document(BaseDocument): else: tmp = frappe.db.sql("""select modified, docstatus from `tab{0}` where name = %s for update""".format(self.doctype), self.name, as_dict=True) + if not tmp: frappe.throw(_("Record does not exist")) else: @@ -740,7 +738,7 @@ class Document(BaseDocument): else: self.check_docstatus_transition(0) - def check_docstatus_transition(self, docstatus): + def check_docstatus_transition(self, to_docstatus): """Ensures valid `docstatus` transition. Valid transitions are (number in brackets is `docstatus`): @@ -751,31 +749,32 @@ class Document(BaseDocument): """ if not self.docstatus: - self.docstatus = 0 - if docstatus==0: - if self.docstatus==0: + self.docstatus = DocStatus.draft() + + if to_docstatus == DocStatus.draft(): + if self.docstatus.is_draft(): self._action = "save" - elif self.docstatus==1: + elif self.docstatus.is_submitted(): self._action = "submit" self.check_permission("submit") - elif self.docstatus==2: + elif self.docstatus.is_cancelled(): raise frappe.DocstatusTransitionError(_("Cannot change docstatus from 0 (Draft) to 2 (Cancelled)")) else: raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus) - elif docstatus==1: - if self.docstatus==1: + elif to_docstatus == DocStatus.submitted(): + if self.docstatus.is_submitted(): self._action = "update_after_submit" self.check_permission("submit") - elif self.docstatus==2: + elif self.docstatus.is_cancelled(): self._action = "cancel" self.check_permission("cancel") - elif self.docstatus==0: + elif self.docstatus.is_draft(): raise frappe.DocstatusTransitionError(_("Cannot change docstatus from 1 (Submitted) to 0 (Draft)")) else: raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus) - elif docstatus==2: + elif to_docstatus == DocStatus.cancelled(): raise frappe.ValidationError(_("Cannot edit cancelled document")) def set_parent_in_children(self): @@ -861,14 +860,14 @@ class Document(BaseDocument): def run_method(self, method, *args, **kwargs): """run standard triggers, plus those in hooks""" - if "flags" in kwargs: - del kwargs["flags"] - if hasattr(self, method) and hasattr(getattr(self, method), "__call__"): - fn = lambda self, *args, **kwargs: getattr(self, method)(*args, **kwargs) - else: - # hack! to run hooks even if method does not exist - fn = lambda self, *args, **kwargs: None + def fn(self, *args, **kwargs): + method_object = getattr(self, method, None) + + # Cannot have a field with same name as method + # If method found in __dict__, expect it to be callable + if method in self.__dict__ or callable(method_object): + return method_object(*args, **kwargs) fn.__name__ = str(method) out = Document.hook(fn)(self, *args, **kwargs) @@ -887,14 +886,14 @@ class Document(BaseDocument): if (frappe.flags.in_import and frappe.flags.mute_emails) or frappe.flags.in_patch or frappe.flags.in_install: return - if self.flags.notifications_executed==None: + if self.flags.notifications_executed is None: self.flags.notifications_executed = [] from frappe.email.doctype.notification.notification import evaluate_alert - if self.flags.notifications == None: + if self.flags.notifications is None: alerts = frappe.cache().hget('notifications', self.doctype) - if alerts==None: + if alerts is None: alerts = frappe.get_all('Notification', fields=['name', 'event', 'method'], filters={'enabled': 1, 'document_type': self.doctype}) frappe.cache().hset('notifications', self.doctype, alerts) @@ -929,14 +928,14 @@ class Document(BaseDocument): @whitelist.__func__ def _submit(self): """Submit the document. Sets `docstatus` = 1, then saves.""" - self.docstatus = 1 + self.docstatus = DocStatus.submitted() return self.save() @whitelist.__func__ def _cancel(self): """Cancel the document. Sets `docstatus` = 2, then saves. """ - self.docstatus = 2 + self.docstatus = DocStatus.cancelled() return self.save() @whitelist.__func__ @@ -954,7 +953,7 @@ class Document(BaseDocument): frappe.delete_doc(self.doctype, self.name, ignore_permissions = ignore_permissions, flags=self.flags) def run_before_save_methods(self): - """Run standard methods before `INSERT` or `UPDATE`. Standard Methods are: + """Run standard methods before `INSERT` or `UPDATE`. Standard Methods are: - `validate`, `before_save` for **Save**. - `validate`, `before_submit` for **Submit**. @@ -1004,8 +1003,6 @@ class Document(BaseDocument): - `on_cancel` for **Cancel** - `update_after_submit` for **Update after Submit**""" - doc_before_save = self.get_doc_before_save() - if self._action=="save": self.run_method("on_update") elif self._action=="submit": @@ -1155,7 +1152,7 @@ class Document(BaseDocument): for f in hooks: add_to_return_value(self, f(self, method, *args, **kwargs)) - return self._return_value + return self.__dict__.pop("_return_value", None) return runner @@ -1199,7 +1196,7 @@ class Document(BaseDocument): if not frappe.compare(val1, condition, val2): label = doc.meta.get_label(fieldname) condition_str = error_condition_map.get(condition, condition) - if doc.parentfield: + if doc.get("parentfield"): msg = _("Incorrect value in row {0}: {1} must be {2} {3}").format(doc.idx, label, condition_str, val2) else: msg = _("Incorrect value: {0} must be {1} {2}").format(label, condition_str, val2) @@ -1223,7 +1220,7 @@ class Document(BaseDocument): doc.meta.get("fields", {"fieldtype": ["in", ["Currency", "Float", "Percent"]]})) for fieldname in fieldnames: - doc.set(fieldname, flt(doc.get(fieldname), self.precision(fieldname, doc.parentfield))) + doc.set(fieldname, flt(doc.get(fieldname), self.precision(fieldname, doc.get("parentfield")))) def get_url(self): """Returns Desk URL for this document.""" @@ -1371,19 +1368,16 @@ class Document(BaseDocument): from frappe.desk.doctype.tag.tag import DocTags return DocTags(self.doctype).get_tags(self.name).split(",")[1:] - def _rename_doc_on_cancel(self): - new_name = gen_new_name_for_cancelled_doc(self) - frappe.rename_doc(self.doctype, self.name, new_name, force=True, show_alert=False) - self.name = new_name - def __repr__(self): name = self.name or "unsaved" doctype = self.__class__.__name__ docstatus = f" docstatus={self.docstatus}" if self.docstatus else "" - parent = f" parent={self.parent}" if self.parent else "" + repr_str = f"<{doctype}: {name}{docstatus}" - return f"<{doctype}: {name}{docstatus}{parent}>" + if not hasattr(self, "parent"): + return repr_str + ">" + return f"{repr_str} parent={self.parent}>" def __str__(self): name = self.name or "unsaved" diff --git a/frappe/model/dynamic_links.py b/frappe/model/dynamic_links.py index 7311b39b30..03f616ef60 100644 --- a/frappe/model/dynamic_links.py +++ b/frappe/model/dynamic_links.py @@ -32,7 +32,7 @@ def get_dynamic_link_map(for_delete=False): Note: Will not map single doctypes ''' - if getattr(frappe.local, 'dynamic_link_map', None)==None or frappe.flags.in_test: + if getattr(frappe.local, 'dynamic_link_map', None) is None or frappe.flags.in_test: # Build from scratch dynamic_link_map = {} for df in get_dynamic_links(): diff --git a/frappe/model/mapper.py b/frappe/model/mapper.py index bde4fb6d73..f40a43bb73 100644 --- a/frappe/model/mapper.py +++ b/frappe/model/mapper.py @@ -4,7 +4,7 @@ import json import frappe from frappe import _ -from frappe.model import default_fields, table_fields +from frappe.model import default_fields, table_fields, child_table_fields from frappe.utils import cstr @@ -149,6 +149,7 @@ def map_fields(source_doc, target_doc, table_map, source_parent): no_copy_fields = set([d.fieldname for d in source_doc.meta.get("fields") if (d.no_copy==1 or d.fieldtype in table_fields)] + [d.fieldname for d in target_doc.meta.get("fields") if (d.no_copy==1 or d.fieldtype in table_fields)] + list(default_fields) + + list(child_table_fields) + list(table_map.get("field_no_map", []))) for df in target_doc.meta.get("fields"): diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 6505f73b3a..61707b61c3 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -14,16 +14,28 @@ Example: ''' +import json +import os from datetime import datetime + import click -import frappe, json, os -from frappe.utils import cstr, cint, cast -from frappe.model import default_fields, no_value_fields, optional_fields, data_fieldtypes, table_fields -from frappe.model.document import Document -from frappe.model.base_document import BaseDocument -from frappe.modules import load_doctype_module -from frappe.model.workflow import get_workflow_name + +import frappe from frappe import _ +from frappe.model import ( + child_table_fields, + data_fieldtypes, + default_fields, + no_value_fields, + optional_fields, + table_fields, +) +from frappe.model.base_document import BaseDocument +from frappe.model.document import Document +from frappe.model.workflow import get_workflow_name +from frappe.modules import load_doctype_module +from frappe.utils import cast, cint, cstr + def get_meta(doctype, cached=True): if cached: @@ -194,6 +206,8 @@ class Meta(Document): else: self._valid_columns = self.default_fields + \ [df.fieldname for df in self.get("fields") if df.fieldtype in data_fieldtypes] + if self.istable: + self._valid_columns += list(child_table_fields) return self._valid_columns @@ -445,9 +459,16 @@ class Meta(Document): self.permissions = [Document(d) for d in custom_perms] def get_fieldnames_with_value(self, with_field_meta=False): - return [df if with_field_meta else df.fieldname \ - for df in self.fields if df.fieldtype not in no_value_fields] + def is_value_field(docfield): + return not ( + docfield.get("is_virtual") + or docfield.fieldtype in no_value_fields + ) + if with_field_meta: + return [df for df in self.fields if is_value_field(df)] + + return [df.fieldname for df in self.fields if is_value_field(df)] def get_fields_to_check_permissions(self, user_permission_doctypes): fields = self.get("fields", { @@ -523,7 +544,7 @@ class Meta(Document): '''add `links` child table in standard link dashboard format''' dashboard_links = [] - if hasattr(self, 'links') and self.links: + if getattr(self, 'links', None): dashboard_links.extend(self.links) if not data.transactions: @@ -547,7 +568,7 @@ class Meta(Document): # For internal links parent doctype will be the key doctype = link.parent_doctype or link.link_doctype # group found - if link.group and group.label == link.group: + if link.group and _(group.label) == _(link.group): if doctype not in group.get('items'): group.get('items').append(doctype) link.added = True @@ -628,9 +649,9 @@ def get_field_currency(df, doc=None): frappe.local.field_currency = frappe._dict() if not (frappe.local.field_currency.get((doc.doctype, doc.name), {}).get(df.fieldname) or - (doc.parent and frappe.local.field_currency.get((doc.doctype, doc.parent), {}).get(df.fieldname))): + (doc.get("parent") and frappe.local.field_currency.get((doc.doctype, doc.parent), {}).get(df.fieldname))): - ref_docname = doc.parent or doc.name + ref_docname = doc.get("parent") or doc.name if ":" in cstr(df.get("options")): split_opts = df.get("options").split(":") @@ -638,7 +659,7 @@ def get_field_currency(df, doc=None): currency = frappe.get_cached_value(split_opts[0], doc.get(split_opts[1]), split_opts[2]) else: currency = doc.get(df.get("options")) - if doc.parent: + if doc.get("parenttype"): if currency: ref_docname = doc.name else: @@ -651,7 +672,7 @@ def get_field_currency(df, doc=None): .setdefault(df.fieldname, currency) return frappe.local.field_currency.get((doc.doctype, doc.name), {}).get(df.fieldname) or \ - (doc.parent and frappe.local.field_currency.get((doc.doctype, doc.parent), {}).get(df.fieldname)) + (doc.get("parent") and frappe.local.field_currency.get((doc.doctype, doc.parent), {}).get(df.fieldname)) def get_field_precision(df, doc=None, currency=None): """get precision based on DocField options and fieldvalue in doc""" @@ -672,19 +693,25 @@ def get_field_precision(df, doc=None, currency=None): def get_default_df(fieldname): - if fieldname in default_fields: + if fieldname in (default_fields + child_table_fields): if fieldname in ("creation", "modified"): return frappe._dict( fieldname = fieldname, fieldtype = "Datetime" ) - else: + elif fieldname in ("idx", "docstatus"): return frappe._dict( fieldname = fieldname, - fieldtype = "Data" + fieldtype = "Int" ) + return frappe._dict( + fieldname = fieldname, + fieldtype = "Data" + ) + + def trim_tables(doctype=None, dry_run=False, quiet=False): """ Removes database fields that don't exist in the doctype (json or custom field). This may be needed @@ -716,7 +743,7 @@ def trim_tables(doctype=None, dry_run=False, quiet=False): def trim_table(doctype, dry_run=True): frappe.cache().hdel('table_columns', f"tab{doctype}") - ignore_fields = default_fields + optional_fields + ignore_fields = default_fields + optional_fields + child_table_fields columns = frappe.db.get_table_columns(doctype) fields = frappe.get_meta(doctype, cached=False).get_fieldnames_with_value() is_internal = lambda f: f not in ignore_fields and not f.startswith("_") diff --git a/frappe/model/naming.py b/frappe/model/naming.py index f3d68f3715..013e5a19db 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -1,24 +1,18 @@ -"""utilities to generate a document name based on various rules defined. - -NOTE: -Till version 13, whenever a submittable document is amended it's name is set to orig_name-X, -where X is a counter and it increments when amended again and so on. - -From Version 14, The naming pattern is changed in a way that amended documents will -have the original name `orig_name` instead of `orig_name-X`. To make this happen -the cancelled document naming pattern is changed to 'orig_name-CANC-X'. -""" - # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from typing import Optional, TYPE_CHECKING, Union import frappe from frappe import _ +from frappe.database.sequence import get_next_val, set_next_val from frappe.utils import now_datetime, cint, cstr import re from frappe.model import log_types from frappe.query_builder import DocType +if TYPE_CHECKING: + from frappe.model.meta import Meta + def set_new_name(doc): """ @@ -34,13 +28,18 @@ def set_new_name(doc): doc.run_method("before_naming") - autoname = frappe.get_meta(doc.doctype).autoname or "" + meta = frappe.get_meta(doc.doctype) + autoname = meta.autoname or "" if autoname.lower() != "prompt" and not frappe.flags.in_import: doc.name = None + if is_autoincremented(doc.doctype, meta): + doc.name = get_next_val(doc.doctype) + return + if getattr(doc, "amended_from", None): - doc.name = _get_amended_name(doc) + _set_amended_name(doc) return elif getattr(doc.meta, "issingle", False): @@ -74,9 +73,37 @@ def set_new_name(doc): doc.name = validate_name( doc.doctype, doc.name, - frappe.get_meta(doc.doctype).get_field("name_case") + meta.get_field("name_case") ) +def is_autoincremented(doctype: str, meta: "Meta" = None): + if doctype in log_types: + if frappe.local.autoincremented_status_map.get(frappe.local.site) is None or \ + frappe.local.autoincremented_status_map[frappe.local.site] == -1: + if frappe.db.sql( + f"""select data_type FROM information_schema.columns + where column_name = 'name' and table_name = 'tab{doctype}'""" + )[0][0] == "bigint": + frappe.local.autoincremented_status_map[frappe.local.site] = 1 + return True + else: + frappe.local.autoincremented_status_map[frappe.local.site] = 0 + + elif frappe.local.autoincremented_status_map[frappe.local.site]: + return True + + else: + if not meta: + meta = frappe.get_meta(doctype) + + if getattr(meta, "issingle", False): + return False + + if meta.autoname == "autoincrement": + return True + + return False + def set_name_from_naming_options(autoname, doc): """ Get a name based on the autoname field option @@ -256,18 +283,6 @@ def revert_series_if_last(key, name, doc=None): * prefix = #### and hashes = 2021 (hash doesn't exist) * will search hash in key then accordingly get prefix = "" """ - if hasattr(doc, 'amended_from'): - # Do not revert the series if the document is amended. - if doc.amended_from: - return - - # Get document name by parsing incase of fist cancelled document - if doc.docstatus == 2 and not doc.amended_from: - if doc.name.endswith('-CANC'): - name, _ = NameParser.parse_docname(doc.name, sep='-CANC') - else: - name, _ = NameParser.parse_docname(doc.name, sep='-CANC-') - if ".#" in key: prefix, hashes = key.rsplit(".", 1) if "#" not in hashes: @@ -306,9 +321,19 @@ def get_default_naming_series(doctype): return None -def validate_name(doctype, name, case=None, merge=False): +def validate_name(doctype: str, name: Union[int, str], case: Optional[str] = None): if not name: frappe.throw(_("No Name Specified for {0}").format(doctype)) + + if isinstance(name, int): + if is_autoincremented(doctype): + # this will set the sequence val to be the provided name and set it to be used + # so that the sequence will start from the next val of the setted val(name) + set_next_val(doctype, name, is_val_used=True) + return name + + frappe.throw(_("Invalid name type (integer) for varchar name column"), frappe.NameError) + if name.startswith("New "+doctype): frappe.throw(_("There were some errors setting the name, please contact the administrator"), frappe.NameError) if case == "Title Case": @@ -356,9 +381,16 @@ def append_number_if_name_exists(doctype, value, fieldname="name", separator="-" return value -def _get_amended_name(doc): - name, _ = NameParser(doc).parse_amended_from() - return name +def _set_amended_name(doc): + am_id = 1 + am_prefix = doc.amended_from + if frappe.db.get_value(doc.doctype, doc.amended_from, "amended_from"): + am_id = cint(doc.amended_from.split("-")[-1]) + 1 + am_prefix = "-".join(doc.amended_from.split("-")[:-1]) # except the last hyphen + + doc.name = am_prefix + "-" + str(am_id) + return doc.name + def _field_autoname(autoname, doc, skip_slicing=None): """ @@ -399,83 +431,3 @@ def _format_autoname(autoname, doc): name = re.sub(r"(\{[\w | #]+\})", get_param_value_for_match, autoname_value) return name - -class NameParser: - """Parse document name and return parts of it. - - NOTE: It handles cancellend and amended doc parsing for now. It can be expanded. - """ - def __init__(self, doc): - self.doc = doc - - def parse_amended_from(self): - """ - Cancelled document naming will be in one of these formats - - * original_name-X-CANC - This is introduced to migrate old style naming to new style - * original_name-CANC - This is introduced to migrate old style naming to new style - * original_name-CANC-X - This is the new style naming - - New style naming: In new style naming amended documents will have original name. That says, - when a document gets cancelled we need rename the document by adding `-CANC-X` to the end - so that amended documents can use the original name. - - Old style naming: cancelled documents stay with original name and when amended, amended one - gets a new name as `original_name-X`. To bring new style naming we had to change the existing - cancelled document names and that is done by adding `-CANC` to cancelled documents through patch. - """ - if not getattr(self.doc, 'amended_from', None): - return (None, None) - - # Handle old style cancelled documents (original_name-X-CANC, original_name-CANC) - if self.doc.amended_from.endswith('-CANC'): - name, _ = self.parse_docname(self.doc.amended_from, '-CANC') - amended_from_doc = frappe.get_all( - self.doc.doctype, - filters = {'name': self.doc.amended_from}, - fields = ['amended_from'], - limit=1) - - # Handle format original_name-X-CANC. - if amended_from_doc and amended_from_doc[0].amended_from: - return self.parse_docname(name, '-') - return name, None - - # Handle new style cancelled documents - return self.parse_docname(self.doc.amended_from, '-CANC-') - - @classmethod - def parse_docname(cls, name, sep='-'): - split_list = name.rsplit(sep, 1) - - if len(split_list) == 1: - return (name, None) - return (split_list[0], split_list[1]) - -def get_cancelled_doc_latest_counter(tname, docname): - """Get the latest counter used for cancelled docs of given docname. - """ - name_prefix = f'{docname}-CANC-' - - rows = frappe.db.sql(""" - select - name - from `tab{tname}` - where - name like %(name_prefix)s and docstatus=2 - """.format(tname=tname), {'name_prefix': name_prefix+'%'}, as_dict=1) - - if not rows: - return -1 - return max([int(row.name.replace(name_prefix, '') or -1) for row in rows]) - -def gen_new_name_for_cancelled_doc(doc): - """Generate a new name for cancelled document. - """ - if getattr(doc, "amended_from", None): - name, _ = NameParser(doc).parse_amended_from() - else: - name = doc.name - - counter = get_cancelled_doc_latest_counter(doc.doctype, name) - return f'{name}-CANC-{counter+1}' diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 2cc5818414..b4a53e3131 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -1,57 +1,91 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from typing import TYPE_CHECKING, Dict, List, Optional + import frappe from frappe import _, bold from frappe.model.dynamic_links import get_dynamic_link_map from frappe.model.naming import validate_name from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data +from frappe.query_builder import Field from frappe.utils import cint from frappe.utils.password import rename_password -from frappe.query_builder import Field + +if TYPE_CHECKING: + from frappe.model.meta import Meta @frappe.whitelist() -def update_document_title(doctype, docname, title_field=None, old_title=None, new_title=None, new_name=None, merge=False): +def update_document_title( + *, + doctype: str, + docname: str, + title: Optional[str] = None, + name: Optional[str] = None, + merge: bool = False, + **kwargs +) -> str: """ Update title from header in form view """ - if docname and new_name and not docname == new_name: - docname = rename_doc(doctype=doctype, old=docname, new=new_name, merge=merge) - if old_title and new_title and not old_title == new_title: + # to maintain backwards API compatibility + updated_title = kwargs.get("new_title") or title + updated_name = kwargs.get("new_name") or name + + # TODO: omit this after runtime type checking (ref: https://github.com/frappe/frappe/pull/14927) + for obj in [docname, updated_title, updated_name]: + if not isinstance(obj, (str, type(None))): + frappe.throw(f"{obj=} must be of type str or None") + + doc = frappe.get_doc(doctype, docname) + doc.check_permission(permtype="write") + + title_field = doc.meta.get_title_field() + + title_updated = updated_title and (title_field != "name") and (updated_title != doc.get(title_field)) + name_updated = updated_name and (updated_name != doc.name) + + if name_updated: + docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge) + + if title_updated: try: - frappe.db.set_value(doctype, docname, title_field, new_title) - frappe.msgprint(_('Saved'), alert=True, indicator='green') + frappe.db.set_value(doctype, docname, title_field, updated_title) + frappe.msgprint(_("Saved"), alert=True, indicator="green") except Exception as e: if frappe.db.is_duplicate_entry(e): frappe.throw( _("{0} {1} already exists").format(doctype, frappe.bold(docname)), title=_("Duplicate Name"), - exc=frappe.DuplicateEntryError + exc=frappe.DuplicateEntryError, ) + raise return docname def rename_doc( - doctype, - old, - new, - force=False, - merge=False, - ignore_permissions=False, - ignore_if_exists=False, - show_alert=True, - rebuild_search=True -): + doctype: str, + old: str, + new: str, + force: bool = False, + merge: bool = False, + ignore_permissions: bool = False, + ignore_if_exists: bool = False, + show_alert: bool = True, + rebuild_search: bool = True, +) -> str: """Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link".""" if not frappe.db.exists(doctype, old): + frappe.errprint(_("Failed: {0} to {1} because {0} doesn't exist.").format(old, new)) return if ignore_if_exists and frappe.db.exists(doctype, new): + frappe.errprint(_("Failed: {0} to {1} because {1} already exists.").format(old, new)) return if old==new: - frappe.msgprint(_('Please select a new name to rename')) + frappe.errprint(_("Ignored: {0} to {1} no changes made because old and new name are the same.").format(old, new)) return force = cint(force) @@ -79,7 +113,8 @@ def rename_doc( update_user_settings(old, new, link_fields) if doctype=='DocType': - rename_doctype(doctype, old, new, force) + rename_doctype(doctype, old, new) + update_customizations(old, new) update_attachments(doctype, old, new) @@ -120,7 +155,7 @@ def rename_doc( return new -def update_assignments(old, new, doctype): +def update_assignments(old: str, new: str, doctype: str) -> None: old_assignments = frappe.parse_json(frappe.db.get_value(doctype, old, '_assign')) or [] new_assignments = frappe.parse_json(frappe.db.get_value(doctype, new, '_assign')) or [] common_assignments = list(set(old_assignments).intersection(new_assignments)) @@ -142,7 +177,7 @@ def update_assignments(old, new, doctype): unique_assignments = list(set(old_assignments + new_assignments)) frappe.db.set_value(doctype, new, '_assign', frappe.as_json(unique_assignments, indent=0)) -def update_user_settings(old, new, link_fields): +def update_user_settings(old: str, new: str, link_fields: List[Dict]) -> None: ''' Update the user settings of all the linked doctypes while renaming. ''' @@ -174,8 +209,10 @@ def update_user_settings(old, new, link_fields): else: continue +def update_customizations(old: str, new: str) -> None: + frappe.db.set_value("Custom DocPerm", {"parent": old}, "parent", new, update_modified=False) -def update_attachments(doctype, old, new): +def update_attachments(doctype: str, old: str, new: str) -> None: try: if old != "File Data" and doctype != "DocType": frappe.db.sql("""update `tabFile` set attached_to_name=%s @@ -184,11 +221,11 @@ def update_attachments(doctype, old, new): if not frappe.db.is_column_missing(e): raise -def rename_versions(doctype, old, new): +def rename_versions(doctype: str, old: str, new: str) -> None: frappe.db.sql("""UPDATE `tabVersion` SET `docname`=%s WHERE `ref_doctype`=%s AND `docname`=%s""", (new, doctype, old)) -def rename_eps_records(doctype, old, new): +def rename_eps_records(doctype: str, old: str, new: str) -> None: epl = frappe.qb.DocType("Energy Point Log") (frappe.qb.update(epl) .set(epl.reference_name, new) @@ -198,20 +235,20 @@ def rename_eps_records(doctype, old, new): ) ).run() -def rename_parent_and_child(doctype, old, new, meta): +def rename_parent_and_child(doctype: str, old: str, new: str, meta: "Meta") -> None: # rename the doc frappe.db.sql("UPDATE `tab{0}` SET `name`={1} WHERE `name`={1}".format(doctype, '%s'), (new, old)) update_autoname_field(doctype, new, meta) update_child_docs(old, new, meta) -def update_autoname_field(doctype, new, meta): +def update_autoname_field(doctype: str, new: str, meta: "Meta") -> None: # update the value of the autoname field on rename of the docname if meta.get('autoname'): field = meta.get('autoname').split(':') if field and field[0] == "field": frappe.db.sql("UPDATE `tab{0}` SET `{1}`={2} WHERE `name`={2}".format(doctype, field[1], '%s'), (new, new)) -def validate_rename(doctype, new, meta, merge, force, ignore_permissions): +def validate_rename(doctype: str, new: str, meta: "Meta", merge: bool, force: bool, ignore_permissions: bool) -> str: # using for update so that it gets locked and someone else cannot edit it while this rename is going on! exists = ( frappe.qb.from_(doctype) @@ -223,27 +260,27 @@ def validate_rename(doctype, new, meta, merge, force, ignore_permissions): exists = exists[0] if exists else None if merge and not exists: - frappe.msgprint(_("{0} {1} does not exist, select a new target to merge").format(doctype, new), raise_exception=1) + frappe.throw(_("{0} {1} does not exist, select a new target to merge").format(doctype, new)) if exists and exists != new: # for fixing case, accents exists = None if (not merge) and exists: - frappe.msgprint(_("Another {0} with name {1} exists, select another name").format(doctype, new), raise_exception=1) + frappe.throw(_("Another {0} with name {1} exists, select another name").format(doctype, new)) if not (ignore_permissions or frappe.permissions.has_permission(doctype, "write", raise_exception=False)): - frappe.msgprint(_("You need write permission to rename"), raise_exception=1) + frappe.throw(_("You need write permission to rename")) if not (force or ignore_permissions) and not meta.allow_rename: - frappe.msgprint(_("{0} not allowed to be renamed").format(_(doctype)), raise_exception=1) + frappe.throw(_("{0} not allowed to be renamed").format(_(doctype))) # validate naming like it's done in doc.py - new = validate_name(doctype, new, merge=merge) + new = validate_name(doctype, new) return new -def rename_doctype(doctype, old, new, force=False): +def rename_doctype(doctype: str, old: str, new: str) -> None: # change options for fieldtype Table, Table MultiSelect and Link fields_with_options = ("Link",) + frappe.model.table_fields @@ -258,13 +295,13 @@ def rename_doctype(doctype, old, new, force=False): # change parenttype for fieldtype Table update_parenttype_values(old, new) -def update_child_docs(old, new, meta): +def update_child_docs(old: str, new: str, meta: "Meta") -> None: # update "parent" for df in meta.get_table_fields(): frappe.db.sql("update `tab%s` set parent=%s where parent=%s" \ % (df.options, '%s', '%s'), (new, old)) -def update_link_field_values(link_fields, old, new, doctype): +def update_link_field_values(link_fields: List[Dict], old: str, new: str, doctype: str) -> None: for field in link_fields: if field['issingle']: try: @@ -299,12 +336,12 @@ def update_link_field_values(link_fields, old, new, doctype): if doctype=='DocType' and field['parent'] == old: field['parent'] = new -def get_link_fields(doctype): +def get_link_fields(doctype: str) -> List[Dict]: # get link fields from tabDocField if not frappe.flags.link_fields: frappe.flags.link_fields = {} - if not doctype in frappe.flags.link_fields: + if doctype not in frappe.flags.link_fields: link_fields = frappe.db.sql("""\ select parent, fieldname, (select issingle from tabDocType dt @@ -342,7 +379,7 @@ def get_link_fields(doctype): return frappe.flags.link_fields[doctype] -def update_options_for_fieldtype(fieldtype, old, new): +def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None: if frappe.conf.developer_mode: for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"): doctype = frappe.get_doc("DocType", name) @@ -363,7 +400,7 @@ def update_options_for_fieldtype(fieldtype, old, new): frappe.db.sql("""update `tabProperty Setter` set value=%s where property='options' and value=%s""", (new, old)) -def get_select_fields(old, new): +def get_select_fields(old: str, new: str) -> List[Dict]: """ get select type fields where doctype's name is hardcoded as new line separated list @@ -407,7 +444,7 @@ def get_select_fields(old, new): return select_fields -def update_select_field_values(old, new): +def update_select_field_values(old: str, new: str): frappe.db.sql(""" update `tabDocField` set options=replace(options, %s, %s) where @@ -430,7 +467,7 @@ def update_select_field_values(old, new): (value like {0} or value like {1})""" .format(frappe.db.escape('%' + '\n' + old + '%'), frappe.db.escape('%' + old + '\n' + '%')), (old, new, new)) -def update_parenttype_values(old, new): +def update_parenttype_values(old: str, new: str): child_doctypes = frappe.db.get_all('DocField', fields=['options', 'fieldname'], filters={ @@ -466,7 +503,7 @@ def update_parenttype_values(old, new): for doctype in child_doctypes: frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old)) -def rename_dynamic_links(doctype, old, new): +def rename_dynamic_links(doctype: str, old: str, new: str): for df in get_dynamic_link_map().get(doctype, []): # dynamic link in single, just one value to check if frappe.get_meta(df.parent).issingle: @@ -482,7 +519,7 @@ def rename_dynamic_links(doctype, old, new): where {options}=%s and {fieldname}=%s""".format(parent = parent, fieldname=df.fieldname, options=df.options), (new, doctype, old)) -def bulk_rename(doctype, rows=None, via_console = False): +def bulk_rename(doctype: str, rows: Optional[List[List]] = None, via_console: bool = False) -> Optional[List[str]]: """Bulk rename documents :param doctype: DocType to be renamed @@ -505,22 +542,23 @@ def bulk_rename(doctype, rows=None, via_console = False): msg = _("Successful: {0} to {1}").format(row[0], row[1]) frappe.db.commit() else: - msg = _("Ignored: {0} to {1}").format(row[0], row[1]) + msg = None except Exception as e: msg = _("** Failed: {0} to {1}: {2}").format(row[0], row[1], repr(e)) frappe.db.rollback() - if via_console: - print(msg) - else: - rename_log.append(msg) + if msg: + if via_console: + print(msg) + else: + rename_log.append(msg) frappe.enqueue('frappe.utils.global_search.rebuild_for_doctype', doctype=doctype) if not via_console: return rename_log -def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None): +def update_linked_doctypes(doctype: str, docname: str, linked_to: str, value: str, ignore_doctypes: Optional[List] = None) -> None: from frappe.model.utils.rename_doc import update_linked_doctypes show_deprecation_warning("update_linked_doctypes") @@ -533,7 +571,7 @@ def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=N ) -def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): +def get_fetch_fields(doctype: str, linked_to: str, ignore_doctypes: Optional[List] = None) -> List[Dict]: from frappe.model.utils.rename_doc import get_fetch_fields show_deprecation_warning("get_fetch_fields") @@ -541,7 +579,7 @@ def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): doctype=doctype, linked_to=linked_to, ignore_doctypes=ignore_doctypes ) -def show_deprecation_warning(funct): +def show_deprecation_warning(funct: str) -> None: from click import secho message = ( f"Function frappe.model.rename_doc.{funct} has been deprecated and " diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 9ba14d5e68..109260d0fe 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -117,7 +117,7 @@ def get_doc_files(files, start_path): if os.path.isdir(os.path.join(doctype_path, docname)): doc_path = os.path.join(doctype_path, docname, docname) + ".json" if os.path.exists(doc_path): - if not doc_path in files: + if doc_path not in files: files.append(doc_path) return files diff --git a/frappe/model/utils/rename_doc.py b/frappe/model/utils/rename_doc.py index bf71d36a42..f7afbd0cf2 100644 --- a/frappe/model/utils/rename_doc.py +++ b/frappe/model/utils/rename_doc.py @@ -1,10 +1,14 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + from itertools import product +from typing import Dict, List, Optional import frappe from frappe.model.rename_doc import get_link_fields -def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None): +def update_linked_doctypes(doctype: str, docname: str, linked_to: str, value: str, ignore_doctypes: Optional[List] = None): """ linked_doctype_info_list = list formed by get_fetch_fields() function docname = Master DocType's name in which modification are made @@ -24,7 +28,7 @@ def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=N ) -def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): +def get_fetch_fields(doctype: str, linked_to: str, ignore_doctypes: Optional[List] = None) -> List[Dict]: """ doctype = Master DocType in which the changes are being made linked_to = DocType name of the field thats being updated in Master diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index e74d88c0f2..1b26cc2c3a 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -1,10 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import json import frappe -from frappe.utils import cint from frappe import _ -import json +from frappe.utils import cint +from frappe.model.docstatus import DocStatus class WorkflowStateError(frappe.ValidationError): pass class WorkflowTransitionError(frappe.ValidationError): pass @@ -102,13 +103,13 @@ def apply_workflow(doc, action): doc.set(next_state.update_field, next_state.update_value) new_docstatus = cint(next_state.doc_status) - if doc.docstatus == 0 and new_docstatus == 0: + if doc.docstatus.is_draft() and new_docstatus == DocStatus.draft(): doc.save() - elif doc.docstatus == 0 and new_docstatus == 1: + elif doc.docstatus.is_draft() and new_docstatus == DocStatus.submitted(): doc.submit() - elif doc.docstatus == 1 and new_docstatus == 1: + elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.submitted(): doc.save() - elif doc.docstatus == 1 and new_docstatus == 2: + elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.cancelled(): doc.cancel() else: frappe.throw(_('Illegal Document Status for {0}').format(next_state.state)) @@ -212,10 +213,10 @@ def bulk_workflow_approval(docnames, doctype, action): frappe.db.commit() except Exception as e: if not frappe.message_log: - # Exception is raised manually and not from msgprint or throw + # Exception is raised manually and not from msgprint or throw message = "{0}".format(e.__class__.__name__) if e.args: - message += " : {0}".format(e.args[0]) + message += " : {0}".format(e.args[0]) message_dict = {"docname": docname, "message": message} failed_transactions[docname].append(message_dict) diff --git a/frappe/modules/export_file.py b/frappe/modules/export_file.py index ab6ffd4985..45e008fa04 100644 --- a/frappe/modules/export_file.py +++ b/frappe/modules/export_file.py @@ -47,7 +47,7 @@ def strip_default_fields(doc, doc_export): for df in doc.meta.get_table_fields(): for d in doc_export.get(df.fieldname): - for fieldname in frappe.model.default_fields: + for fieldname in (frappe.model.default_fields + frappe.model.child_table_fields): if fieldname in d: del d[fieldname] diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 1219fbb045..f9c7b55a99 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -11,7 +11,7 @@ from frappe.query_builder import DocType from frappe.utils import get_datetime, now -def caclulate_hash(path: str) -> str: +def calculate_hash(path: str) -> str: """Calculate md5 hash of the file in binary mode Args: @@ -99,7 +99,7 @@ def import_file_by_path(path: str,force: bool = False,data_import: bool = False, print(f"{path} missing") return - calculated_hash = caclulate_hash(path) + calculated_hash = calculate_hash(path) if docs: if not isinstance(docs, list): @@ -115,10 +115,11 @@ def import_file_by_path(path: str,force: bool = False,data_import: bool = False, if not force or db_modified_timestamp: try: - stored_hash = frappe.db.get_value(doc["doctype"], doc["name"], "migration_hash") + stored_hash = None + if doc["doctype"] == "DocType": + stored_hash = frappe.db.get_value(doc["doctype"], doc["name"], "migration_hash") except Exception: frappe.flags.dt += [doc["doctype"]] - stored_hash = None # if hash exists and is equal no need to update if stored_hash and stored_hash == calculated_hash: diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index 8dfb27c0b8..0a23d5b0f4 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -1,37 +1,77 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +""" Patch Handler. + +This file manages execution of manaully written patches. Patches are script +that apply changes in database schema or data to accomodate for changes in the +code. + +Ways to specify patches: + +1. patches.txt file specifies patches that run before doctype schema +migration. Each line represents one patch (old format). +2. patches.txt can alternatively also separate pre and post model sync +patches by using INI like file format: + ```patches.txt + [pre_model_sync] + app.module.patch1 + app.module.patch2 + + + [post_model_sync] + app.module.patch3 + ``` + + When different sections are specified patches are executed in this order: + 1. Run pre_model_sync patches + 2. Reload/resync all doctype schema + 3. Run post_model_sync patches + + Hence any patch that just needs to modify data but doesn't depend on + old schema should be added to post_model_sync section of file. + +3. simple python commands can be added by starting line with `execute:` +`execute:` example: `execute:print("hello world")` """ - Execute Patch Files - To run directly +import configparser +import time +from enum import Enum +from textwrap import dedent, indent +from typing import List, Optional - python lib/wnf.py patch patch1, patch2 etc - python lib/wnf.py patch -f patch1, patch2 etc +import frappe - where patch1, patch2 is module name -""" -import frappe, frappe.permissions, time -class PatchError(Exception): pass +class PatchError(Exception): + pass -def run_all(skip_failing=False): + +class PatchType(Enum): + pre_model_sync = "pre_model_sync" + post_model_sync = "post_model_sync" + + +def run_all(skip_failing: bool = False, patch_type: Optional[PatchType] = None) -> None: """run all pending patches""" - executed = [p[0] for p in frappe.db.sql("""select patch from `tabPatch Log`""")] + executed = set(frappe.get_all("Patch Log", fields="patch", pluck="patch")) frappe.flags.final_patches = [] def run_patch(patch): try: if not run_single(patchmodule = patch): - log(patch + ': failed: STOPPED') + print(patch + ': failed: STOPPED') raise PatchError(patch) except Exception: if not skip_failing: raise else: - log('Failed to execute patch') + print('Failed to execute patch') - for patch in get_all_patches(): + patches = get_all_patches(patch_type=patch_type) + + for patch in patches: if patch and (patch not in executed): run_patch(patch) @@ -40,18 +80,57 @@ def run_all(skip_failing=False): patch = patch.replace('finally:', '') run_patch(patch) -def get_all_patches(): +def get_all_patches(patch_type: Optional[PatchType] = None) -> List[str]: + + if patch_type and not isinstance(patch_type, PatchType): + frappe.throw(f"Unsupported patch type specified: {patch_type}") + patches = [] for app in frappe.get_installed_apps(): - if app == "shopping_cart": - continue - # 3-to-4 fix - if app=="webnotes": - app="frappe" - patches.extend(frappe.get_file_items(frappe.get_pymodule_path(app, "patches.txt"))) + patches.extend(get_patches_from_app(app, patch_type=patch_type)) return patches +def get_patches_from_app(app: str, patch_type: Optional[PatchType] = None) -> List[str]: + """ Get patches from an app's patches.txt + + patches.txt can be: + 1. ini like file with section for different patch_type + 2. plain text file with each line representing a patch. + """ + + patches_txt = frappe.get_pymodule_path(app, "patches.txt") + + try: + # Attempt to parse as ini file with pre/post patches + # allow_no_value: patches are not key value pairs + # delimiters = '\n' to avoid treating default `:` and `=` in execute as k:v delimiter + parser = configparser.ConfigParser(allow_no_value=True, delimiters="\n") + # preserve case + parser.optionxform = str + parser.read(patches_txt) + + # empty file + if not parser.sections(): + return [] + + if not patch_type: + return [patch for patch in parser[PatchType.pre_model_sync.value]] + \ + [patch for patch in parser[PatchType.post_model_sync.value]] + + if patch_type.value in parser.sections(): + return [patch for patch in parser[patch_type.value]] + else: + frappe.throw(frappe._("Patch type {} not found in patches.txt").format(patch_type)) + + except configparser.MissingSectionHeaderError: + # treat as old format with each line representing a single patch + # backward compatbility with old patches.txt format + if not patch_type or patch_type == PatchType.pre_model_sync: + return frappe.get_file_items(patches_txt) + + return [] + def reload_doc(args): import frappe.modules run_single(method = frappe.modules.reload_doc, methodargs = args) @@ -70,21 +149,36 @@ def run_single(patchmodule=None, method=None, methodargs=None, force=False): def execute_patch(patchmodule, method=None, methodargs=None): """execute the patch""" block_user(True) - frappe.db.begin() + + if patchmodule.startswith("execute:"): + has_patch_file = False + patch = patchmodule.split("execute:")[1] + docstring = "" + else: + has_patch_file = True + patch = f"{patchmodule.split()[0]}.execute" + _patch = frappe.get_attr(patch) + docstring = _patch.__doc__ or "" + + if docstring: + docstring = "\n" + indent(dedent(docstring), "\t") + + print(f"Executing {patchmodule or methodargs} in {frappe.local.site} ({frappe.db.cur_db_name}){docstring}") + start_time = time.time() + frappe.db.begin() try: - log('Executing {patch} in {site} ({db})'.format(patch=patchmodule or str(methodargs), - site=frappe.local.site, db=frappe.db.cur_db_name)) if patchmodule: if patchmodule.startswith("finally:"): # run run patch at the end frappe.flags.final_patches.append(patchmodule) else: - if patchmodule.startswith("execute:"): - exec(patchmodule.split("execute:")[1],globals()) + if has_patch_file: + _patch() else: - frappe.get_attr(patchmodule.split()[0] + ".execute")() + exec(patch, globals()) update_patch_log(patchmodule) + elif method: method(**methodargs) @@ -96,7 +190,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): frappe.db.commit() end_time = time.time() block_user(False) - log('Success: Done in {time}s'.format(time = round(end_time - start_time, 3))) + print(f"Success: Done in {round(end_time - start_time, 3)}s") return True @@ -109,10 +203,7 @@ def executed(patchmodule): if patchmodule.startswith('finally:'): # patches are saved without the finally: tag patchmodule = patchmodule.replace('finally:', '') - done = frappe.db.get_value("Patch Log", {"patch": patchmodule}) - # if done: - # print "Patch %s already executed in %s" % (patchmodule, frappe.db.cur_db_name) - return done + return frappe.db.get_value("Patch Log", {"patch": patchmodule}) def block_user(block, msg=None): """stop/start execution till patch is run""" @@ -128,6 +219,3 @@ def check_session_stopped(): if frappe.db.get_global("__session_status")=='stop': frappe.msgprint(frappe.db.get_global("__session_status_message")) raise frappe.SessionStopped('Session Stopped') - -def log(msg): - print (msg) diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index bbfd63a277..13b52d2020 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -257,6 +257,12 @@ def make_boilerplate(template, doc, opts=None): pass def get_list(self, args): + pass + + def get_count(self, args): + pass + + def get_stats(self, args): pass""" with open(target_file_path, 'w') as target: diff --git a/frappe/patches.txt b/frappe/patches.txt index af7e4d6e3f..82b1f497c2 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -1,3 +1,4 @@ +[pre_model_sync] frappe.patches.v12_0.remove_deprecated_fields_from_doctype #3 execute:frappe.utils.global_search.setup_global_search_table() execute:frappe.reload_doc('core', 'doctype', 'doctype_action', force=True) #2019-09-23 @@ -87,7 +88,6 @@ frappe.patches.v11_0.set_missing_creation_and_modified_value_for_user_permission frappe.patches.v11_0.set_default_letter_head_source frappe.patches.v12_0.set_primary_key_in_series execute:frappe.delete_doc("Page", "modules", ignore_missing=True) -frappe.patches.v11_0.set_default_letter_head_source frappe.patches.v12_0.setup_comments_from_communications frappe.patches.v12_0.replace_null_values_in_tables frappe.patches.v12_0.reset_home_settings @@ -119,10 +119,12 @@ execute:frappe.delete_doc_if_exists('DocType', 'GSuite Settings') execute:frappe.delete_doc_if_exists('DocType', 'GSuite Templates') execute:frappe.delete_doc_if_exists('DocType', 'GCalendar Account') execute:frappe.delete_doc_if_exists('DocType', 'GCalendar Settings') -frappe.patches.v12_0.remove_parent_and_parenttype_from_print_formats frappe.patches.v12_0.remove_example_email_thread_notify execute:from frappe.desk.page.setup_wizard.install_fixtures import update_genders;update_genders() frappe.patches.v12_0.set_correct_url_in_files +execute:frappe.reload_doc('core', 'doctype', 'doctype') +execute:frappe.reload_doc('custom', 'doctype', 'property_setter') +frappe.patches.v13_0.remove_invalid_options_for_data_fields frappe.patches.v13_0.website_theme_custom_scss frappe.patches.v13_0.make_user_type frappe.patches.v13_0.set_existing_dashboard_charts_as_public @@ -144,7 +146,7 @@ frappe.patches.v13_0.update_duration_options frappe.patches.v13_0.replace_old_data_import # 2020-06-24 frappe.patches.v13_0.create_custom_dashboards_cards_and_charts frappe.patches.v13_0.rename_is_custom_field_in_dashboard_chart -frappe.patches.v13_0.add_standard_navbar_items # 2020-12-15 +frappe.patches.v13_0.add_standard_navbar_items # 2022-03-15 frappe.patches.v13_0.generate_theme_files_in_public_folder frappe.patches.v13_0.increase_password_length frappe.patches.v12_0.fix_email_id_formatting @@ -153,7 +155,6 @@ frappe.patches.v13_0.rename_notification_fields frappe.patches.v13_0.remove_duplicate_navbar_items frappe.patches.v13_0.set_social_icons frappe.patches.v12_0.set_default_password_reset_limit -execute:frappe.reload_doc('core', 'doctype', 'doctype', force=True) frappe.patches.v13_0.set_route_for_blog_category frappe.patches.v13_0.enable_custom_script frappe.patches.v13_0.update_newsletter_content_type @@ -173,22 +174,27 @@ execute:frappe.delete_doc_if_exists('Page', 'workspace') execute:frappe.delete_doc_if_exists('Page', 'dashboard', force=1) frappe.core.doctype.page.patches.drop_unused_pages execute:frappe.get_doc('Role', 'Guest').save() # remove desk access +frappe.patches.v13_0.remove_chat frappe.patches.v13_0.rename_desk_page_to_workspace # 02.02.2021 frappe.patches.v13_0.delete_package_publish_tool frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings frappe.patches.v13_0.remove_twilio_settings frappe.patches.v12_0.rename_uploaded_files_with_proper_name frappe.patches.v13_0.queryreport_columns -execute:frappe.reload_doc('core', 'doctype', 'doctype') frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.update_notification_channel_if_empty frappe.patches.v13_0.set_first_day_of_the_week -frappe.patches.v14_0.drop_data_import_legacy -frappe.patches.v14_0.rename_cancelled_documents -frappe.patches.v14_0.copy_mail_data #08.03.21 +execute:frappe.reload_doc('custom', 'doctype', 'custom_field') frappe.patches.v14_0.update_workspace2 # 20.09.2021 +frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 +frappe.patches.v14_0.transform_todo_schema +frappe.patches.v14_0.remove_post_and_post_comment +frappe.patches.v14_0.reset_creation_datetime + +[post_model_sync] +frappe.patches.v14_0.drop_data_import_legacy +frappe.patches.v14_0.copy_mail_data #08.03.21 frappe.patches.v14_0.update_github_endpoints #08-11-2021 frappe.patches.v14_0.remove_db_aggregation -frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.update_color_names_in_kanban_board_column -frappe.patches.v14_0.transform_todo_schema +frappe.patches.v14_0.update_auto_account_deletion_duration diff --git a/frappe/patches/v11_0/remove_skip_for_doctype.py b/frappe/patches/v11_0/remove_skip_for_doctype.py index 1bbe74bb6d..6e66c75f68 100644 --- a/frappe/patches/v11_0/remove_skip_for_doctype.py +++ b/frappe/patches/v11_0/remove_skip_for_doctype.py @@ -33,7 +33,7 @@ def execute(): continue skip_for_doctype = user_permission.skip_for_doctype.split('\n') else: # while migrating from v10 -> v11 - if skip_for_doctype_map.get((user_permission.allow, user_permission.user)) == None: + if skip_for_doctype_map.get((user_permission.allow, user_permission.user)) is None: skip_for_doctype = get_doctypes_to_skip(user_permission.allow, user_permission.user) # cache skip for doctype for same user and doctype skip_for_doctype_map[(user_permission.allow, user_permission.user)] = skip_for_doctype diff --git a/frappe/patches/v12_0/remove_parent_and_parenttype_from_print_formats.py b/frappe/patches/v12_0/remove_parent_and_parenttype_from_print_formats.py deleted file mode 100644 index 1a3c56da59..0000000000 --- a/frappe/patches/v12_0/remove_parent_and_parenttype_from_print_formats.py +++ /dev/null @@ -1,14 +0,0 @@ -import frappe - -def execute(): - frappe.db.sql(""" - UPDATE - `tabPrint Format` - SET - `tabPrint Format`.`parent`='', - `tabPrint Format`.`parenttype`='', - `tabPrint Format`.parentfield='' - WHERE - `tabPrint Format`.parent != '' - OR `tabPrint Format`.parenttype != '' - """) \ No newline at end of file diff --git a/frappe/patches/v12_0/set_correct_url_in_files.py b/frappe/patches/v12_0/set_correct_url_in_files.py index 4f820c1b24..4613f88694 100644 --- a/frappe/patches/v12_0/set_correct_url_in_files.py +++ b/frappe/patches/v12_0/set_correct_url_in_files.py @@ -15,7 +15,7 @@ def execute(): for file in files: file_path = file.file_url file_name = file_path.split('/')[-1] - + if not file_path.startswith(('/private/', '/files/')): continue diff --git a/frappe/patches/v13_0/remove_chat.py b/frappe/patches/v13_0/remove_chat.py new file mode 100644 index 0000000000..1804c7693f --- /dev/null +++ b/frappe/patches/v13_0/remove_chat.py @@ -0,0 +1,17 @@ +import frappe +import click + +def execute(): + frappe.delete_doc_if_exists("DocType", "Chat Message") + frappe.delete_doc_if_exists("DocType", "Chat Message Attachment") + frappe.delete_doc_if_exists("DocType", "Chat Profile") + frappe.delete_doc_if_exists("DocType", "Chat Token") + frappe.delete_doc_if_exists("DocType", "Chat Room User") + frappe.delete_doc_if_exists("DocType", "Chat Room") + frappe.delete_doc_if_exists("Module Def", "Chat") + + click.secho( + "Chat Module is moved to a separate app and is removed from Frappe in version-13.\n" + "Please install the app to continue using the chat feature: https://github.com/frappe/chat", + fg="yellow", + ) \ No newline at end of file diff --git a/frappe/patches/v13_0/remove_invalid_options_for_data_fields.py b/frappe/patches/v13_0/remove_invalid_options_for_data_fields.py new file mode 100644 index 0000000000..90e4b3c5c6 --- /dev/null +++ b/frappe/patches/v13_0/remove_invalid_options_for_data_fields.py @@ -0,0 +1,17 @@ +# Copyright (c) 2022, Frappe and Contributors +# License: MIT. See LICENSE + + +import frappe +from frappe.model import data_field_options + + +def execute(): + custom_field = frappe.qb.DocType('Custom Field') + (frappe.qb + .update(custom_field) + .set(custom_field.options, None) + .where( + (custom_field.fieldtype == "Data") + & (custom_field.options.notin(data_field_options))) + ).run() diff --git a/frappe/patches/v14_0/copy_mail_data.py b/frappe/patches/v14_0/copy_mail_data.py index d3a5c59209..8ef9cfaf1f 100644 --- a/frappe/patches/v14_0/copy_mail_data.py +++ b/frappe/patches/v14_0/copy_mail_data.py @@ -3,9 +3,6 @@ import frappe def execute(): - frappe.reload_doc("email", "doctype", "imap_folder") - frappe.reload_doc("email", "doctype", "email_account") - # patch for all Email Account with the flag use_imap for email_account in frappe.get_list("Email Account", filters={"enable_incoming": 1, "use_imap": 1}): # get all data from Email Account diff --git a/frappe/patches/v14_0/remove_post_and_post_comment.py b/frappe/patches/v14_0/remove_post_and_post_comment.py new file mode 100644 index 0000000000..3a93139961 --- /dev/null +++ b/frappe/patches/v14_0/remove_post_and_post_comment.py @@ -0,0 +1,5 @@ +import frappe + +def execute(): + frappe.delete_doc_if_exists("DocType", "Post") + frappe.delete_doc_if_exists("DocType", "Post Comment") diff --git a/frappe/patches/v14_0/rename_cancelled_documents.py b/frappe/patches/v14_0/rename_cancelled_documents.py deleted file mode 100644 index 4b565d4f76..0000000000 --- a/frappe/patches/v14_0/rename_cancelled_documents.py +++ /dev/null @@ -1,213 +0,0 @@ -import functools -import traceback - -import frappe - -def execute(): - """Rename cancelled documents by adding a postfix. - """ - rename_cancelled_docs() - -def get_submittable_doctypes(): - """Returns list of submittable doctypes in the system. - """ - return frappe.db.get_all('DocType', filters={'is_submittable': 1}, pluck='name') - -def get_cancelled_doc_names(doctype): - """Return names of cancelled document names those are in old format. - """ - docs = frappe.db.get_all(doctype, filters={'docstatus': 2}, pluck='name') - return [each for each in docs if not (each.endswith('-CANC') or ('-CANC-' in each))] - -@functools.lru_cache() -def get_linked_doctypes(): - """Returns list of doctypes those are linked with given doctype using 'Link' fieldtype. - """ - filters=[['fieldtype','=', 'Link']] - links = frappe.get_all("DocField", - fields=["parent", "fieldname", "options as linked_to"], - filters=filters, - as_list=1) - - links+= frappe.get_all("Custom Field", - fields=["dt as parent", "fieldname", "options as linked_to"], - filters=filters, - as_list=1) - - links_by_doctype = {} - for doctype, fieldname, linked_to in links: - links_by_doctype.setdefault(linked_to, []).append((doctype, fieldname)) - return links_by_doctype - -@functools.lru_cache() -def get_single_doctypes(): - return frappe.get_all("DocType", filters={'issingle': 1}, pluck='name') - -@functools.lru_cache() -def get_dynamic_linked_doctypes(): - filters=[['fieldtype','=', 'Dynamic Link']] - - # find dynamic links of parents - links = frappe.get_all("DocField", - fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], - filters=filters, - as_list=1) - links+= frappe.get_all("Custom Field", - fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], - filters=filters, - as_list=1) - return links - -@functools.lru_cache() -def get_child_tables(): - """ - """ - filters =[['fieldtype', 'in', ('Table', 'Table MultiSelect')]] - links = frappe.get_all("DocField", - fields=["parent as doctype", "options as child_table"], - filters=filters, - as_list=1) - - links+= frappe.get_all("Custom Field", - fields=["dt as doctype", "options as child_table"], - filters=filters, - as_list=1) - - map = {} - for doctype, child_table in links: - map.setdefault(doctype, []).append(child_table) - return map - -def update_cancelled_document_names(doctype, cancelled_doc_names): - return frappe.db.sql(""" - update - `tab{doctype}` - set - name=CONCAT(name, '-CANC') - where - docstatus=2 - and - name in %(cancelled_doc_names)s; - """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) - -def update_amended_field(doctype, cancelled_doc_names): - return frappe.db.sql(""" - update - `tab{doctype}` - set - amended_from=CONCAT(amended_from, '-CANC') - where - amended_from in %(cancelled_doc_names)s; - """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) - -def update_attachments(doctype, cancelled_doc_names): - frappe.db.sql(""" - update - `tabFile` - set - attached_to_name=CONCAT(attached_to_name, '-CANC') - where - attached_to_doctype=%(dt)s and attached_to_name in %(cancelled_doc_names)s - """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - -def update_versions(doctype, cancelled_doc_names): - frappe.db.sql(""" - UPDATE - `tabVersion` - SET - docname=CONCAT(docname, '-CANC') - WHERE - ref_doctype=%(dt)s AND docname in %(cancelled_doc_names)s - """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - -def update_linked_doctypes(doctype, cancelled_doc_names): - single_doctypes = get_single_doctypes() - - for linked_dt, field in get_linked_doctypes().get(doctype, []): - if linked_dt not in single_doctypes: - frappe.db.sql(""" - update - `tab{linked_dt}` - set - `{column}`=CONCAT(`{column}`, '-CANC') - where - `{column}` in %(cancelled_doc_names)s; - """.format(linked_dt=linked_dt, column=field), - {'cancelled_doc_names': cancelled_doc_names}) - else: - doc = frappe.get_single(linked_dt) - if getattr(doc, field) in cancelled_doc_names: - setattr(doc, field, getattr(doc, field)+'-CANC') - doc.flags.ignore_mandatory=True - doc.flags.ignore_validate=True - doc.save(ignore_permissions=True) - -def update_dynamic_linked_doctypes(doctype, cancelled_doc_names): - single_doctypes = get_single_doctypes() - - for linked_dt, fieldname, doctype_fieldname in get_dynamic_linked_doctypes(): - if linked_dt not in single_doctypes: - frappe.db.sql(""" - update - `tab{linked_dt}` - set - `{column}`=CONCAT(`{column}`, '-CANC') - where - `{column}` in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; - """.format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname), - {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - else: - doc = frappe.get_single(linked_dt) - if getattr(doc, doctype_fieldname) == doctype and getattr(doc, fieldname) in cancelled_doc_names: - setattr(doc, fieldname, getattr(doc, fieldname)+'-CANC') - doc.flags.ignore_mandatory=True - doc.flags.ignore_validate=True - doc.save(ignore_permissions=True) - -def update_child_tables(doctype, cancelled_doc_names): - child_tables = get_child_tables().get(doctype, []) - single_doctypes = get_single_doctypes() - - for table in child_tables: - if table not in single_doctypes: - frappe.db.sql(""" - update - `tab{table}` - set - parent=CONCAT(parent, '-CANC') - where - parenttype=%(dt)s and parent in %(cancelled_doc_names)s; - """.format(table=table), {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - else: - doc = frappe.get_single(table) - if getattr(doc, 'parenttype')==doctype and getattr(doc, 'parent') in cancelled_doc_names: - setattr(doc, 'parent', getattr(doc, 'parent')+'-CANC') - doc.flags.ignore_mandatory=True - doc.flags.ignore_validate=True - doc.save(ignore_permissions=True) - -def rename_cancelled_docs(): - submittable_doctypes = get_submittable_doctypes() - - for dt in submittable_doctypes: - for retry in range(2): - try: - cancelled_doc_names = tuple(get_cancelled_doc_names(dt)) - if not cancelled_doc_names: - break - update_cancelled_document_names(dt, cancelled_doc_names) - update_amended_field(dt, cancelled_doc_names) - update_child_tables(dt, cancelled_doc_names) - update_linked_doctypes(dt, cancelled_doc_names) - update_dynamic_linked_doctypes(dt, cancelled_doc_names) - update_attachments(dt, cancelled_doc_names) - update_versions(dt, cancelled_doc_names) - print(f"Renaming cancelled records of {dt} doctype") - frappe.db.commit() - break - except Exception: - if retry == 1: - print(f"Failed to rename the cancelled records of {dt} doctype, moving on!") - traceback.print_exc() - frappe.db.rollback() - diff --git a/frappe/patches/v14_0/reset_creation_datetime.py b/frappe/patches/v14_0/reset_creation_datetime.py new file mode 100644 index 0000000000..54eb6c65af --- /dev/null +++ b/frappe/patches/v14_0/reset_creation_datetime.py @@ -0,0 +1,41 @@ +import glob +import json +import frappe +import os +from frappe.query_builder import DocType as _DocType + + +def execute(): + """Resetting creation datetimes for DocTypes""" + DocType = _DocType("DocType") + doctype_jsons = glob.glob( + os.path.join("..", "apps", "frappe", "frappe", "**", "doctype", "**", "*.json") + ) + + frappe_modules = frappe.get_all( + "Module Def", filters={"app_name": "frappe"}, pluck="name" + ) + site_doctypes = frappe.get_all( + "DocType", + filters={"module": ("in", frappe_modules), "custom": False}, + fields=["name", "creation"], + ) + + for dt_path in doctype_jsons: + with open(dt_path) as f: + try: + file_schema = frappe._dict(json.load(f)) + except Exception: + continue + + if not file_schema.name: + continue + + _site_schema = [x for x in site_doctypes if x.name == file_schema.name] + if not _site_schema: + continue + + if file_schema.creation != _site_schema[0].creation: + frappe.qb.update(DocType).set( + DocType.creation, file_schema.creation + ).where(DocType.name == file_schema.name).run() diff --git a/frappe/patches/v14_0/update_auto_account_deletion_duration.py b/frappe/patches/v14_0/update_auto_account_deletion_duration.py new file mode 100644 index 0000000000..74957066e6 --- /dev/null +++ b/frappe/patches/v14_0/update_auto_account_deletion_duration.py @@ -0,0 +1,5 @@ +import frappe + +def execute(): + days = frappe.db.get_single_value("Website Settings", "auto_account_deletion") + frappe.db.set_value("Website Settings", None, "auto_account_deletion", days * 24) diff --git a/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py b/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py index ea8a10e43a..ff03604754 100644 --- a/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py +++ b/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals import frappe def execute(): - frappe.reload_doc("desk", "doctype", "kanban_board_column") indicator_map = { 'blue': 'Blue', 'orange': 'Orange', diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index 82076c4328..a4b057b989 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -5,10 +5,10 @@ from frappe import _ def execute(): frappe.reload_doc('desk', 'doctype', 'workspace', force=True) - for seq, wspace in enumerate(frappe.get_all('Workspace', order_by='name asc')): - doc = frappe.get_doc('Workspace', wspace.name) + for seq, workspace in enumerate(frappe.get_all('Workspace', order_by='name asc')): + doc = frappe.get_doc('Workspace', workspace.name) content = create_content(doc) - update_wspace(doc, seq, content) + update_workspace(doc, seq, content) frappe.db.commit() def create_content(doc): @@ -49,7 +49,7 @@ def create_content(doc): del doc.links[doc.links.index(l)] return content -def update_wspace(doc, seq, content): +def update_workspace(doc, seq, content): if not doc.title and not doc.content and not doc.is_standard and not doc.public: doc.sequence_id = seq + 1 doc.content = json.dumps(content) diff --git a/frappe/permissions.py b/frappe/permissions.py index 5faaf7dcfb..a6c17fb59f 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -23,7 +23,7 @@ def print_has_permission_check_logs(func): frappe.flags['has_permission_check_logs'] = [] result = func(*args, **kwargs) self_perm_check = True if not kwargs.get('user') else kwargs.get('user') == frappe.session.user - raise_exception = False if kwargs.get('raise_exception') == False else True + raise_exception = False if kwargs.get('raise_exception') is False else True # print only if access denied # and if user is checking his own permission @@ -559,7 +559,9 @@ def filter_allowed_docs_for_doctype(user_permissions, doctype, with_default_doc= return (allowed_doc, default_doc) if with_default_doc else allowed_doc def push_perm_check_log(log): - if frappe.flags.get('has_permission_check_logs') == None: return + if frappe.flags.get('has_permission_check_logs') is None: + return + frappe.flags.get('has_permission_check_logs').append(_(log)) def has_child_table_permission(child_doctype, ptype="read", child_doc=None, @@ -592,4 +594,4 @@ def is_parent_valid(child_doctype, parent_doctype): from frappe.core.utils import find parent_meta = frappe.get_meta(parent_doctype) child_table_field_exists = find(parent_meta.get_table_fields(), lambda d: d.options == child_doctype) - return not parent_meta.istable and child_table_field_exists \ No newline at end of file + return not parent_meta.istable and child_table_field_exists diff --git a/frappe/public/css/tree.css b/frappe/public/css/tree.css index 2aa411bc11..8b216bc321 100644 --- a/frappe/public/css/tree.css +++ b/frappe/public/css/tree.css @@ -24,7 +24,7 @@ ul.tree-children { } .tree-link .node-parent, .tree-link .node-leaf { - margin-right: 5px; + margin-right: 8px; } .tree-link.active i { color: #5e64ff; diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index b878f713e9..bf4e02a7af 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -1,128 +1,309 @@ diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index cac02c7a68..e056a34be2 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -96,6 +96,7 @@ import "./frappe/ui/sort_selector.js"; import "./frappe/change_log.html"; import "./frappe/ui/workspace_loading_skeleton.html"; +import "./frappe/ui/workspace_sidebar_loading_skeleton.html"; import "./frappe/desk.js"; import "./frappe/query_string.js"; diff --git a/frappe/public/js/frappe-web.bundle.js b/frappe/public/js/frappe-web.bundle.js index c962457964..b8d4006090 100644 --- a/frappe/public/js/frappe-web.bundle.js +++ b/frappe/public/js/frappe-web.bundle.js @@ -2,6 +2,7 @@ import "./jquery-bootstrap"; import "./frappe/class.js"; import "./frappe/polyfill.js"; import "./lib/md5.min.js"; +import "./lib/moment.js"; import "./frappe/provide.js"; import "./frappe/format.js"; import "./frappe/utils/number_format.js"; diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js index 2264042539..b153718c70 100644 --- a/frappe/public/js/frappe/data_import/import_preview.js +++ b/frappe/public/js/frappe/data_import/import_preview.js @@ -331,7 +331,7 @@ frappe.data_import.ImportPreview = class ImportPreview { is_row_imported(row) { let serial_no = row[0].content; return this.import_log.find(log => { - return log.success && log.row_indexes.includes(serial_no); + return log.success && JSON.parse(log.row_indexes || '[]').includes(serial_no); }); } }; diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 202cee645a..51ada70948 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -214,19 +214,20 @@ frappe.Application = class Application { email_password_prompt(email_account,user,i) { var me = this; + const email_id = email_account[i]["email_id"]; let d = new frappe.ui.Dialog({ title: __('Password missing in Email Account'), fields: [ { 'fieldname': 'password', 'fieldtype': 'Password', - 'label': __('Please enter the password for: {0}', [email_account[i]["email_id"]]), + 'label': __('Please enter the password for: {0}', [email_id], "Email Account"), 'reqd': 1 }, { "fieldname": "submit", "fieldtype": "Button", - "label": __("Submit") + "label": __("Submit", null, "Submit password for Email Account") } ] }); diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 167b4955fa..1b30726a7a 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -534,22 +534,21 @@ export default { }); }, show_google_drive_picker() { - let dialog = cur_dialog; - dialog.hide(); + this.close_dialog = true; let google_drive = new GoogleDrivePicker({ - pickerCallback: data => this.google_drive_callback(data, dialog), + pickerCallback: data => this.google_drive_callback(data), ...this.google_drive_settings }); google_drive.loadPicker(); }, - google_drive_callback(data, dialog) { + google_drive_callback(data) { if (data.action == google.picker.Action.PICKED) { this.upload_file({ file_url: data.docs[0].url, file_name: data.docs[0].name }); } else if (data.action == google.picker.Action.CANCEL) { - dialog.show(); + cur_frm.attachments.new_attachment() } }, url_to_file(url, filename, mime_type) { diff --git a/frappe/public/js/frappe/form/controls/attach.js b/frappe/public/js/frappe/form/controls/attach.js index bd66225171..a91058a208 100644 --- a/frappe/public/js/frappe/form/controls/attach.js +++ b/frappe/public/js/frappe/form/controls/attach.js @@ -37,8 +37,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro if(this.frm) { me.parse_validate_and_set_in_model(null); me.refresh(); - me.frm.attachments.remove_attachment_by_filename(me.value, function() { - me.parse_validate_and_set_in_model(null); + me.frm.attachments.remove_attachment_by_filename(me.value, async () => { + await me.parse_validate_and_set_in_model(null); me.refresh(); me.frm.doc.docstatus == 1 ? me.frm.save('Update') : me.frm.save(); }); @@ -110,9 +110,9 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro return this.value || null; } - on_upload_complete(attachment) { + async on_upload_complete(attachment) { if(this.frm) { - this.parse_validate_and_set_in_model(attachment.file_url); + await this.parse_validate_and_set_in_model(attachment.file_url); this.frm.attachments.update_attachment(attachment); this.frm.doc.docstatus == 1 ? this.frm.save('Update') : this.frm.save(); } diff --git a/frappe/public/js/frappe/form/controls/autocomplete.js b/frappe/public/js/frappe/form/controls/autocomplete.js index 1bc0ffeb8a..a509af4121 100644 --- a/frappe/public/js/frappe/form/controls/autocomplete.js +++ b/frappe/public/js/frappe/form/controls/autocomplete.js @@ -11,7 +11,26 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui set_options() { if (this.df.options) { let options = this.df.options || []; - this._data = this.parse_options(options); + this.set_data(options); + } + } + + format_for_input(value) { + if (value == null) { + return ""; + } else if (this._data && this._data.length) { + const item = this._data.find(i => i.value == value); + return item ? item.label : value; + } else { + return value; + } + } + + get_input_value() { + if (this.$input) { + const label = this.$input.val(); + const item = this._data?.find(i => i.label == label); + return item ? item.value : label; } } @@ -23,7 +42,7 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui autoFirst: true, list: this.get_data(), data: function(item) { - if (!(item instanceof Object)) { + if (typeof item !== 'object') { var d = { value: item }; item = d; } @@ -65,6 +84,18 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui }; } + init_option_cache() { + if (!this.$input.cache) { + this.$input.cache = {}; + } + if (!this.$input.cache[this.doctype]) { + this.$input.cache[this.doctype] = {}; + } + if (!this.$input.cache[this.doctype][this.df.fieldname]) { + this.$input.cache[this.doctype][this.df.fieldname] = {}; + } + } + setup_awesomplete() { this.awesomplete = new Awesomplete( this.input, @@ -75,12 +106,18 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui .find('.awesomplete ul') .css('min-width', '100%'); - this.$input.on( - 'input', - frappe.utils.debounce(() => { + this.init_option_cache(); + + this.$input.on('input', frappe.utils.debounce((e) => { + const cached_options = this.$input.cache[this.doctype][this.df.fieldname][e.target.value]; + if (cached_options && cached_options.length) { + this.set_data(cached_options); + } else if (this.get_query || this.df.get_query) { + this.execute_query_if_exists(e.target.value); + } else { this.awesomplete.list = this.get_data(); - }, 500) - ); + } + }, 500)); this.$input.on('focus', () => { if (!this.$input.val()) { @@ -89,6 +126,17 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui } }); + this.$input.on("blur", () => { + if(this.selected) { + this.selected = false; + return; + } + var value = this.get_input_value(); + if(value!==this.last_value) { + this.parse_validate_and_set_in_model(value); + } + }); + this.$input.on("awesomplete-open", () => { this.autocomplete_open = true; }); @@ -118,6 +166,9 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui } parse_options(options) { + if (typeof options === 'string' && options[0] === '[') { + options = frappe.utils.parse_json(options); + } if (typeof options === 'string') { options = options.split('\n'); } @@ -127,6 +178,75 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui return options; } + execute_query_if_exists(term) { + const args = { txt: term }; + let get_query = this.get_query || this.df.get_query; + + if (!get_query) { + return; + } + + let set_nulls = function(obj) { + $.each(obj, function(key, value) { + if (value !== undefined) { + obj[key] = value; + } + }); + return obj; + }; + + let process_query_object = function(obj) { + if (obj.query) { + args.query = obj.query; + } + + if (obj.params) { + set_nulls(obj.params); + Object.assign(args, obj.params); + } + + // turn off value translation + if (obj.translate_values !== undefined) { + this.translate_values = obj.translate_values; + } + }; + + if ($.isPlainObject(get_query)) { + process_query_object(get_query); + } else if (typeof get_query === "string") { + args.query = get_query; + } else { + // get_query by function + var q = get_query( + (this.frm && this.frm.doc) || this.doc, + this.doctype, + this.docname + ); + + if (typeof q === "string") { + // returns a string + args.query = q; + } else if ($.isPlainObject(q)) { + // returns an object + process_query_object(q); + } + } + + if (args.query) { + frappe.call({ + method: args.query, + args: args, + callback: ({ message }) => { + if(!this.$input.is(":focus")) { + return; + } + this.$input.cache[this.doctype][this.df.fieldname][term] = message; + this.set_data(message); + } + }) + } + } + get_data() { return this._data || []; } diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index ce871c50cb..4ee52d16b8 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -39,6 +39,9 @@ frappe.ui.form.Control = class BaseControl { if (this.df.get_status) { return this.df.get_status(this); } + if (this.df.is_virtual) { + return "Read"; + } if ((!this.doctype && !this.docname) || this.df.parenttype === 'Web Form' || this.df.is_web_form) { // like in case of a dialog box @@ -52,7 +55,7 @@ frappe.ui.form.Control = class BaseControl { if(explain) console.log("By Hidden Dependency: None"); // eslint-disable-line no-console return "None"; - } else if (cint(this.df.read_only)) { + } else if (cint(this.df.read_only || this.df.is_virtual)) { // eslint-disable-next-line if (explain) console.log("By Read Only: Read"); // eslint-disable-line no-console return "Read"; diff --git a/frappe/public/js/frappe/form/controls/date.js b/frappe/public/js/frappe/form/controls/date.js index 7ad1887d62..0f80371706 100644 --- a/frappe/public/js/frappe/form/controls/date.js +++ b/frappe/public/js/frappe/form/controls/date.js @@ -158,8 +158,10 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat return value; } get_df_options() { + let df_options = this.df.options; + if (!df_options) return {}; + let options = {}; - let df_options = this.df.options || ''; if (typeof df_options === 'string') { try { options = JSON.parse(df_options); diff --git a/frappe/public/js/frappe/form/controls/date_range.js b/frappe/public/js/frappe/form/controls/date_range.js index 727e9d55c2..170404f575 100644 --- a/frappe/public/js/frappe/form/controls/date_range.js +++ b/frappe/public/js/frappe/form/controls/date_range.js @@ -11,7 +11,8 @@ frappe.ui.form.ControlDateRange = class ControlDateRange extends frappe.ui.form. language: "en", range: true, autoClose: true, - toggleSelected: false + toggleSelected: false, + firstDay: frappe.datetime.get_first_day_of_the_week_index() }; this.datepicker_options.dateFormat = (frappe.boot.sysdefaults.date_format || 'yyyy-mm-dd'); diff --git a/frappe/public/js/frappe/form/controls/dynamic_link.js b/frappe/public/js/frappe/form/controls/dynamic_link.js index 2c5661ca87..ea9ceb35f3 100644 --- a/frappe/public/js/frappe/form/controls/dynamic_link.js +++ b/frappe/public/js/frappe/form/controls/dynamic_link.js @@ -2,7 +2,7 @@ frappe.ui.form.ControlDynamicLink = class ControlDynamicLink extends frappe.ui.f get_options() { let options = ''; if (this.df.get_options) { - options = this.df.get_options(); + options = this.df.get_options(this); } else if (this.docname==null && cur_dialog) { //for dialog box options = cur_dialog.get_value(this.df.options); diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index ed355cf8b4..2295cad41a 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -29,7 +29,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat setTimeout(function() { if(me.$input.val() && me.get_options()) { let doctype = me.get_options(); - let name = me.$input.val(); + let name = me.get_input_value(); me.$link.toggle(true); me.$link_open.attr('href', frappe.utils.get_form_link(doctype, name)); } @@ -69,6 +69,59 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat this.$input_area.find(".link-btn").remove(); } } + set_formatted_input(value) { + super.set_formatted_input(); + if (!value) return; + + if (!this.title_value_map) { + this.title_value_map = {}; + } + this.set_link_title(value); + } + set_link_title(value) { + let doctype = this.get_options(); + + if (!doctype) return; + + if (in_list(frappe.boot.link_title_doctypes, doctype)) { + let link_title = frappe.utils.get_link_title(doctype, value); + if (!link_title) { + link_title = frappe.utils + .fetch_link_title(doctype, value) + .then(link_title => { + this.set_input_value(link_title); + this.title_value_map[link_title] = value; + }); + } else { + this.set_input_value(link_title); + this.title_value_map[link_title] = value; + } + } else { + this.set_input_value(value); + } + } + parse_validate_and_set_in_model(value, e, label) { + if (this.parse) value = this.parse(value, label); + if (label) { + this.label = label; + frappe.utils.add_link_title(this.df.options, value, label); + } + + return this.validate_and_set_in_model(value, e); + } + get_input_value() { + if (this.$input) { + const input_value = this.$input.val(); + return this.title_value_map?.[input_value] || input_value; + } + return null; + } + get_label_value() { + return this.$input ? this.$input.val() : ""; + } + set_input_value(value) { + this.$input && this.$input.val(value); + } open_advanced_search() { var doctype = this.get_options(); if(!doctype) return; @@ -98,7 +151,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } // partially entered name field - frappe.route_options.name_field = this.get_value(); + frappe.route_options.name_field = this.get_label_value(); // reference to calling link frappe._from_link = this; @@ -120,6 +173,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat maxItems: 99, autoFirst: true, list: [], + replace: function (suggestion) { + // Override Awesomeplete replace function as it is used to set the input value + // https://github.com/LeaVerou/awesomplete/issues/17104#issuecomment-359185403 + this.input.value = suggestion.label || suggestion.value; + }, data: function (item) { return { label: item.label || item.value, @@ -236,9 +294,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat me.selected = false; return; } - var value = me.get_input_value(); - if(value!==me.last_value) { - me.parse_validate_and_set_in_model(value); + let value = me.get_input_value(); + let label = me.get_label_value(); + + if (value !== me.last_value || me.label !== label) { + me.parse_validate_and_set_in_model(value, null, label); } }); @@ -258,14 +318,15 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat // prevent selection on tab var TABKEY = 9; - if(e.keyCode === TABKEY) { + if (e.keyCode === TABKEY) { e.preventDefault(); me.awesomplete.close(); return false; } - if(item.action) { + if (item.action) { item.value = ""; + item.label = ""; item.action.apply(me); } @@ -277,12 +338,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat frappe.boot.user.last_selected_values[me.df.options] = item.value; } - me.parse_validate_and_set_in_model(item.value); + me.parse_validate_and_set_in_model(item.value, null, item.label); }); this.$input.on("awesomplete-selectcomplete", function(e) { - var o = e.originalEvent; - if(o.text.value.indexOf("__link_option") !== -1) { + let o = e.originalEvent; + if (o.text.value.indexOf("__link_option") !== -1) { me.$input.val(""); } }); @@ -374,10 +435,22 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } set_custom_query(args) { - var set_nulls = function(obj) { - $.each(obj, function(key, value) { - if(value!==undefined) { - obj[key] = value; + const is_valid_value = (value, key) => { + if (value) return true; + // check if empty value is valid + if (this.frm) { + let field = frappe.meta.get_docfield(this.frm.doctype, key); + // empty value link fields is invalid + return !field || !["Link", "Dynamic Link"].includes(field.fieldtype); + } else { + return value !== undefined; + } + } + + const set_nulls = (obj) => { + $.each(obj, (key, value) => { + if (!is_valid_value(value, key)) { + delete obj[key]; } }); return obj; @@ -458,7 +531,6 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat validate_link_and_fetch(df, options, docname, value) { if (!options) return; - let field_value = ""; const fetch_map = this.fetch_map; const columns_to_fetch = Object.values(fetch_map); @@ -467,16 +539,10 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat 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; - + function update_dependant_fields(response) { + let field_value = ""; 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, @@ -485,9 +551,23 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat df.fieldtype, ); } + } - return response.name; - }); + // to avoid unnecessary request + if (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; + update_dependant_fields(response); + return response.name; + }); + } else { + update_dependant_fields({}); + return value; + } } get fetch_map() { diff --git a/frappe/public/js/frappe/form/controls/multiselect_pills.js b/frappe/public/js/frappe/form/controls/multiselect_pills.js index b4a1ecf30d..bf93ac0dd8 100644 --- a/frappe/public/js/frappe/form/controls/multiselect_pills.js +++ b/frappe/public/js/frappe/form/controls/multiselect_pills.js @@ -83,15 +83,21 @@ frappe.ui.form.ControlMultiSelectPills = class ControlMultiSelectPills extends f } get_pill_html(value) { + const label = this.get_label(value); const encoded_value = encodeURIComponent(value); return ` `; } + get_label(value) { + const item = this._data?.find(d => d.value === value); + return item ? item.label || item.value : null; + } + get_awesomplete_settings() { const settings = super.get_awesomplete_settings(); diff --git a/frappe/public/js/frappe/form/controls/table.js b/frappe/public/js/frappe/form/controls/table.js index d8fb4bb0e9..5b7cf9421e 100644 --- a/frappe/public/js/frappe/form/controls/table.js +++ b/frappe/public/js/frappe/form/controls/table.js @@ -92,7 +92,7 @@ frappe.ui.form.ControlTable = class ControlTable extends frappe.ui.form.Control if (frappe.model.no_value_type.includes(field.fieldtype)) { return false; } - + const is_field_matching = () => { return ( field.fieldname.toLowerCase() === field_name || diff --git a/frappe/public/js/frappe/form/controls/table_multiselect.js b/frappe/public/js/frappe/form/controls/table_multiselect.js index 15dfd9649e..477679bc92 100644 --- a/frappe/public/js/frappe/form/controls/table_multiselect.js +++ b/frappe/public/js/frappe/form/controls/table_multiselect.js @@ -49,7 +49,7 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f setup_buttons() { this.$input_area.find('.link-btn').remove(); } - parse(value) { + parse(value, label) { const link_field = this.get_link_field(); if (value) { @@ -62,6 +62,7 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f [link_field.fieldname]: value }); } + frappe.utils.add_link_title(link_field.options, value, label); } this._rows_list = this.rows.map(row => row[link_field.fieldname]); return this.rows; @@ -126,10 +127,12 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f this.$input_area.prepend(html); } get_pill_html(value) { + const link_field = this.get_link_field(); const encoded_value = encodeURIComponent(value); + const pill_name = frappe.utils.get_link_title(link_field.options, value) || value; return ` `; diff --git a/frappe/public/js/frappe/form/controls/text_editor.js b/frappe/public/js/frappe/form/controls/text_editor.js index f9ee15692c..faf803ee54 100644 --- a/frappe/public/js/frappe/form/controls/text_editor.js +++ b/frappe/public/js/frappe/form/controls/text_editor.js @@ -88,6 +88,9 @@ frappe.ui.form.ControlTextEditor = class ControlTextEditor extends frappe.ui.for make_quill_editor() { if (this.quill) return; this.quill_container = $('
').appendTo(this.input_area); + if (this.df.max_height) { + $(this.quill_container).css({'max-height': this.df.max_height, 'overflow': 'auto'}); + } this.quill = new Quill(this.quill_container[0], this.get_quill_options()); this.bind_events(); } diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index df4dbf09e7..6e3dd3eb0b 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -549,14 +549,14 @@ frappe.ui.form.Dashboard = class FormDashboard { render_graph(args) { this.chart_area.show(); this.chart_area.body.empty(); - $.extend({ + $.extend(args, { type: 'line', colors: ['green'], truncateLegends: 1, axisOptions: { shortenYAxisNumbers: 1 } - }, args); + }); this.show(); this.chart = new frappe.Chart('.form-graph', args); diff --git a/frappe/public/js/frappe/form/footer/base_timeline.js b/frappe/public/js/frappe/form/footer/base_timeline.js index beeba16459..ba7a4eb565 100644 --- a/frappe/public/js/frappe/form/footer/base_timeline.js +++ b/frappe/public/js/frappe/form/footer/base_timeline.js @@ -12,8 +12,11 @@ class BaseTimeline { this.wrapper = this.timeline_wrapper; this.timeline_items_wrapper = $(`
`); this.timeline_actions_wrapper = $(` -
-
+
+
+
+
+
`); @@ -37,7 +40,7 @@ class BaseTimeline { ${label} `); action_btn.click(action); - this.timeline_actions_wrapper.append(action_btn); + this.timeline_actions_wrapper.find('.action-buttons').append(action_btn); return action_btn; } diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index f278d1b64b..0070d384d7 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -77,12 +77,14 @@ class FormTimeline extends BaseTimeline { const message = __("Add to this activity by mailing to {0}", [link.bold()]); this.document_email_link_wrapper = $(` -
-

+ ${__('Add / Remove Columns')} @@ -403,18 +461,18 @@ export default class GridRow { data-label='${docfield.label}' data-type='${docfield.fieldtype}'>

-
+ -
+
${__(docfield.label)}
+ data-fieldname='${docfield.fieldname}' style='background-color: var(--modal-bg); display: inline'>
-
+
@@ -530,6 +588,7 @@ export default class GridRow { setup_columns() { this.focus_set = false; + this.search_columns = {}; this.grid.setup_visible_columns(); this.grid.visible_columns.forEach((col, ci) => { @@ -545,8 +604,10 @@ export default class GridRow { txt = __(txt); } let column; - if (!this.columns[df.fieldname]) { + if (!this.columns[df.fieldname] && !this.show_search) { column = this.make_column(df, colsize, txt, ci); + } else if (!this.columns[df.fieldname] && this.show_search) { + column = this.make_search_column(df, colsize); } else { column = this.columns[df.fieldname]; this.refresh_field(df.fieldname, txt); @@ -564,6 +625,77 @@ export default class GridRow { } } }); + + if (this.show_search) { + // last empty column + $(`
`) + .appendTo(this.row); + } + } + + show_search_row() { + // show or remove search columns based on grid rows + this.show_search = this.frm && this.frm.doc && + this.frm.doc[this.grid.df.fieldname] && + this.frm.doc[this.grid.df.fieldname].length >= 20; + !this.show_search && this.wrapper.remove(); + return this.show_search; + } + + make_search_column(df, colsize) { + let title = ""; + let input_class = ""; + let is_disabled = ""; + + if (["Text", "Small Text"].includes(df.fieldtype)) { + input_class = "grid-overflow-no-ellipsis"; + } else if (["Int", "Currency", "Float", "Percent"].includes(df.fieldtype)) { + input_class = "text-right"; + } else if (df.fieldtype === "Check") { + title = __("1 = True & 0 = False"); + input_class = "text-center"; + } else if (df.fieldtype === 'Password') { + is_disabled = 'disabled'; + title = __('Password cannot be filtered'); + } + + let $col = $('') + .appendTo(this.row); + + let $search_input = $(` + + `).appendTo($col); + + this.search_columns[df.fieldname] = $col; + + $search_input.on('keyup', frappe.utils.debounce((e) => { + this.grid.filter[df.fieldname] = { + df: df, + value: e.target.value + }; + + if (e.target.value == '') { + delete this.grid.filter[df.fieldname]; + } + + this.grid.grid_sortable + .option('disabled', Object.keys(this.grid.filter).length !== 0); + + this.grid.prevent_build = true; + this.grid.refresh(); + this.grid.prevent_build = false; + }, 500)); + + ["Currency", "Float", "Int", "Percent", "Rating"].includes(df.fieldtype) && + frappe.utils.only_allow_num_decimal($search_input); + + return $col; } make_column(df, colsize, txt, ci) { @@ -599,6 +731,7 @@ export default class GridRow { if (!this.doc) { $col.attr("title", txt); } + df.fieldname && $col.static_area.toggleClass('reqd', Boolean(df.reqd)); $col.df = df; $col.column_index = ci; diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 0de6b1db0d..578956f0ca 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -98,7 +98,7 @@ frappe.ui.form.Layout = class Layout { // remove previous color this.message.removeClass(this.message_color); } - this.message_color = (color && ['yellow', 'blue'].includes(color)) ? color : 'blue'; + this.message_color = (color && ['yellow', 'blue', 'red'].includes(color)) ? color : 'blue'; if (html) { if (html.substr(0, 1)!=='<') { // wrap in a block @@ -547,24 +547,28 @@ frappe.ui.form.Layout = class Layout { } refresh_dependency() { - // Resolve "depends_on" and show / hide accordingly + /** + Resolve "depends_on" and show / hide accordingly + build dependants' dictionary + */ - // build dependants' dictionary let has_dep = false; - for (let fkey in this.fields_list) { - let f = this.fields_list[fkey]; - f.dependencies_clear = true; + const fields = this.fields_list.concat(this.tabs); + + for (let fkey in fields) { + let f = fields[fkey]; if (f.df.depends_on || f.df.mandatory_depends_on || f.df.read_only_depends_on) { has_dep = true; + break; } } if (!has_dep) return; // show / hide based on values - for (let i = this.fields_list.length - 1; i >= 0; i--) { - let f = this.fields_list[i]; + for (let i = fields.length - 1; i >= 0; i--) { + let f = fields[i]; f.guardian_has_value = true; if (f.df.depends_on) { // evaluate guardian diff --git a/frappe/public/js/frappe/form/linked_with.js b/frappe/public/js/frappe/form/linked_with.js index 20db7bdb7c..c47a6e0c86 100644 --- a/frappe/public/js/frappe/form/linked_with.js +++ b/frappe/public/js/frappe/form/linked_with.js @@ -1,9 +1,8 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// MIT License. See license.txt +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +// MIT License. See LICENSE frappe.ui.form.LinkedWith = class LinkedWith { - constructor(opts) { $.extend(this, opts); } @@ -21,29 +20,23 @@ frappe.ui.form.LinkedWith = class LinkedWith { } make_dialog() { - this.dialog = new frappe.ui.Dialog({ title: __("Linked With") }); this.dialog.on_page_show = () => { - // execute ajax calls sequentially - // 1. get linked doctypes - // 2. load all doctypes - // 3. load linked docs - this.get_linked_doctypes() - .then(() => this.load_doctypes()) - .then(() => this.links_not_permitted_or_missing()) - .then(() => this.get_linked_docs()) - .then(() => this.make_html()); + frappe.xcall( + "frappe.desk.form.linked_with.get", + {"doctype": cur_frm.doctype, "docname": cur_frm.docname}, + ).then(r => { + this.frm.__linked_docs = r; + }).then(() => this.make_html()); }; } make_html() { - const linked_docs = this.frm.__linked_docs; - let html = ''; - + const linked_docs = this.frm.__linked_docs; const linked_doctypes = Object.keys(linked_docs); if (linked_doctypes.length === 0) { @@ -63,88 +56,6 @@ frappe.ui.form.LinkedWith = class LinkedWith { $(this.dialog.body).html(html); } - load_doctypes() { - const already_loaded = Object.keys(locals.DocType); - let doctypes_to_load = []; - - if (this.frm.__linked_doctypes) { - doctypes_to_load = - Object.keys(this.frm.__linked_doctypes) - .filter(doctype => !already_loaded.includes(doctype)); - } - - // load all doctypes asynchronously using with_doctype - const promises = doctypes_to_load.map(dt => { - return frappe.model.with_doctype(dt, () => { - if(frappe.listview_settings[dt]) { - // add additional fields to __linked_doctypes - this.frm.__linked_doctypes[dt].add_fields = - frappe.listview_settings[dt].add_fields; - } - }); - }); - - return Promise.all(promises); - } - - links_not_permitted_or_missing() { - let links = null; - - if (this.frm.__linked_doctypes) { - links = - Object.keys(this.frm.__linked_doctypes) - .filter(frappe.model.can_get_report); - } - - let flag; - if(!links) { - $(this.dialog.body).html(`${this.frm.__linked_doctypes - ? __("Not enough permission to see links") - : __("Not Linked to any record")}`); - flag = true; - } - flag = false; - - // reject Promise if not_permitted or missing - return new Promise( - (resolve, reject) => flag ? reject() : resolve() - ); - } - - get_linked_doctypes() { - return new Promise((resolve) => { - if (this.frm.__linked_doctypes) { - resolve(); - } - - frappe.call({ - method: "frappe.desk.form.linked_with.get_linked_doctypes", - args: { - doctype: this.frm.doctype - }, - callback: (r) => { - this.frm.__linked_doctypes = r.message; - resolve(); - } - }); - }); - } - - get_linked_docs() { - return frappe.call({ - method: "frappe.desk.form.linked_with.get_linked_docs", - args: { - doctype: this.frm.doctype, - name: this.frm.docname, - linkinfo: this.frm.__linked_doctypes, - for_doctype: this.for_doctype - }, - callback: (r) => { - this.frm.__linked_docs = r.message || {}; - } - }); - } - make_doc_head(heading) { return `
diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index bc0286e62d..61922a2422 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -1,6 +1,6 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { constructor(opts) { - /* Options: doctype, target, setters, get_query, action, add_filters_group, data_fields, primary_action_label */ + /* Options: doctype, target, setters, get_query, action, add_filters_group, data_fields, primary_action_label, columns */ Object.assign(this, opts); this.for_select = this.doctype == "[Select]"; if (!this.for_select) { @@ -150,8 +150,12 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { }); } + is_child_selection_enabled() { + return this.dialog.fields_dict['allow_child_item_selection'].get_value(); + } + toggle_child_selection() { - if (this.dialog.fields_dict['allow_child_item_selection'].get_value()) { + if (this.is_child_selection_enabled()) { this.show_child_results(); } else { this.child_results = []; @@ -289,7 +293,11 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { parent: this.dialog.get_field('filter_area').$wrapper, doctype: this.doctype, on_change: () => { - this.get_results(); + if (this.is_child_selection_enabled()) { + this.show_child_results(); + } else { + this.get_results(); + } } }); // 'Apply Filter' breaks since the filers are not in a popover @@ -325,7 +333,11 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.$parent.find('.input-with-feedback').on('change', () => { frappe.flags.auto_scroll = false; - this.get_results(); + if (this.is_child_selection_enabled()) { + this.show_child_results(); + } else { + this.get_results(); + } }); this.$parent.find('[data-fieldtype="Data"]').on('input', () => { @@ -333,8 +345,12 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { clearTimeout($this.data('timeout')); $this.data('timeout', setTimeout(function () { frappe.flags.auto_scroll = false; - me.empty_list(); - me.get_results(); + if (me.is_child_selection_enabled()) { + me.show_child_results(); + } else { + me.empty_list(); + me.get_results(); + } }, 300)); }); } @@ -384,23 +400,22 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { return this.results.filter(res => checked_values.includes(res.name)); } + get_datatable_columns() { + if (this.get_query && this.get_query().query && this.columns) return this.columns; + + if (Array.isArray(this.setters)) + return ["name", ...this.setters.map(df => df.fieldname)]; + + return ["name", ...Object.keys(this.setters)]; + } + make_list_row(result = {}) { var me = this; // Make a head row by default (if result not passed) let head = Object.keys(result).length === 0; let contents = ``; - let columns = ["name"]; - - if ($.isArray(this.setters)) { - for (let df of this.setters) { - columns.push(df.fieldname); - } - } else { - columns = columns.concat(Object.keys(this.setters)); - } - - columns.forEach(function (column) { + this.get_datatable_columns().forEach(function (column) { contents += `
${ head ? `${__(frappe.model.unscrub(column))}` @@ -470,7 +485,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { get_filters_from_setters() { let me = this; - let filters = this.get_query ? this.get_query().filters : {} || {}; + let filters = (this.get_query ? this.get_query().filters : {}) || {}; let filter_fields = []; if ($.isArray(this.setters)) { diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index e412b1dec8..86523d7088 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -55,7 +55,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm { // prepare a list of mandatory, bold and allow in quick entry fields this.mandatory = fields.filter(df => { - return ((df.reqd || df.bold || df.allow_in_quick_entry) && !df.read_only); + return ((df.reqd || df.bold || df.allow_in_quick_entry) && !df.read_only && !df.is_virtual); }); } diff --git a/frappe/public/js/frappe/form/save.js b/frappe/public/js/frappe/form/save.js index 65d84e2202..90516b7c0a 100644 --- a/frappe/public/js/frappe/form/save.js +++ b/frappe/public/js/frappe/form/save.js @@ -7,12 +7,12 @@ frappe.ui.form.save = function (frm, action, callback, btn) { $(btn).prop("disabled", true); // specified here because there are keyboard shortcuts to save - var working_label = { - "Save": __("Saving"), - "Submit": __("Submitting"), - "Update": __("Updating"), - "Amend": __("Amending"), - "Cancel": __("Cancelling") + const working_label = { + "Save": __("Saving", null, "Freeze message while saving a document"), + "Submit": __("Submitting", null, "Freeze message while submitting a document"), + "Update": __("Updating", null, "Freeze message while updating a document"), + "Amend": __("Amending", null, "Freeze message while amending a document"), + "Cancel": __("Cancelling", null, "Freeze message while cancelling a document"), }[toTitle(action)]; var freeze_message = working_label ? __(working_label) : ""; @@ -148,14 +148,15 @@ frappe.ui.form.save = function (frm, action, callback, btn) { }); if (frm.is_new() && frm.meta.autoname === 'Prompt' && !frm.doc.__newname) { + has_errors = true; error_fields = [__('Name'), ...error_fields]; } if (error_fields.length) { let meta = frappe.get_meta(doc.doctype); if (meta.istable) { - var message = __('Mandatory fields required in table {0}, Row {1}', - [__(frappe.meta.docfield_map[doc.parenttype][doc.parentfield].label).bold(), doc.idx]); + const table_label = __(frappe.meta.docfield_map[doc.parenttype][doc.parentfield].label).bold(); + var message = __('Mandatory fields required in table {0}, Row {1}', [table_label, doc.idx]); } else { var message = __('Mandatory fields required in {0}', [__(doc.doctype)]); } @@ -249,31 +250,39 @@ frappe.ui.form.update_calling_link = (newdoc) => { }; if (is_valid_doctype()) { - // set value - if (doc && doc.parentfield) { - //update values for child table - $.each(frappe._from_link.frm.fields_dict[doc.parentfield].grid.grid_rows, function (index, field) { - if (field.doc && field.doc.name === frappe._from_link.docname) { - frappe._from_link.set_value(newdoc.name); - } - }); - } else { - frappe._from_link.set_value(newdoc.name); - } - - // refresh field - frappe._from_link.refresh(); - - // if from form, switch - if (frappe._from_link.frm) { - frappe.set_route("Form", - frappe._from_link.frm.doctype, frappe._from_link.frm.docname) - .then(() => { - frappe.utils.scroll_to(frappe._from_link_scrollY); + frappe.model.with_doctype(newdoc.doctype, () => { + let meta = frappe.get_meta(newdoc.doctype); + // set value + if (doc && doc.parentfield) { + //update values for child table + $.each(frappe._from_link.frm.fields_dict[doc.parentfield].grid.grid_rows, function (index, field) { + if (field.doc && field.doc.name === frappe._from_link.docname) { + if (meta.title_field && meta.show_title_field_in_link) { + frappe.utils.add_link_title(newdoc.doctype, newdoc.name, newdoc[meta.title_field]); + } + frappe._from_link.set_value(newdoc.name); + } }); - } + } else { + if (meta.title_field && meta.show_title_field_in_link) { + frappe.utils.add_link_title(newdoc.doctype, newdoc.name, newdoc[meta.title_field]); + } + frappe._from_link.set_value(newdoc.name); + } - frappe._from_link = null; + // refresh field + frappe._from_link.refresh(); + + // if from form, switch + if (frappe._from_link.frm) { + frappe.set_route("Form", + frappe._from_link.frm.doctype, frappe._from_link.frm.docname) + .then(() => { + frappe.utils.scroll_to(frappe._from_link_scrollY); + }); + } + + frappe._from_link = null; + }); } } - diff --git a/frappe/public/js/frappe/form/script_manager.js b/frappe/public/js/frappe/form/script_manager.js index d1732ee702..29f1c86d17 100644 --- a/frappe/public/js/frappe/form/script_manager.js +++ b/frappe/public/js/frappe/form/script_manager.js @@ -192,9 +192,18 @@ frappe.ui.form.ScriptManager = class ScriptManager { } function setup_add_fetch(df) { - if ((['Data', 'Read Only', 'Text', 'Small Text', 'Currency', 'Check', - 'Text Editor', 'Code', 'Link', 'Float', 'Int', 'Date', 'Select', 'Duration'].includes(df.fieldtype) || df.read_only==1) - && df.fetch_from && df.fetch_from.indexOf(".")!=-1) { + let is_read_only_field = ( + ['Data', 'Read Only', 'Text', 'Small Text', 'Currency', 'Check', 'Text Editor', 'Attach Image', + 'Code', 'Link', 'Float', 'Int', 'Date', 'Select', 'Duration'].includes(df.fieldtype) + || df.read_only == 1 + || df.is_virtual == 1 + ) + + if ( + is_read_only_field + && df.fetch_from + && df.fetch_from.indexOf(".") != -1 + ) { var parts = df.fetch_from.split("."); me.frm.add_fetch(parts[0], parts[1], df.fieldname, df.parent); } diff --git a/frappe/public/js/frappe/form/sidebar/attachments.js b/frappe/public/js/frappe/form/sidebar/attachments.js index 538534e5cf..0713d5dc43 100644 --- a/frappe/public/js/frappe/form/sidebar/attachments.js +++ b/frappe/public/js/frappe/form/sidebar/attachments.js @@ -44,8 +44,17 @@ frappe.ui.form.Attachments = class Attachments { // add attachment objects var attachments = this.get_attachments(); if(attachments.length) { - attachments.forEach(function(attachment) { - me.add_attachment(attachment) + let exists = {}; + let unique_attachments = attachments.filter(attachment => { + return Object.prototype.hasOwnProperty.call( + exists, + attachment.file_name + ) + ? false + : (exists[attachment.file_name] = true); + }); + unique_attachments.forEach(attachment => { + me.add_attachment(attachment); }); } else { this.attachments_label.removeClass("has-attachments"); @@ -75,7 +84,19 @@ frappe.ui.form.Attachments = class Attachments { remove_action = function(target_id) { frappe.confirm(__("Are you sure you want to delete the attachment?"), function() { - me.remove_attachment(target_id); + let target_attachment = me + .get_attachments() + .find(attachment => attachment.name === target_id); + let to_be_removed = me + .get_attachments() + .filter( + attachment => + attachment.file_name === + target_attachment.file_name + ); + to_be_removed.forEach(attachment => + me.remove_attachment(attachment.name) + ); } ); return false; diff --git a/frappe/public/js/frappe/form/tab.js b/frappe/public/js/frappe/form/tab.js index c8ca016398..0e740ce49c 100644 --- a/frappe/public/js/frappe/form/tab.js +++ b/frappe/public/js/frappe/form/tab.js @@ -40,7 +40,7 @@ export default class Tab { hide = true; } - hide && this.toggle(false); + this.toggle(!hide); } toggle(show) { diff --git a/frappe/public/js/frappe/form/templates/form_sidebar.html b/frappe/public/js/frappe/form/templates/form_sidebar.html index efa8b9ea5e..dcea2f4647 100644 --- a/frappe/public/js/frappe/form/templates/form_sidebar.html +++ b/frappe/public/js/frappe/form/templates/form_sidebar.html @@ -1,5 +1,5 @@ -
`); } @@ -119,8 +110,10 @@ frappe.views.Workspace = class Workspace { }); // Scroll sidebar to selected page if it is not in viewport. - !frappe.dom.is_element_in_viewport(this.sidebar.find('.selected')) + this.sidebar.find('.selected').length && !frappe.dom.is_element_in_viewport(this.sidebar.find('.selected')) && this.sidebar.find('.selected')[0].scrollIntoView(); + + this.remove_sidebar_skeleton(); } build_sidebar_section(title, root_pages) { @@ -164,7 +157,8 @@ frappe.views.Workspace = class Workspace { let child_items = pages.filter(page => page.parent_page == item.title); if (child_items.length > 0) { - let child_container = $(``); + let child_container = $item_container.find('.sidebar-child-item'); + child_container.addClass('hidden'); this.prepare_sidebar(child_items, child_container, $item_container); } @@ -179,18 +173,23 @@ frappe.views.Workspace = class Workspace { } add_drop_icon(item, sidebar_control, item_container) { + let drop_icon = 'small-down'; + if (item_container.find(`[item-name="${this.current_page.name}"]`).length) { + drop_icon = 'small-up'; + } + let $child_item_section = item_container.find('.sidebar-child-item'); - let $drop_icon = $(``) + let $drop_icon = $(``) .appendTo(sidebar_control); let pages = item.public ? this.public_pages : this.private_pages; if (pages.some(e => e.parent_page == item.title)) { $drop_icon.removeClass('hidden'); - $drop_icon.on('click', () => { - let icon = $drop_icon.find("use").attr("href")==="#icon-small-down" ? "#icon-small-up" : "#icon-small-down"; - $drop_icon.find("use").attr("href", icon); - $child_item_section.toggleClass("hidden"); - }); } + $drop_icon.on('click', () => { + let icon = $drop_icon.find("use").attr("href")==="#icon-small-down" ? "#icon-small-up" : "#icon-small-down"; + $drop_icon.find("use").attr("href", icon); + $child_item_section.toggleClass("hidden"); + }); } show() { @@ -203,22 +202,49 @@ frappe.views.Workspace = class Workspace { let page = this.get_page_to_show(); this.page.set_title(`${__(page.name)}`); + this.update_selected_sidebar(this.current_page, false); //remove selected from old page + this.update_selected_sidebar(page, true); //add selected on new page + this.show_page(page); } + update_selected_sidebar(page, add) { + let section = page.public ? 'public' : 'private'; + if (this.sidebar && this.sidebar_items[section] && this.sidebar_items[section][page.name]) { + let $sidebar = this.sidebar_items[section][page.name]; + let pages = page.public ? this.public_pages : this.private_pages; + let sidebar_page = pages.find(p => p.title == page.name); + + if (add) { + $sidebar[0].firstElementChild.classList.add("selected"); + if (sidebar_page) sidebar_page.selected = true; + + // open child sidebar section if closed + $sidebar.parent().hasClass('hidden') && + $sidebar.parent().removeClass('hidden'); + + this.current_page = { name: page.name, public: page.public }; + localStorage.current_page = page.name; + localStorage.is_current_page_public = page.public; + } else { + $sidebar[0].firstElementChild.classList.remove("selected"); + if (sidebar_page) sidebar_page.selected = false; + } + } + } + get_data(page) { - return frappe.xcall("frappe.desk.desktop.get_desktop_page", { + return frappe.call("frappe.desk.desktop.get_desktop_page", { page: page }).then(data => { - this.page_data = data; + this.page_data = data.message; // caching page data this.pages[page.name] && delete this.pages[page.name]; - this.pages[page.name] = data; + this.pages[page.name] = data.message; if (!this.page_data || Object.keys(this.page_data).length === 0) return; - - if (this.page_data.charts && this.page_data.charts.items.length === 0) return; + if (this.page_data.charts && this.page_data.charts.items.length === 0) return; return frappe.dashboard_utils.get_dashboard_settings().then(settings => { if (settings) { @@ -249,49 +275,35 @@ frappe.views.Workspace = class Workspace { } async show_page(page) { - let section = this.current_page.public ? 'public' : 'private'; - if (this.sidebar_items && this.sidebar_items[section] && this.sidebar_items[section][this.current_page.name]) { - this.sidebar_items[section][this.current_page.name][0].firstElementChild.classList.remove("selected"); - this.sidebar_items[page.public ? 'public':'private'][page.name][0].firstElementChild.classList.add("selected"); - - if (this.sidebar_items[page.public ? 'public':'private'][page.name].parents('.sidebar-item-container')[0]) { - this.sidebar_items[page.public ? 'public':'private'][page.name] - .parents('.sidebar-item-container') - .find('.drop-icon use') - .attr("href", "#icon-small-up"); - } - } - - this.current_page = { name: page.name, public: page.public }; - localStorage.current_page = page.name; - localStorage.is_current_page_public = page.public; - if (!this.body.find('#editorjs')[0]) { this.$page = $(`
`).appendTo(this.body); } - this.create_skeleton(); if (this.all_pages) { + this.create_page_skeleton(); + let pages = page.public ? this.public_pages : this.private_pages; - let this_page = pages.filter(p => p.title == page.name)[0]; - this.setup_actions(page); - this.content = this_page && JSON.parse(this_page.content); + let current_page = pages.filter(p => p.title == page.name)[0]; + this.content = current_page && JSON.parse(current_page.content); this.add_custom_cards_in_content(); $('.item-anchor').addClass('disable-click'); - if (this.pages && this.pages[this_page.name]) { - this.page_data = this.pages[this_page.name]; + if (this.pages && this.pages[current_page.name]) { + this.page_data = this.pages[current_page.name]; } else { - await this.get_data(this_page); + await frappe.after_ajax(() => this.get_data(current_page)); } + this.setup_actions(page); + this.prepare_editorjs(); $('.item-anchor').removeClass('disable-click'); - this.remove_skeleton(); + + this.remove_page_skeleton(); } } @@ -329,9 +341,7 @@ frappe.views.Workspace = class Workspace { return; } - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.page.clear_inner_toolbar(); + this.clear_page_actions(); current_page.is_editable && this.page.set_secondary_action(__("Edit"), async () => { if (!this.editor || !this.editor.readOnly) return; @@ -341,7 +351,6 @@ frappe.views.Workspace = class Workspace { this.initialize_editorjs_undo(); this.setup_customization_buttons(current_page); this.show_sidebar_actions(); - this.make_sidebar_sortable(); this.make_blocks_sortable(); }); }); @@ -357,22 +366,25 @@ frappe.views.Workspace = class Workspace { this.undo.readOnly = false; } - setup_customization_buttons(page) { - let me = this; + clear_page_actions() { this.page.clear_primary_action(); this.page.clear_secondary_action(); this.page.clear_inner_toolbar(); + } + + setup_customization_buttons(page) { + this.clear_page_actions(); page.is_editable && this.page.set_primary_action( - __("Save Customizations"), + __("Save"), () => { - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.page.clear_inner_toolbar(); - this.undo.readOnly = true; - this.save_page(); - this.editor.readOnly.toggle(); - this.is_read_only = true; + this.clear_page_actions(); + this.save_page(page).then((saved) => { + if (!saved) return; + this.undo.readOnly = true; + this.editor.readOnly.toggle(); + this.is_read_only = true; + }); }, null, __("Saving") @@ -382,11 +394,10 @@ frappe.views.Workspace = class Workspace { __("Discard"), async () => { this.discard = true; - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.page.clear_inner_toolbar(); + this.clear_page_actions(); await this.editor.readOnly.toggle(); this.is_read_only = true; + this.sidebar_pages = this.cached_pages; this.reload(); frappe.show_alert({ message: __("Customizations Discarded"), indicator: "info" }); } @@ -395,34 +406,30 @@ frappe.views.Workspace = class Workspace { page.name && this.page.add_inner_button(__("Settings"), () => { frappe.set_route(`workspace/${page.name}`); }); - - Object.keys(this.blocks).forEach(key => { - this.page.add_inner_button(` - ${this.blocks[key].toolbox.icon} - ${__(this.blocks[key].toolbox.title)} - `, function() { - const index = me.editor.blocks.getBlocksCount() + 1; - me.editor.blocks.insert(key, {}, {}, index, true); - me.editor.caret.setToLastBlock('start', 0); - $('.ce-block:last-child')[0].scrollIntoView(); - }, __('Add Block')); - }); } show_sidebar_actions() { this.sidebar.find('.standard-sidebar-section').addClass('show-control'); + this.make_sidebar_sortable(); } - add_sidebar_actions(item, sidebar_control) { + add_sidebar_actions(item, sidebar_control, is_new) { if (!item.is_editable) { - $(`${frappe.utils.icon("lock", "sm")}`) - .appendTo(sidebar_control); sidebar_control.parent().click(() => { !this.is_read_only && frappe.show_alert({ message: __("Only Workspace Manager can sort or edit this page"), indicator: 'info' }, 5); }); + + frappe.utils.add_custom_button( + frappe.utils.icon('duplicate', 'sm'), + () => this.duplicate_page(item), + "duplicate-page", + `${__('Duplicate Workspace')}`, + null, + sidebar_control + ); } else { frappe.utils.add_custom_button( frappe.utils.icon('drag', 'xs'), @@ -432,22 +439,378 @@ frappe.views.Workspace = class Workspace { null, sidebar_control ); - frappe.utils.add_custom_button( - frappe.utils.icon('delete', 'xs'), - () => this.delete_page(item), - "delete-page", - `${__('Delete')}`, - null, - sidebar_control - ); + + !is_new && this.add_settings_button(item, sidebar_control); } } - delete_page(item) { - frappe.confirm(__("Are you sure you want to delete page {0}?", [item.title]), () => { - this.deleted_sidebar_items.push(item); - this.sidebar.find(`.standard-sidebar-section [item-name="${item.title}"][item-public="${item.public}"]`).addClass('hidden'); + get_parent_pages(page) { + this.public_parent_pages = ['', ...this.public_pages.filter(p => !p.parent_page).map(p => p.title)]; + this.private_parent_pages = ['', ...this.private_pages.filter(p => !p.parent_page).map(p => p.title)]; + + if (page) { + return page.public ? this.public_parent_pages : this.private_parent_pages; + } + } + + edit_page(item) { + var me = this; + let old_item = item; + let parent_pages = this.get_parent_pages(item); + let idx = parent_pages.findIndex(x => x == item.title); + if (idx !== -1) parent_pages.splice(idx, 1); + const d = new frappe.ui.Dialog({ + title: __('Update Details'), + fields: [ + { + label: __('Title'), + fieldtype: 'Data', + fieldname: 'title', + reqd: 1, + default: item.title + }, + { + label: __('Parent'), + fieldtype: 'Select', + fieldname: 'parent', + options: parent_pages, + default: item.parent_page + }, + { + label: __('Public'), + fieldtype: 'Check', + fieldname: 'is_public', + depends_on: `eval:${this.has_access}`, + default: item.public, + onchange: function() { + d.set_df_property('parent', 'options', + this.get_value() ? me.public_parent_pages : me.private_parent_pages); + } + }, + { + fieldtype: 'Column Break' + }, + { + label: __('Icon'), + fieldtype: 'Icon', + fieldname: 'icon', + default: item.icon + }, + ], + primary_action_label: __('Update'), + primary_action: (values) => { + let is_title_changed = values.title != old_item.title; + let is_section_changed = values.is_public != old_item.public; + if ((is_title_changed || is_section_changed) && !this.validate_page(values, old_item)) return; + d.hide(); + + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.update_page", + args: { + name: old_item.name, + title: values.title, + icon: values.icon || '', + parent: values.parent || '', + public: values.is_public || 0, + }, + callback: function(res) { + if (res.message) { + let message = `Workspace ${old_item.title} Edited Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + this.update_sidebar(old_item, values); + + if (this.make_page_selected) { + let pre_url = values.is_public ? '' : 'private/'; + let route = pre_url + frappe.router.slug(values.title); + frappe.set_route(route); + + this.make_page_selected = false; + } + + this.make_sidebar(); + this.show_sidebar_actions(); + } }); + d.show(); + } + + update_sidebar(old_item, new_item) { + let is_section_changed = old_item.public != (new_item.is_public || 0); + let is_title_changed = old_item.title != new_item.title; + let new_updated_item = {...old_item}; + + let pages = old_item.public ? this.public_pages : this.private_pages; + + let child_items = pages.filter(page => page.parent_page == old_item.title); + + this.make_page_selected = old_item.selected; + + new_updated_item.title = new_item.title; + new_updated_item.icon = new_item.icon; + new_updated_item.parent_page = new_item.parent || ""; + new_updated_item.public = new_item.is_public; + + if (is_title_changed || is_section_changed) { + if (new_item.is_public) { + new_updated_item.name = new_item.title; + new_updated_item.label = new_item.title; + new_updated_item.for_user = ""; + } else { + let user = frappe.session.user; + new_updated_item.name = `${new_item.title}-${user}`; + new_updated_item.label = `${new_item.title}-${user}`; + new_updated_item.for_user = user; + } + } + this.update_cached_values(old_item, new_updated_item); + + if (child_items.length) { + child_items.forEach(child => { + child.parent_page = new_item.title; + is_section_changed && this.update_child_sidebar(child, new_item); + }); + } + } + + update_child_sidebar(child, new_item) { + let old_child = {...child}; + this.make_page_selected = child.selected; + + child.public = new_item.is_public; + if (new_item.is_public) { + child.name = child.title; + child.label = child.title; + child.for_user = ""; + } else { + let user = frappe.session.user; + child.name = `${child.title}-${user}`; + child.label = `${child.title}-${user}`; + child.for_user = user; + } + + this.update_cached_values(old_child, child); + } + + update_cached_values(old_item, new_item, duplicate, new_page) { + let [from_pages, to_pages] = old_item.public ? + [this.public_pages, this.private_pages] : [this.private_pages, this.public_pages]; + + let old_item_index = from_pages.findIndex(page => page.title == old_item.title); + duplicate && old_item_index++; + + // update frappe.workspaces + if (frappe.workspaces[frappe.router.slug(old_item.name)] || new_page) { + !duplicate && delete frappe.workspaces[frappe.router.slug(old_item.name)]; + if (new_item) { + frappe.workspaces[frappe.router.slug(new_item.name)] = {'title': new_item.title}; + } + } + + // update page block data + if (this.pages && this.pages[old_item.name] || new_page) { + if (new_item) { + this.pages[new_item.name] = this.pages[old_item.name] || {}; + } + !duplicate && delete this.pages[old_item.name]; + } + + // update public and private pages + if (new_item) { + let is_section_changed = old_item.public != (new_item.is_public || new_item.public || 0); + + if (is_section_changed) { + !duplicate && from_pages.splice(old_item_index, 1); + to_pages.push(new_item); + } else if (new_page) { + from_pages.push(new_item); + } else { + from_pages.splice(old_item_index, duplicate ? 0 : 1, new_item); + } + } else { + from_pages.splice(old_item_index, 1); + } + + this.sidebar_pages.pages = [...this.public_pages, ...this.private_pages]; + this.cached_pages = this.sidebar_pages; + } + + add_settings_button(item, sidebar_control) { + this.dropdown_list = [ + { + label: 'Edit', + title: 'Edit Workspace', + icon: frappe.utils.icon('edit', 'sm'), + action: () => this.edit_page(item) + }, + { + label: 'Duplicate', + title: 'Duplicate Workspace', + icon: frappe.utils.icon('duplicate', 'sm'), + action: () => this.duplicate_page(item) + }, + { + label: 'Delete', + title: 'Delete Workspace', + icon: frappe.utils.icon('delete-active', 'sm'), + action: () => this.delete_page(item) + } + ]; + + let $button = $(` + + + `); + + let dropdown_item = function(label, title, icon, action) { + let html = $(` + + `); + + html.click(event => { + event.stopPropagation(); + action && action(); + }); + + return html; + }; + + $button.filter('.dropdown-btn').click(event => { + event.stopPropagation(); + if ($button.filter('.dropdown-list.hidden').length) { + $('.dropdown-list:not(.hidden)').addClass('hidden'); + } + $button.filter('.dropdown-list').toggleClass('hidden'); + }); + + $(document).click(event => { + event.stopPropagation(); + $('.dropdown-list:not(.hidden)').addClass('hidden'); + }); + + sidebar_control.append($button); + + this.dropdown_list.forEach((i) => { + $button.filter('.dropdown-list').append(dropdown_item(i.label, i.title, i.icon, i.action)); + }); + } + + delete_page(page) { + frappe.confirm(__("Are you sure you want to delete page {0}?", [page.title]), () => { + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.delete_page", + args: { page: page }, + callback: function(res) { + if (res.message) { + let page = res.message; + let message = `Workspace ${page.title} Deleted Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + this.page.clear_primary_action(); + this.update_cached_values(page); + + if (this.current_page.name == page.title && this.current_page.public == page.public) { + frappe.set_route('/'); + } + + this.make_sidebar(); + this.show_sidebar_actions(); + }); + } + + duplicate_page(page) { + var me = this; + let parent_pages = this.get_parent_pages(page); + const d = new frappe.ui.Dialog({ + title: __('Create Duplicate'), + fields: [ + { + label: __('Title'), + fieldtype: 'Data', + fieldname: 'title', + reqd: 1 + }, + { + label: __('Parent'), + fieldtype: 'Select', + fieldname: 'parent', + options: parent_pages, + default: page.parent_page + }, + { + label: __('Public'), + fieldtype: 'Check', + fieldname: 'is_public', + depends_on: `eval:${this.has_access}`, + default: page.public, + onchange: function() { + d.set_df_property('parent', 'options', + this.get_value() ? me.public_parent_pages : me.private_parent_pages); + } + }, + { + fieldtype: 'Column Break' + }, + { + label: __('Icon'), + fieldtype: 'Icon', + fieldname: 'icon', + default: page.icon + }, + ], + primary_action_label: __('Duplicate'), + primary_action: (values) => { + if (!this.validate_page(values)) return; + d.hide(); + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.duplicate_page", + args: { + page_name: page.name, + new_page: values + }, + callback: function(res) { + if (res.message) { + let new_page = res.message; + let message = `Duplicate of ${page.title} named as ${new_page.title} is created successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + let new_page = {...page}; + + new_page.title = values.title; + new_page.public = values.is_public || 0; + new_page.name = values.title + (new_page.public ? '' : '-' + frappe.session.user); + new_page.label = new_page.name; + new_page.icon = values.icon; + new_page.parent_page = values.parent || ''; + new_page.for_user = new_page.public ? '' : frappe.session.user; + new_page.is_editable = !new_page.public; + new_page.selected = true; + + this.update_cached_values(page, new_page, true); + + let pre_url = values.is_public ? '' : 'private/'; + let route = pre_url + frappe.router.slug(values.title); + frappe.set_route(route); + + me.make_sidebar(); + me.show_sidebar_actions(); + } + }); + d.show(); } make_sidebar_sortable() { @@ -463,35 +826,75 @@ frappe.views.Workspace = class Workspace { onEnd: function (evt) { let is_public = $(evt.item).attr('item-public') == '1'; me.prepare_sorted_sidebar(is_public); + me.update_sorted_sidebar(); } }); }); } prepare_sorted_sidebar(is_public) { + let pages = is_public ? this.public_pages : this.private_pages; if (is_public) { - this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last()); + this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last(), pages); } else { - this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first()); + this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first(), pages); } + + this.sidebar_pages.pages = [...this.public_pages, ...this.private_pages]; + this.cached_pages = this.sidebar_pages; } - sort_sidebar($sidebar_section) { + sort_sidebar($sidebar_section, pages) { let sorted_items = []; - for (let page of $sidebar_section.find('.sidebar-item-container')) { + Array.from($sidebar_section.find('.sidebar-item-container')).forEach((page, i) => { let parent_page = ""; + if (page.closest('.nested-container').classList.contains('sidebar-child-item')) { parent_page = page.parentElement.parentElement.attributes["item-name"].value; } + sorted_items.push({ title: page.attributes['item-name'].value, parent_page: parent_page, public: page.attributes['item-public'].value }); - } + + let $drop_icon = $(page).find('.sidebar-item-control .drop-icon').first(); + if ($(page).find('.sidebar-child-item > *').length != 0) { + $drop_icon.removeClass('hidden'); + } else { + $drop_icon.addClass('hidden'); + } + + let from_index = pages.findIndex(p => p.title == page.attributes['item-name'].value); + let element = pages[from_index]; + element.parent_page = parent_page; + if (from_index != i) { + pages.splice(from_index, 1); + pages.splice(i, 0, element); + } + }); return sorted_items; } + update_sorted_sidebar() { + if (this.sorted_public_items || this.sorted_private_items) { + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.sort_pages", + args: { + sb_public_items: this.sorted_public_items, + sb_private_items: this.sorted_private_items, + }, + callback: function(res) { + if (res.message) { + let message = `Sidebar Updated Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + } + } + make_blocks_sortable() { let me = this; this.page_sortable = Sortable.create(this.page.main.find(".codex-editor__redactor").get(0), { @@ -508,11 +911,10 @@ frappe.views.Workspace = class Workspace { } initialize_new_page() { - this.public_parent_pages = ['', ...this.public_pages.filter(page => !page.parent_page).map(page => page.title)]; - this.private_parent_pages = ['', ...this.private_pages.filter(page => !page.parent_page).map(page => page.title)]; var me = this; + this.get_parent_pages(); const d = new frappe.ui.Dialog({ - title: __('Set Title'), + title: __('New Workspace'), fields: [ { label: __('Title'), @@ -551,81 +953,115 @@ frappe.views.Workspace = class Workspace { d.hide(); this.initialize_editorjs_undo(); this.setup_customization_buttons({is_editable: true}); - this.title = values.title; - this.icon = values.icon; - this.parent = values.parent; - this.public = values.is_public; + + let name = values.title + (values.is_public ? '' : '-' + frappe.session.user); + let blocks = [{ + type: "header", + data: { text: values.title } + }]; + + let new_page = { + content: JSON.stringify(blocks), + name: name, + label: name, + title: values.title, + public: values.is_public || 0, + for_user: values.is_public ? '' : frappe.session.user, + icon: values.icon, + parent_page: values.parent || '', + is_editable: true, + selected: true + }; + this.editor.render({ - blocks: [ - { - type: "header", - data: { - text: this.title, - level: 4 - } - } - ] + blocks: blocks }).then(async () => { if (this.editor.configuration.readOnly) { this.is_read_only = false; await this.editor.readOnly.toggle(); } - this.add_page_to_sidebar(values); + + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.new_page", + args: { + new_page: new_page + }, + callback: function(res) { + if (res.message) { + let message = `Workspace ${new_page.title} Created Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + this.update_cached_values(new_page, new_page, true, true); + + let pre_url = new_page.public ? '' : 'private/'; + let route = pre_url + frappe.router.slug(new_page.title); + frappe.set_route(route); + + this.make_sidebar(); this.show_sidebar_actions(); - this.make_sidebar_sortable(); - this.make_blocks_sortable(); - this.prepare_sorted_sidebar(values.is_public); }); } }); d.show(); } - validate_page(values) { + validate_page(new_page, old_page) { let message = ""; - let pages = values.is_public ? this.public_pages : this.private_pages; + let [from_pages, to_pages] = new_page.is_public ? + [this.private_pages, this.public_pages] : [this.public_pages, this.private_pages]; - if (pages && pages.filter(p => p.title == values.title)[0]) { - message = "Page with title '{0}' already exist."; - } else if (frappe.router.doctype_route_exist(frappe.router.slug(values.title))) { + let section = this.sidebar_categories[new_page.is_public]; + + if (to_pages && to_pages.filter(p => p.title == new_page.title)[0]) { + message = `Page with title ${new_page.title} already exist.`; + } + + if (frappe.router.doctype_route_exist(frappe.router.slug(new_page.title))) { message = "Doctype with same route already exist. Please choose different title."; } + let child_pages = old_page && from_pages.filter(p => p.parent_page == old_page.title); + if (child_pages) { + child_pages.every(child_page => { + if (to_pages && to_pages.find(p => p.title == child_page.title)) { + message = `One of the child page with name ${child_page.title} already exist in ${section} Section. Please update the name of the child page first before moving`; + cur_dialog.hide(); + return false; + } + return true; + }); + } + if (message) { - frappe.throw(__(message, [__(values.title)])); + frappe.throw(__(message)); return false; } return true; } - add_page_to_sidebar({title, icon, parent, is_public}) { + add_page_to_sidebar(page) { let $sidebar = $('.standard-sidebar-section'); - let item = { - title: title, - icon: icon, - parent_page: parent, - public: is_public - }; - let $sidebar_item = this.sidebar_item_container(item); - $sidebar_item.addClass('is-draggable'); + let item = {...page}; + + item.selected = true; + item.is_editable = true; + + let $sidebar_item = this.sidebar_item_container(item); + + this.add_sidebar_actions(item, $sidebar_item.find('.sidebar-item-control'), true); - frappe.utils.add_custom_button( - frappe.utils.icon('drag', 'xs'), - null, - "drag-handle", - `${__('Drag')}`, - null, - $sidebar_item.find('.sidebar-item-control') - ); $sidebar_item.find('.sidebar-item-control .drag-handle').css('margin-right', '8px'); - let $sidebar_section = is_public ? $sidebar[1] : $sidebar[0]; + let sidebar_section = item.is_public ? $sidebar[1] : $sidebar[0]; - if (!parent) { - !is_public && $sidebar.first().removeClass('hidden'); - $sidebar_item.appendTo($sidebar_section); + if (!item.parent) { + !item.is_public && $sidebar.first().removeClass('hidden'); + $sidebar_item.appendTo(sidebar_section); } else { - let $item_container = $($sidebar_section).find(`[item-name="${parent}"]`); + let $item_container = $(sidebar_section).find(`[item-name="${item.parent}"]`); let $child_section = $item_container.find('.sidebar-child-item'); let $drop_icon = $item_container.find('.drop-icon'); if (!$child_section[0]) { @@ -635,22 +1071,31 @@ frappe.views.Workspace = class Workspace { } $sidebar_item.appendTo($child_section); $child_section.removeClass('hidden'); + $item_container.find('.drop-icon.hidden').removeClass('hidden'); $item_container.find('.drop-icon use').attr("href", "#icon-small-up"); } + + let section = item.is_public ? 'public' : 'private'; + if (this.sidebar_items && this.sidebar_items[section] && !this.sidebar_items[section][item.title]) { + this.sidebar_items[section][item.title] = $sidebar_item; + } } initialize_editorjs(blocks) { this.tools = { header: { class: this.blocks['header'], - inlineToolbar: true, + inlineToolbar: ['HeaderSize', 'bold', 'italic', 'link'], config: { - defaultLevel: 4 + default_size: 4 } }, paragraph: { class: this.blocks['paragraph'], - inlineToolbar: true + inlineToolbar: ['HeaderSize', 'bold', 'italic', 'link'], + config: { + placeholder: 'Choose a block or continue typing' + } }, chart: { class: this.blocks['chart'], @@ -677,7 +1122,7 @@ frappe.views.Workspace = class Workspace { } }, spacer: this.blocks['spacer'], - spacingTune: frappe.wspace_block.tunes['spacing_tune'], + HeaderSize: frappe.workspace_block.tunes['header_size'], }; this.editor = new EditorJS({ data: { @@ -685,27 +1130,18 @@ frappe.views.Workspace = class Workspace { }, tools: this.tools, autofocus: false, - tunes: ['spacingTune'], readOnly: true, logLevel: 'ERROR' }); } - save_page() { - frappe.dom.freeze(); - this.create_skeleton(); - let save = true; - if (!this.title && this.current_page) { - let pages = this.current_page.public ? this.public_pages : this.private_pages; - this.title = this.current_page.name; - this.public = pages.filter(p => p.title == this.title)[0].public; - save = false; - } else { - this.current_page = { name: this.title, public: this.public }; - } + save_page(page) { let me = this; - this.editor.save().then((outputData) => { + this.current_page = { name: page.title, public: page.public }; + + return this.editor.save().then((outputData) => { let new_widgets = {}; + outputData.blocks.forEach(item => { if (item.data.new) { if (!new_widgets[item.type]) { @@ -718,34 +1154,36 @@ frappe.views.Workspace = class Workspace { let blocks = outputData.blocks.filter( item => item.type != 'card' || - (item.data.card_name !== 'Custom Documents' && - item.data.card_name !== 'Custom Reports') + (item.data.card_name !== 'Custom Documents' && + item.data.card_name !== 'Custom Reports') ); + if (page.content == JSON.stringify(blocks) && Object.keys(new_widgets).length === 0) { + this.setup_customization_buttons(page); + frappe.show_alert({ message: __("No changes made on the page"), indicator: "warning" }); + return false; + } + + this.create_page_skeleton(); + page.content = JSON.stringify(blocks); frappe.call({ method: "frappe.desk.doctype.workspace.workspace.save_page", args: { - title: me.title, - icon: me.icon || '', - parent: me.parent || '', - public: me.public || 0, - sb_public_items: me.sorted_public_items, - sb_private_items: me.sorted_private_items, - deleted_pages: me.deleted_sidebar_items, + title: page.title, + public: page.public || 0, new_widgets: new_widgets, - blocks: JSON.stringify(blocks), - save: save + blocks: JSON.stringify(blocks) }, callback: function(res) { - frappe.dom.unfreeze(); if (res.message) { - me.new_page = res.message; - me.pages[res.message.label] && delete me.pages[res.message.label]; + me.discard = true; + me.update_cached_values(page, page); me.reload(); frappe.show_alert({ message: __("Page Saved Successfully"), indicator: "green" }); } } }); + return true; }).catch((error) => { error; // console.log('Saving failed: ', error); @@ -753,26 +1191,34 @@ frappe.views.Workspace = class Workspace { } reload() { - this.title = ''; - this.icon = ''; - this.parent = ''; - this.public = false; this.sorted_public_items = []; this.sorted_private_items = []; - this.deleted_sidebar_items = []; - this.create_skeleton(); this.setup_pages(true); this.discard = false; this.undo.readOnly = true; } - create_skeleton() { - this.$page.prepend(frappe.render_template('workspace_loading_skeleton')); - this.$page.find('.codex-editor').addClass('hidden'); + create_page_skeleton() { + if ($('.layout-main-section').find('.workspace-skeleton').length) return; + + $('.layout-main-section').prepend(frappe.render_template('workspace_loading_skeleton')); + $('.layout-main-section').find('.codex-editor').addClass('hidden'); } - remove_skeleton() { - this.$page.find('.codex-editor').removeClass('hidden'); - this.$page.find('.workspace-skeleton').remove(); + remove_page_skeleton() { + $('.layout-main-section').find('.codex-editor').removeClass('hidden'); + $('.layout-main-section').find('.workspace-skeleton').remove(); + } + + create_sidebar_skeleton() { + if ($('.list-sidebar').find('.workspace-sidebar-skeleton').length) return; + + $('.list-sidebar').prepend(frappe.render_template('workspace_sidebar_loading_skeleton')); + $('.desk-sidebar').addClass('hidden'); + } + + remove_sidebar_skeleton() { + $('.desk-sidebar').removeClass('hidden'); + $('.list-sidebar').find('.workspace-sidebar-skeleton').remove(); } }; diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index 1f540958df..a45fc941d3 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -160,17 +160,17 @@ export default class WebForm extends frappe.ui.FieldGroup { } setup_primary_action() { - this.add_button_to_header(this.button_label || "Save", "primary", () => + this.add_button_to_header(this.button_label || __("Save", null, "Button in web form"), "primary", () => this.save() ); - this.add_button_to_footer(this.button_label || "Save", "primary", () => + this.add_button_to_footer(this.button_label || __("Save", null, "Button in web form"), "primary", () => this.save() ); } setup_cancel_button() { - this.add_button_to_header(__("Cancel"), "light", () => this.cancel()); + this.add_button_to_header(__("Cancel", null, "Button in web form"), "light", () => this.cancel()); } setup_delete_button() { @@ -216,16 +216,18 @@ export default class WebForm extends frappe.ui.FieldGroup { let message = ''; if (invalid_values.length) { - message += __('Invalid values for fields:') + '

  • ' + invalid_values.join('
  • ') + '
'; + message += __('Invalid values for fields:', null, 'Error message in web form'); + message += '

  • ' + invalid_values.join('
  • ') + '
'; } if (errors.length) { - message += __('Mandatory fields required:') + '

  • ' + errors.join('
  • ') + '
'; + message += __('Mandatory fields required:', null, 'Error message in web form'); + message += '

  • ' + errors.join('
  • ') + '
'; } if (invalid_values.length || errors.length) { frappe.msgprint({ - title: __('Error'), + title: __('Error', null, 'Title of error message in web form'), message: message, indicator: 'orange' }); diff --git a/frappe/public/js/frappe/web_form/web_form_list.js b/frappe/public/js/frappe/web_form/web_form_list.js index f4d41c2a0b..1ad332e3c2 100644 --- a/frappe/public/js/frappe/web_form/web_form_list.js +++ b/frappe/public/js/frappe/web_form/web_form_list.js @@ -16,7 +16,8 @@ export default class WebFormList { if (this.table) { Array.from(this.table.tBodies).forEach(tbody => tbody.remove()); let check = document.getElementById('select-all'); - check.checked = false; + if (check) + check.checked = false; } this.rows = []; this.page_length = 20; @@ -131,9 +132,39 @@ export default class WebFormList { this.make_table_head(); } - this.append_rows(this.data); + if (this.data.length) { + this.append_rows(this.data); + this.wrapper.appendChild(this.table); + } else { + let new_button = ""; + let empty_state = document.createElement("div"); + empty_state.classList.add("no-result", "text-muted", "flex", "justify-center", "align-center"); - this.wrapper.appendChild(this.table); + frappe.has_permission(this.doctype, "", "create", () => { + new_button = ` + + `; + + empty_state.innerHTML = ` +
+
+ Generic Empty State +
+

${__("No {0} found", [__(this.doctype)])}

+ ${new_button} +
+ `; + + this.wrapper.appendChild(empty_state); + }); + } } make_table_head() { @@ -212,8 +243,7 @@ export default class WebFormList { "btn", "btn-secondary", "btn-sm", - "ml-2", - "text-white" + "ml-2" ); } else if (type == "danger") { diff --git a/frappe/public/js/frappe/widgets/base_widget.js b/frappe/public/js/frappe/widgets/base_widget.js index e6ae64d9dc..45d4926904 100644 --- a/frappe/public/js/frappe/widgets/base_widget.js +++ b/frappe/public/js/frappe/widgets/base_widget.js @@ -34,16 +34,6 @@ export default class Widget { this.action_area ); - options.allow_delete && - frappe.utils.add_custom_button( - frappe.utils.icon('delete', 'xs'), - () => this.delete(), - "", - `${__('Delete')}`, - null, - this.action_area - ); - if (options.allow_hiding) { if (this.hidden) { this.widget.removeClass("hidden"); @@ -71,27 +61,11 @@ export default class Widget { frappe.utils.add_custom_button( frappe.utils.icon("edit", "xs"), () => this.edit(), - null, + "edit-button", `${__('Edit')}`, null, this.action_area ); - - if (options.allow_resize) { - const title = this.width == 'Full'? `${__('Collapse')}` : `${__('Expand')}`; - frappe.utils.add_custom_button( - '', - () => this.toggle_width(), - "resize-button", - title, - null, - this.action_area - ); - - this.resize_button = this.action_area.find( - ".resize-button" - ); - } } make() { @@ -100,9 +74,7 @@ export default class Widget { } make_widget() { - this.widget = $(`
+ this.widget = $(`
@@ -110,10 +82,8 @@ export default class Widget {
-
-
- +
+
`); this.title_field = this.widget.find(".widget-title"); @@ -130,7 +100,7 @@ export default class Widget { let title = max_chars ? frappe.ellipsis(base, max_chars) : base; if (this.icon) { - let icon = frappe.utils.icon(this.icon); + let icon = frappe.utils.icon(this.icon, "lg"); this.title_field[0].innerHTML = `${icon} ${title}`; } else { this.title_field[0].innerHTML = `${title}`; diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js index ec602b8522..53e9ecf6bf 100644 --- a/frappe/public/js/frappe/widgets/chart_widget.js +++ b/frappe/public/js/frappe/widgets/chart_widget.js @@ -28,7 +28,7 @@ export default class ChartWidget extends Widget { } set_chart_title() { - const max_chars = this.widget.width() < 600 ? 20 : 60; + const max_chars = this.widget.width() < 600 ? 40 : 60; this.set_title(max_chars); } @@ -698,12 +698,12 @@ export default class ChartWidget extends Widget { .get_filters_for_chart_type(this.chart_doc).then(filters => { chart_saved_filters = this.update_default_date_filters(filters, chart_saved_filters); this.filters = - frappe.utils.parse_array(user_saved_filters) || frappe.utils.parse_array(this.filters) + frappe.utils.parse_array(user_saved_filters) || frappe.utils.parse_array(this.filters) || frappe.utils.parse_array(chart_saved_filters); }); } else { this.filters = - frappe.utils.parse_array(user_saved_filters) || frappe.utils.parse_array(this.filters) + frappe.utils.parse_array(user_saved_filters) || frappe.utils.parse_array(this.filters) || frappe.utils.parse_array(chart_saved_filters); return Promise.resolve(); } diff --git a/frappe/public/js/frappe/widgets/links_widget.js b/frappe/public/js/frappe/widgets/links_widget.js index cc771b96b5..3320e88bfb 100644 --- a/frappe/public/js/frappe/widgets/links_widget.js +++ b/frappe/public/js/frappe/widgets/links_widget.js @@ -80,7 +80,9 @@ export default class LinksWidget extends Widget { return $(` + } ${disabled_dependent(item)}" type="${item.type}" title="${ + item.label ? item.label : item.name + }"> ${get_link_for_item(item)} `); diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index 5676a834fe..d1ba75227b 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -9,6 +9,7 @@ class WidgetDialog { this.setup_dialog_events(); this.dialog.show(); + window.cur_dialog = this.dialog; this.editing && this.set_default_values(); } @@ -153,7 +154,7 @@ class CardDialog extends WidgetDialog { { fieldtype: "Data", fieldname: "label", - label: "Label", + label: "Label" }, { fieldname: 'links', @@ -173,7 +174,7 @@ class CardDialog extends WidgetDialog { }, { fieldname: "icon", - fieldtype: "Data", + fieldtype: "Icon", label: "Icon" }, { @@ -181,19 +182,17 @@ class CardDialog extends WidgetDialog { fieldtype: "Select", in_list_view: 1, label: "Link Type", - options: ["DocType", "Page", "Report"], - onchange: (e) => { - me.link_to = e.currentTarget.value; - } + reqd: 1, + options: ["DocType", "Page", "Report"] }, { fieldname: "link_to", fieldtype: "Dynamic Link", in_list_view: 1, label: "Link To", - options: "link_type", - get_options: () => { - return me.link_to; + reqd: 1, + get_options: (df) => { + return df.doc.link_type; } }, { @@ -229,6 +228,31 @@ class CardDialog extends WidgetDialog { } process_data(data) { + data.links.map((item, idx) => { + let message = ''; + let row = idx+1; + + if (!item.link_type) { + message = "Following fields have missing values:

    "; + message += `
  • Link Type in Row ${row}
  • `; + } + + if (!item.link_to) { + message += `
  • Link To in Row ${row}
  • `; + } + + if (message) { + message += "
"; + frappe.throw({ + message: __(message), + title: __("Missing Values Required"), + indicator: 'orange' + }); + } + + item.label = item.label ? item.label : item.link_to; + }); + data.label = data.label ? data.label : data.chart_name; return data; } @@ -506,7 +530,7 @@ class NumberCardDialog extends WidgetDialog { setup_dialog_events() { if (!this.document_type) { - if (this.default_values['doctype']) { + if (this.default_values && this.default_values['doctype']) { this.document_type = this.default_values['doctype']; this.setup_filter(this.default_values['doctype']); this.set_aggregate_function_fields(); @@ -518,7 +542,7 @@ class NumberCardDialog extends WidgetDialog { set_aggregate_function_fields() { let aggregate_function_fields = []; - if (this.document_type) { + if (this.document_type && frappe.get_meta(this.document_type)) { frappe.get_meta(this.document_type).fields.map(df => { if (frappe.model.numeric_fieldtypes.includes(df.fieldtype)) { if (df.fieldtype == 'Currency') { @@ -537,7 +561,7 @@ class NumberCardDialog extends WidgetDialog { if (data.new_or_existing == 'Existing Card') { data.name = data.card; } - data.stats_filter = JSON.stringify(this.filter_group.get_filters()); + data.stats_filter = this.filter_group && JSON.stringify(this.filter_group.get_filters()); data.document_type = this.document_type; return data; diff --git a/frappe/public/js/integrations/google_drive_picker.js b/frappe/public/js/integrations/google_drive_picker.js index 9d7971e75c..1e4f1dca7c 100644 --- a/frappe/public/js/integrations/google_drive_picker.js +++ b/frappe/public/js/integrations/google_drive_picker.js @@ -44,9 +44,16 @@ export default class GoogleDrivePicker { } handleAuthResult(authResult) { + let error_map = { + "popup_closed_by_user": __("Google Authentication was closed abruptly by the user") + }; + if (authResult && !authResult.error) { frappe.boot.user.google_drive_token = authResult.access_token; this.createPicker(); + } else { + let error = error_map[authResult.error] || __("Google Authentication Error"); + frappe.throw(error); } } @@ -58,20 +65,34 @@ export default class GoogleDrivePicker { createPicker() { // Create and render a Picker object for searching images. if (this.pickerApiLoaded && frappe.boot.user.google_drive_token) { - var view = new google.picker.DocsView(google.picker.ViewId.DOCS) + this.view = new google.picker.DocsView(google.picker.ViewId.DOCS) .setParent('root') // show the root folder by default .setIncludeFolders(true); // also show folders, not just files - var picker = new google.picker.PickerBuilder() + this.picker = new google.picker.PickerBuilder() .setAppId(this.appId) .setDeveloperKey(this.developerKey) .setOAuthToken(frappe.boot.user.google_drive_token) - .addView(view) + .addView(this.view) .setLocale(frappe.boot.lang) .setCallback(this.pickerCallback) .build(); - picker.setVisible(true); + this.picker.setVisible(true); + this.setupHide(); + } + } + + setupHide() { + let bg = $(".picker-dialog-bg"); + + for (let el of bg) { + el.onclick = () => { + this.picker.setVisible(false); + this.picker.Ob({ + action: google.picker.Action.CANCEL + }); + }; } } } diff --git a/frappe/public/js/lib/jSignature.min.js b/frappe/public/js/lib/jSignature.min.js index 3e29c19549..dd75b4e69c 100755 --- a/frappe/public/js/lib/jSignature.min.js +++ b/frappe/public/js/lib/jSignature.min.js @@ -993,7 +993,7 @@ jSignatureClass.prototype.resetCanvas = function(data, dontClear){ ctx.shadowBlur = 0; } } - + ctx.strokeStyle = settings.color; // setting up new dataEngine diff --git a/frappe/public/js/lib/moment.js b/frappe/public/js/lib/moment.js new file mode 100644 index 0000000000..7a817a36cd --- /dev/null +++ b/frappe/public/js/lib/moment.js @@ -0,0 +1,5 @@ +// This file is used to make sure that `moment` is bound to the window +// before the bundle finishes loading, due to imports (datetime.js) in the bundle +// that depend on `moment`. +import momentTimezone from "moment-timezone/builds/moment-timezone-with-data.js"; +window.moment = momentTimezone; diff --git a/frappe/public/js/lib/photoswipe/default-skin.css b/frappe/public/js/lib/photoswipe/default-skin.css index f99db1be62..f678fea2f0 100755 --- a/frappe/public/js/lib/photoswipe/default-skin.css +++ b/frappe/public/js/lib/photoswipe/default-skin.css @@ -12,7 +12,7 @@ */ /* - + 1. Buttons */ @@ -257,7 +257,7 @@ a.pswp__share--download:hover { padding: 0 10px; } /* - + 4. Caption */ @@ -338,8 +338,8 @@ a.pswp__share--download:hover { margin: 0; } .pswp--css_animation .pswp__preloader__cut { - /* - The idea of animating inner circle is based on Polymer ("material") loading indicator + /* + The idea of animating inner circle is based on Polymer ("material") loading indicator by Keanu Lee https://blog.keanulee.com/2014/10/20/the-tale-of-three-spinners.html */ position: relative; @@ -409,7 +409,7 @@ a.pswp__share--download:hover { transform: rotate(0); } } /* - + 6. Additional styles */ diff --git a/frappe/public/js/lib/photoswipe/photoswipe-ui-default.js b/frappe/public/js/lib/photoswipe/photoswipe-ui-default.js index 7f3a71a0fd..03f9ff6759 100755 --- a/frappe/public/js/lib/photoswipe/photoswipe-ui-default.js +++ b/frappe/public/js/lib/photoswipe/photoswipe-ui-default.js @@ -5,9 +5,9 @@ * * UI on top of main sliding area (caption, arrows, close button, etc.). * Built just using public methods/properties of PhotoSwipe. -* +* */ -(function (root, factory) { +(function (root, factory) { if (typeof define === 'function' && define.amd) { define(factory); } else if (typeof exports === 'object') { @@ -48,11 +48,11 @@ var PhotoSwipeUI_Default = _options, _defaultUIOptions = { barsSize: {top:44, bottom:'auto'}, - closeElClasses: ['item', 'caption', 'zoom-wrap', 'ui', 'top-bar'], - timeToIdle: 4000, + closeElClasses: ['item', 'caption', 'zoom-wrap', 'ui', 'top-bar'], + timeToIdle: 4000, timeToIdleOutside: 1000, loadingIndicatorDelay: 1000, // 2s - + addCaptionHTMLFn: function(item, captionEl /*, isFake */) { if(!item.title) { captionEl.children[0].innerHTML = ''; @@ -92,7 +92,7 @@ var PhotoSwipeUI_Default = getTextForShare: function( /* shareButtonData */ ) { return pswp.currItem.title || ''; }, - + indexIndicatorSep: ' / ', fitControlsWidth: 1200 @@ -136,12 +136,12 @@ var PhotoSwipeUI_Default = } _blockControlsTap = true; - // Some versions of Android don't prevent ghost click event + // Some versions of Android don't prevent ghost click event // when preventDefault() was called on touchstart and/or touchend. - // - // This happens on v4.3, 4.2, 4.1, - // older versions strangely work correctly, - // but just in case we add delay on all of them) + // + // This happens on v4.3, 4.2, 4.1, + // older versions strangely work correctly, + // but just in case we add delay on all of them) var tapDelay = framework.features.isOldAndroid ? 600 : 30; _blockControlsTapTimeout = setTimeout(function() { _blockControlsTap = false; @@ -172,8 +172,8 @@ var PhotoSwipeUI_Default = _toggleShareModal = function() { _shareModalHidden = !_shareModalHidden; - - + + if(!_shareModalHidden) { _toggleShareModalClass(); setTimeout(function() { @@ -189,7 +189,7 @@ var PhotoSwipeUI_Default = } }, 300); } - + if(!_shareModalHidden) { _updateShareURLs(); } @@ -211,13 +211,13 @@ var PhotoSwipeUI_Default = } window.open(target.href, 'pswp_share', 'scrollbars=yes,resizable=yes,toolbar=no,'+ - 'location=yes,width=550,height=420,top=100,left=' + + 'location=yes,width=550,height=420,top=100,left=' + (window.screen ? Math.round(screen.width / 2 - 275) : 100) ); if(!_shareModalHidden) { _toggleShareModal(); } - + return false; }, _updateShareURLs = function() { @@ -242,7 +242,7 @@ var PhotoSwipeUI_Default = shareButtonOut += '' + + (shareButtonData.download ? 'download' : '') + '>' + shareButtonData.label + ''; if(_options.parseShareButtonOut) { @@ -297,7 +297,7 @@ var PhotoSwipeUI_Default = _setupLoadingIndicator = function() { // Setup loading indicator if(_options.preloaderEl) { - + _toggleLoadingIndicator(true); _listen('beforeChange', function() { @@ -310,18 +310,18 @@ var PhotoSwipeUI_Default = if(pswp.currItem && pswp.currItem.loading) { if( !pswp.allowProgressiveImg() || (pswp.currItem.img && !pswp.currItem.img.naturalWidth) ) { - // show preloader if progressive loading is not enabled, + // show preloader if progressive loading is not enabled, // or image width is not defined yet (because of slow connection) - _toggleLoadingIndicator(false); + _toggleLoadingIndicator(false); // items-controller.js function allowProgressiveImg } - + } else { _toggleLoadingIndicator(true); // hide preloader } }, _options.loadingIndicatorDelay); - + }); _listen('imageLoadComplete', function(index, item) { if(pswp.currItem === item) { @@ -341,8 +341,8 @@ var PhotoSwipeUI_Default = var gap = item.vGap; if( _fitControlsInViewport() ) { - - var bars = _options.barsSize; + + var bars = _options.barsSize; if(_options.captionEl && bars.bottom === 'auto') { if(!_fakeCaptionContainer) { _fakeCaptionContainer = framework.createEl('pswp__caption pswp__caption--fake'); @@ -360,7 +360,7 @@ var PhotoSwipeUI_Default = } else { gap.bottom = bars.bottom === 'auto' ? 0 : bars.bottom; } - + // height of top bar is static, no need to calculate it gap.top = bars.top; } else { @@ -371,7 +371,7 @@ var PhotoSwipeUI_Default = // Hide controls when mouse is used if(_options.timeToIdle) { _listen('mouseUsed', function() { - + framework.bind(document, 'mousemove', _onIdleMouseMove); framework.bind(document, 'mouseout', _onMouseLeaveWindow); @@ -418,77 +418,77 @@ var PhotoSwipeUI_Default = var _uiElements = [ - { - name: 'caption', + { + name: 'caption', option: 'captionEl', - onInit: function(el) { - _captionContainer = el; - } + onInit: function(el) { + _captionContainer = el; + } }, - { - name: 'share-modal', + { + name: 'share-modal', option: 'shareEl', - onInit: function(el) { + onInit: function(el) { _shareModal = el; }, onTap: function() { _toggleShareModal(); - } + } }, - { - name: 'button--share', + { + name: 'button--share', option: 'shareEl', - onInit: function(el) { + onInit: function(el) { _shareButton = el; }, onTap: function() { _toggleShareModal(); - } + } }, - { - name: 'button--zoom', + { + name: 'button--zoom', option: 'zoomEl', onTap: pswp.toggleDesktopZoom }, - { - name: 'counter', + { + name: 'counter', option: 'counterEl', - onInit: function(el) { + onInit: function(el) { _indexIndicator = el; - } + } }, - { - name: 'button--close', + { + name: 'button--close', option: 'closeEl', onTap: pswp.close }, - { - name: 'button--arrow--left', + { + name: 'button--arrow--left', option: 'arrowEl', onTap: pswp.prev }, - { - name: 'button--arrow--right', + { + name: 'button--arrow--right', option: 'arrowEl', onTap: pswp.next }, - { - name: 'button--fs', + { + name: 'button--fs', option: 'fullscreenEl', - onTap: function() { + onTap: function() { if(_fullscrenAPI.isFullscreen()) { _fullscrenAPI.exit(); } else { _fullscrenAPI.enter(); } - } + } }, - { - name: 'preloader', + { + name: 'preloader', option: 'preloaderEl', - onInit: function(el) { + onInit: function(el) { _loadingIndicator = el; - } + } } ]; @@ -514,12 +514,12 @@ var PhotoSwipeUI_Default = if(classAttr.indexOf('pswp__' + uiElement.name) > -1 ) { if( _options[uiElement.option] ) { // if element is not disabled from options - + framework.removeClass(item, 'pswp__element--disabled'); if(uiElement.onInit) { uiElement.onInit(item); } - + //item.style.display = 'block'; } else { framework.addClass(item, 'pswp__element--disabled'); @@ -538,7 +538,7 @@ var PhotoSwipeUI_Default = }; - + ui.init = function() { @@ -574,9 +574,9 @@ var PhotoSwipeUI_Default = _listen('preventDragEvent', function(e, isDown, preventObj) { var t = e.target || e.srcElement; if( - t && - t.getAttribute('class') && e.type.indexOf('mouse') > -1 && - ( t.getAttribute('class').indexOf('__caption') > 0 || (/(SMALL|STRONG|EM)/i).test(t.tagName) ) + t && + t.getAttribute('class') && e.type.indexOf('mouse') > -1 && + ( t.getAttribute('class').indexOf('__caption') > 0 || (/(SMALL|STRONG|EM)/i).test(t.tagName) ) ) { preventObj.prevent = false; } @@ -634,7 +634,7 @@ var PhotoSwipeUI_Default = framework.addClass( _controls, 'pswp__ui--hidden'); ui.setIdle(false); }); - + if(!_options.showAnimationDuration) { framework.removeClass( _controls, 'pswp__ui--hidden'); @@ -649,7 +649,7 @@ var PhotoSwipeUI_Default = }); _listen('parseVerticalMargin', _applyNavBarGaps); - + _setupUIElements(); if(_options.shareEl && _shareButton && _shareModal) { @@ -673,7 +673,7 @@ var PhotoSwipeUI_Default = ui.update = function() { // Don't update UI if it's hidden if(_controlsVisible && pswp.currItem) { - + ui.updateIndexIndicator(); if(_options.captionEl) { @@ -704,19 +704,19 @@ var PhotoSwipeUI_Default = pswp.setScrollOffset( 0, framework.getScrollY() ); }, 50); } - + // toogle pswp--fs class on root element framework[ (_fullscrenAPI.isFullscreen() ? 'add' : 'remove') + 'Class' ](pswp.template, 'pswp--fs'); }; ui.updateIndexIndicator = function() { if(_options.counterEl) { - _indexIndicator.innerHTML = (pswp.getCurrentIndex()+1) + - _options.indexIndicatorSep + + _indexIndicator.innerHTML = (pswp.getCurrentIndex()+1) + + _options.indexIndicatorSep + _options.getNumItemsFn(); } }; - + ui.onGlobalTap = function(e) { e = e || window.event; var target = e.target || e.srcElement; @@ -742,7 +742,7 @@ var PhotoSwipeUI_Default = pswp.toggleDesktopZoom(e.detail.releasePoint); } } - + } else { // tap anywhere (except buttons) to toggle visibility of controls @@ -759,7 +759,7 @@ var PhotoSwipeUI_Default = pswp.close(); return; } - + } }; ui.onMouseOver = function(e) { @@ -809,7 +809,7 @@ var PhotoSwipeUI_Default = eventK: 'moz' + tF }; - + } else if(dE.webkitRequestFullscreen) { api = { @@ -829,21 +829,21 @@ var PhotoSwipeUI_Default = } if(api) { - api.enter = function() { + api.enter = function() { // disable close-on-scroll in fullscreen - _initalCloseOnScrollValue = _options.closeOnScroll; - _options.closeOnScroll = false; + _initalCloseOnScrollValue = _options.closeOnScroll; + _options.closeOnScroll = false; if(this.enterK === 'webkitRequestFullscreen') { pswp.template[this.enterK]( Element.ALLOW_KEYBOARD_INPUT ); } else { - return pswp.template[this.enterK](); + return pswp.template[this.enterK](); } }; - api.exit = function() { + api.exit = function() { _options.closeOnScroll = _initalCloseOnScrollValue; - return document[this.exitK](); + return document[this.exitK](); }; api.isFullscreen = function() { return document[this.elementK]; }; diff --git a/frappe/public/js/lib/photoswipe/photoswipe.js b/frappe/public/js/lib/photoswipe/photoswipe.js index d2cf5efa1a..1af30bb629 100755 --- a/frappe/public/js/lib/photoswipe/photoswipe.js +++ b/frappe/public/js/lib/photoswipe/photoswipe.js @@ -1,7 +1,7 @@ /*! PhotoSwipe - v4.1.1 - 2015-12-24 * http://photoswipe.com * Copyright (c) 2015 Dmitry Semenov; */ -(function (root, factory) { +(function (root, factory) { if (typeof define === 'function' && define.amd) { define(factory); } else if (typeof exports === 'object') { @@ -18,9 +18,9 @@ /** * * Set of generic functions used by gallery. - * + * * You're free to modify anything here as long as functionality is kept. - * + * */ var framework = { features: null, @@ -52,7 +52,7 @@ var framework = { }, removeClass: function(el, className) { var reg = new RegExp('(\\s|^)' + className + '(\\s|$)'); - el.className = el.className.replace(reg, ' ').replace(/^\s\s*/, '').replace(/\s\s*$/, ''); + el.className = el.className.replace(reg, ' ').replace(/^\s\s*/, '').replace(/\s\s*$/, ''); }, addClass: function(el, className) { if( !framework.hasClass(el,className) ) { @@ -76,7 +76,7 @@ var framework = { while(i--) { if(array[i][key] === value) { return i; - } + } } return -1; }, @@ -127,16 +127,16 @@ var framework = { }, /** - * + * * @return {object} - * + * * { * raf : request animation frame function * caf : cancel animation frame function * transfrom : transform property key (with vendor), or null if not supported * oldIE : IE8 or below * } - * + * */ detectFeatures: function() { if(framework.features) { @@ -161,14 +161,14 @@ var framework = { // fix false-positive detection of old Android in new IE // (IE11 ua string contains "Android 4.0") - - if(!features.pointerEvent) { + + if(!features.pointerEvent) { var ua = navigator.userAgent; // Detect if device is iPhone or iPod and if it's older than iOS 8 // http://stackoverflow.com/a/14223920 - // + // // This detection is made because of buggy top/bottom toolbars // that don't trigger window.resize event. // For more info refer to _isFixedPosition variable in core.js @@ -186,7 +186,7 @@ var framework = { // Detect old Android (before KitKat) // due to bugs related to position:fixed // http://stackoverflow.com/questions/7184573/pick-up-the-android-version-in-the-browser-by-javascript - + var match = ua.match(/Android\s([0-9\.]*)/); var androidversion = match ? match[1] : 0; androidversion = parseFloat(androidversion); @@ -195,12 +195,12 @@ var framework = { features.isOldAndroid = true; // for fixed position bug & performance } features.androidVersion = androidversion; // for touchend bug - } + } features.isMobileOpera = /opera mini|opera mobi/i.test(ua); // p.s. yes, yes, UA sniffing is bad, propose your solution for above bugs. } - + var styleChecks = ['transform', 'perspective', 'animationName'], vendors = ['', 'webkit','Moz','ms','O'], styleCheckItem, @@ -213,10 +213,10 @@ var framework = { styleCheckItem = styleChecks[a]; // uppercase first letter of property name, if vendor is present - styleName = vendor + (vendor ? - styleCheckItem.charAt(0).toUpperCase() + styleCheckItem.slice(1) : + styleName = vendor + (vendor ? + styleCheckItem.charAt(0).toUpperCase() + styleCheckItem.slice(1) : styleCheckItem); - + if(!features[styleCheckItem] && styleName in helperStyle ) { features[styleCheckItem] = styleName; } @@ -226,12 +226,12 @@ var framework = { vendor = vendor.toLowerCase(); features.raf = window[vendor+'RequestAnimationFrame']; if(features.raf) { - features.caf = window[vendor+'CancelAnimationFrame'] || + features.caf = window[vendor+'CancelAnimationFrame'] || window[vendor+'CancelRequestAnimationFrame']; } } } - + if(!features.raf) { var lastTime = 0; features.raf = function(fn) { @@ -245,7 +245,7 @@ var framework = { } // Detect SVG support - features.svg = !!document.createElementNS && + features.svg = !!document.createElementNS && !!document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGRect; framework.features = features; @@ -260,7 +260,7 @@ framework.detectFeatures(); if(framework.features.oldIE) { framework.bind = function(target, type, listener, unbind) { - + type = type.split(' '); var methodName = (unbind ? 'detach' : 'attach') + 'Event', @@ -290,7 +290,7 @@ if(framework.features.oldIE) { } } }; - + } /*>>framework-bridge*/ @@ -303,7 +303,7 @@ var self = this; /** * Static vars, don't change unless you know what you're doing. */ -var DOUBLE_TAP_RADIUS = 25, +var DOUBLE_TAP_RADIUS = 25, NUM_HOLDERS = 3; /** @@ -350,8 +350,8 @@ framework.extend(_options, options); * Private helper variables & functions */ -var _getEmptyPoint = function() { - return {x:0,y:0}; +var _getEmptyPoint = function() { + return {x:0,y:0}; }; var _isOpen, @@ -413,7 +413,7 @@ var _isOpen, } return index; }, - + // Micro bind/trigger _listeners = {}, _listen = function(name, fn) { @@ -445,9 +445,9 @@ var _isOpen, _applyZoomTransform = function(styleObj,x,y,zoom,item) { if(!_renderMaxResolution || (item && item !== self.currItem) ) { - zoom = zoom / (item ? item.fitRatio : self.currItem.fitRatio); + zoom = zoom / (item ? item.fitRatio : self.currItem.fitRatio); } - + styleObj[_transformKey] = _translatePrefix + x + 'px, ' + y + 'px' + _translateSufix + ' scale(' + zoom + ')'; }, _applyCurrentZoomPan = function( allowRenderResolution ) { @@ -466,7 +466,7 @@ var _isOpen, } } } - + _applyZoomTransform(_currZoomElementStyle, _panOffset.x, _panOffset.y, _currZoomLevel); } @@ -474,9 +474,9 @@ var _isOpen, _applyZoomPanToItem = function(item) { if(item.container) { - _applyZoomTransform(item.container.style, - item.initialPosition.x, - item.initialPosition.y, + _applyZoomTransform(item.container.style, + item.initialPosition.x, + item.initialPosition.y, item.initialZoomLevel, item); } @@ -490,12 +490,12 @@ var _isOpen, var newSlideIndexOffset = _currentItemIndex + (_slideSize.x * _currPositionIndex - x) / _slideSize.x, delta = Math.round(x - _mainScrollPos.x); - if( (newSlideIndexOffset < 0 && delta > 0) || + if( (newSlideIndexOffset < 0 && delta > 0) || (newSlideIndexOffset >= _getNumItems() - 1 && delta < 0) ) { x = _mainScrollPos.x + delta * _options.mainScrollEndFriction; - } + } } - + _mainScrollPos.x = x; _setTranslateX(x, _containerStyle); }, @@ -503,7 +503,7 @@ var _isOpen, var m = _midZoomPoint[axis] - _offset[axis]; return _startPanOffset[axis] + _currPanDist[axis] + m - m * ( zoomLevel / _startZoomLevel ); }, - + _equalizePoints = function(p1, p2) { p1.x = p2.x; p1.y = p2.y; @@ -520,7 +520,7 @@ var _isOpen, _onFirstMouseMove = function() { // Wait until mouse move event is fired at least twice during 100ms // We do this, because some mobile browsers trigger it on touchstart - if(_mouseMoveTimeout ) { + if(_mouseMoveTimeout ) { framework.unbind(document, 'mousemove', _onFirstMouseMove); framework.addClass(template, 'pswp--has_mouse'); _options.mouseUsed = true; @@ -538,7 +538,7 @@ var _isOpen, // don't bind click event in browsers that don't support transform (mostly IE8) framework.bind(self.scrollWrap, 'click', self); } - + if(!_options.mouseUsed) { framework.bind(document, 'mousemove', _onFirstMouseMove); @@ -565,7 +565,7 @@ var _isOpen, _shout('unbindEvents'); }, - + _calculatePanBounds = function(zoomLevel, update) { var bounds = _calculateItemSize( self.currItem, _viewportSize, zoomLevel ); if(update) { @@ -573,7 +573,7 @@ var _isOpen, } return bounds; }, - + _getMinZoomLevel = function(item) { if(!item) { item = self.currItem; @@ -593,7 +593,7 @@ var _isOpen, destPanOffset[axis] = self.currItem.initialPosition[axis]; return true; } else { - destPanOffset[axis] = _calculatePanOffset(axis, destZoomLevel); + destPanOffset[axis] = _calculatePanOffset(axis, destZoomLevel); if(destPanOffset[axis] > destPanBounds.min[axis]) { destPanOffset[axis] = destPanBounds.min[axis]; @@ -612,13 +612,13 @@ var _isOpen, // setup 3d transforms var allow3dTransform = _features.perspective && !_likelyTouchDevice; _translatePrefix = 'translate' + (allow3dTransform ? '3d(' : '('); - _translateSufix = _features.perspective ? ', 0px)' : ')'; + _translateSufix = _features.perspective ? ', 0px)' : ')'; return; } // Override zoom/pan/move functions in case old browser is used (most likely IE) // (so they use left/top/width/height, instead of CSS transform) - + _transformKey = 'left'; framework.addClass(template, 'pswp--ie'); @@ -654,18 +654,18 @@ var _isOpen, s.left = _panOffset.x + 'px'; s.top = _panOffset.y + 'px'; } - + }; }, _onKeyDown = function(e) { var keydownAction = ''; - if(_options.escKey && e.keyCode === 27) { + if(_options.escKey && e.keyCode === 27) { keydownAction = 'close'; } else if(_options.arrowKeys) { if(e.keyCode === 37) { keydownAction = 'prev'; - } else if(e.keyCode === 39) { + } else if(e.keyCode === 39) { keydownAction = 'next'; } } @@ -678,7 +678,7 @@ var _isOpen, e.preventDefault(); } else { e.returnValue = false; - } + } self[keydownAction](); } } @@ -697,12 +697,12 @@ var _isOpen, }, _updatePageScrollOffset = function() { - self.setScrollOffset(0, framework.getScrollY()); + self.setScrollOffset(0, framework.getScrollY()); }; - - + + @@ -732,8 +732,8 @@ var _animations = {}, if( _animations.hasOwnProperty( prop ) ) { _stopAnimation(prop); - } - + } + } }, _animateProp = function(name, b, endProp, d, easingFn, onUpdate, onComplete) { @@ -742,7 +742,7 @@ var _animations = {}, var animloop = function(){ if ( _animations[name] ) { - + t = _getCurrentTime() - startAnimTime; // time diff //b - beginning (start prop) //d - anim duration @@ -762,7 +762,7 @@ var _animations = {}, }; animloop(); }; - + var publicMethods = { @@ -784,7 +784,7 @@ var publicMethods = { }, isDragging: function() { return _isDragging; - }, + }, isZooming: function() { return _isZooming; }, @@ -814,13 +814,13 @@ var publicMethods = { _initalClassName = template.className; _isOpen = true; - + _features = framework.detectFeatures(); _requestAF = _features.raf; _cancelAF = _features.caf; _transformKey = _features.transform; _oldIE = _features.oldIE; - + self.scrollWrap = framework.getChildByClass(template, 'pswp__scroll-wrap'); self.container = framework.getChildByClass(self.scrollWrap, 'pswp__container'); @@ -846,7 +846,7 @@ var publicMethods = { click: _onGlobalClick }; - // disable show/hide effects on old browsers that don't support CSS animations or transforms, + // disable show/hide effects on old browsers that don't support CSS animations or transforms, // old IOS, Android and Opera mobile. Blackberry seems to work fine, even older models. var oldPhone = _features.isOldIOSPhone || _features.isOldAndroid || _features.isMobileOpera; if(!_features.animationName || !_features.transform || oldPhone) { @@ -857,7 +857,7 @@ var publicMethods = { for(i = 0; i < _modules.length; i++) { self['init' + _modules[i]](); } - + // init if(UiClass) { var ui = self.ui = new UiClass(self, framework); @@ -872,11 +872,11 @@ var publicMethods = { } self.currItem = _getItemAt( _currentItemIndex ); - + if(_features.isOldIOSPhone || _features.isOldAndroid) { _isFixedPosition = false; } - + template.setAttribute('aria-hidden', 'false'); if(_options.modal) { if(!_isFixedPosition) { @@ -891,7 +891,7 @@ var publicMethods = { _shout('initialLayout'); _currentWindowScrollY = _initalWindowScrollY = framework.getScrollY(); } - + // add classes to root element of PhotoSwipe var rootClasses = 'pswp--open '; if(_options.mainClass) { @@ -916,7 +916,7 @@ var publicMethods = { if(!_oldIE) { framework.bind(self.scrollWrap, _downEvents, self); // no dragging for old IE - } + } _listen('initialZoomInEnd', function() { self.setContent(_itemHolders[0], _currentItemIndex-1); @@ -925,19 +925,19 @@ var publicMethods = { _itemHolders[0].el.style.display = _itemHolders[2].el.style.display = 'block'; if(_options.focus) { - // focus causes layout, - // which causes lag during the animation, + // focus causes layout, + // which causes lag during the animation, // that's why we delay it untill the initial zoom transition ends template.focus(); } - + _bindEvents(); }); // set content for center slide (first time) self.setContent(_itemHolders[1], _currentItemIndex); - + self.updateCurrItem(); _shout('afterInit'); @@ -945,14 +945,14 @@ var publicMethods = { if(!_isFixedPosition) { // On all versions of iOS lower than 8.0, we check size of viewport every second. - // - // This is done to detect when Safari top & bottom bars appear, - // as this action doesn't trigger any events (like resize). - // + // + // This is done to detect when Safari top & bottom bars appear, + // as this action doesn't trigger any events (like resize). + // // On iOS8 they fixed this. - // + // // 10 Nov 2014: iOS 7 usage ~40%. iOS 8 usage 56%. - + _updateSizeInterval = setInterval(function() { if(!_numAnimations && !_isDragging && !_isZooming && (_currZoomLevel === self.currItem.initialZoomLevel) ) { self.updateSize(); @@ -984,7 +984,7 @@ var publicMethods = { if(_showOrHideTimeout) { clearTimeout(_showOrHideTimeout); } - + template.setAttribute('aria-hidden', 'true'); template.className = _initalClassName; @@ -1006,8 +1006,8 @@ var publicMethods = { /** * Pan image to position - * @param {Number} x - * @param {Number} y + * @param {Number} x + * @param {Number} y * @param {Boolean} force Will ignore bounds if set to true. */ panTo: function(x,y,force) { @@ -1024,12 +1024,12 @@ var publicMethods = { y = _currPanBounds.max.y; } } - + _panOffset.x = x; _panOffset.y = y; _applyCurrentZoomPan(); }, - + handleEvent: function (e) { e = e || window.event; if(_globalEventHandlers[e.type]) { @@ -1048,9 +1048,9 @@ var publicMethods = { _currentItemIndex = index; self.currItem = _getItemAt( _currentItemIndex ); _currPositionIndex -= diff; - + _moveMainScroll(_slideSize.x * _currPositionIndex); - + _stopAllAnimations(); _mainScrollAnimating = false; @@ -1081,8 +1081,8 @@ var publicMethods = { } else { _currZoomElementStyle = null; } - - _currPanBounds = self.currItem.bounds; + + _currPanBounds = self.currItem.bounds; _startZoomLevel = _currZoomLevel = self.currItem.initialZoomLevel; _panOffset.x = _currPanBounds.center.x; @@ -1119,7 +1119,7 @@ var publicMethods = { self.currItem = _getItemAt( _currentItemIndex ); _renderMaxResolution = false; - + _shout('beforeChange', _indexDiff); if(diffAbs >= NUM_HOLDERS) { @@ -1142,7 +1142,7 @@ var publicMethods = { _setTranslateX( _containerShiftIndex * _slideSize.x, tempHolder.el.style); self.setContent(tempHolder, _currentItemIndex + diffAbs - i - 1 - 1); } - + } // reset zoom/pan on previous item @@ -1152,7 +1152,7 @@ var publicMethods = { if(prevItem.initialZoomLevel !== _currZoomLevel) { _calculateItemSize(prevItem , _viewportSize ); _setImageSize(prevItem); - _applyZoomPanToItem( prevItem ); + _applyZoomPanToItem( prevItem ); } } @@ -1165,13 +1165,13 @@ var publicMethods = { _prevItemIndex = _currentItemIndex; _shout('afterChange'); - + }, updateSize: function(force) { - + if(!_isFixedPosition && _options.modal) { var windowScrollY = framework.getScrollY(); if(_currentWindowScrollY !== windowScrollY) { @@ -1228,7 +1228,7 @@ var publicMethods = { if( item && (_itemsNeedUpdate || item.needsUpdate || !item.bounds) ) { self.cleanSlide( item ); - + self.setContent( holder, hIndex ); // if "center" slide @@ -1248,10 +1248,10 @@ var publicMethods = { _setImageSize(item); _applyZoomPanToItem( item ); } - + } _itemsNeedUpdate = false; - } + } _startZoomLevel = _currZoomLevel = self.currItem.initialZoomLevel; _currPanBounds = self.currItem.bounds; @@ -1261,10 +1261,10 @@ var publicMethods = { _panOffset.y = _currPanBounds.center.y; _applyCurrentZoomPan( true ); } - + _shout('resize'); }, - + // Zoom current item to zoomTo: function(destZoomLevel, centerPoint, speed, easingFn, updateFn) { /* @@ -1330,7 +1330,7 @@ var publicMethods = { /*>>gestures*/ /** * Mouse/touch/pointer event handlers. - * + * * separated from @core.js for readability */ @@ -1406,7 +1406,7 @@ var _gestureStartTime, _canPan = function() { return !(_options.scaleMode === 'fit' && _currZoomLevel === self.currItem.initialZoomLevel); }, - + // find the closest parent DOM element _closestElement = function(el, fn) { if(!el || el === document) { @@ -1447,7 +1447,7 @@ var _gestureStartTime, if(time - _gestureCheckSpeedTime > 50) { var o = _posPoints.length > 2 ? _posPoints.shift() : {}; o.x = x; - o.y = y; + o.y = y; _posPoints.push(o); _gestureCheckSpeedTime = time; } @@ -1458,7 +1458,7 @@ var _gestureStartTime, return 1 - Math.abs( yOffset / (_viewportSize.y / 2) ); }, - + // points pool, reused during touch events _ePoint1 = {}, _ePoint2 = {}, @@ -1479,7 +1479,7 @@ var _gestureStartTime, _tempPointsArr[1] = _convertTouchToPoint(e.touches[1], _ePoint2); } } - + } else { _ePoint1.x = e.pageX; _ePoint1.y = e.pageY; @@ -1517,13 +1517,13 @@ var _gestureStartTime, // calculate fdistance over the bounds and friction if(newOffset > _currPanBounds.min[axis] || newOffset < _currPanBounds.max[axis]) { panFriction = _options.panEndFriction; - // Linear increasing of friction, so at 1/4 of viewport it's at max value. + // Linear increasing of friction, so at 1/4 of viewport it's at max value. // Looks not as nice as was expected. Left for history. // panFriction = (1 - (_panOffset[axis] + delta[axis] + panBounds.min[axis]) / (_viewportSize[axis] / 4) ); } else { panFriction = 1; } - + newOffset = _panOffset[axis] + delta[axis] * panFriction; // move main scroll or start panning @@ -1531,18 +1531,18 @@ var _gestureStartTime, if(!_currZoomElementStyle) { - + newMainScrollPos = newMainScrollPosition; } else if(_direction === 'h' && axis === 'x' && !_zoomStarted ) { - + if(dir) { if(newOffset > _currPanBounds.min[axis]) { panFriction = _options.panEndFriction; overDiff = _currPanBounds.min[axis] - newOffset; startOverDiff = _currPanBounds.min[axis] - _startPanOffset[axis]; } - + // drag right if( (startOverDiff <= 0 || mainScrollDiff < 0) && _getNumItems() > 1 ) { newMainScrollPos = newMainScrollPosition; @@ -1553,7 +1553,7 @@ var _gestureStartTime, if(_currPanBounds.min.x !== _currPanBounds.max.x) { newPanPos = newOffset; } - + } } else { @@ -1608,17 +1608,17 @@ var _gestureStartTime, } if(!_mainScrollAnimating) { - + if(!_mainScrollShifted) { if(_currZoomLevel > self.currItem.fitRatio) { _panOffset[axis] += delta[axis] * panFriction; - + } } - + } - + }, // Pointerdown/touchstart/mousedown handler @@ -1626,7 +1626,7 @@ var _gestureStartTime, // Allow dragging only via left mouse button. // As this handler is not added in IE8 - we ignore e.which - // + // // http://www.quirksmode.org/js/events_properties.html // https://developer.mozilla.org/en-US/docs/Web/API/event.button if(e.type === 'mousedown' && e.button > 0 ) { @@ -1657,7 +1657,7 @@ var _gestureStartTime, } _currPointers[pointerIndex] = {x:e.pageX, y:e.pageY, id: e.pointerId}; } - + var startPointsList = _getTouchPoints(e), @@ -1670,18 +1670,18 @@ var _gestureStartTime, // init drag if(!_isDragging || numPoints === 1) { - + _isDragging = _isFirstMove = true; framework.bind(window, _upMoveEvents, self); - _isZoomingIn = - _wasOverInitialZoom = - _opacityChanged = - _verticalDragInitiated = - _mainScrollShifted = - _moved = - _isMultitouch = + _isZoomingIn = + _wasOverInitialZoom = + _opacityChanged = + _verticalDragInitiated = + _mainScrollShifted = + _moved = + _isMultitouch = _zoomStarted = false; _direction = null; @@ -1706,11 +1706,11 @@ var _gestureStartTime, //_mainScrollAnimationEnd(true); _calculatePanBounds( _currZoomLevel, true ); - + // Start rendering _stopDragUpdateLoop(); _dragUpdateLoop(); - + } // init zoom @@ -1746,7 +1746,7 @@ var _gestureStartTime, if(pointerIndex > -1) { var p = _currPointers[pointerIndex]; p.x = e.pageX; - p.y = e.pageY; + p.y = e.pageY; } } @@ -1765,13 +1765,13 @@ var _gestureStartTime, _currentPoints = touchesList; } } - + } else { _currentPoints = touchesList; } - } + } }, - // + // _renderMovement = function() { if(!_currentPoints) { @@ -1794,7 +1794,7 @@ var _gestureStartTime, _currPoint.x = p.x; _currPoint.y = p.y; - + // check if one of two points changed if( !delta.x && !delta.y && _isEqualPoints(_currentPoints[1], p2) ) { return; @@ -1807,7 +1807,7 @@ var _gestureStartTime, _zoomStarted = true; _shout('zoomGestureStarted'); } - + // Distance between two points var pointsDistance = _calculatePointsDistance(p,p2); @@ -1824,7 +1824,7 @@ var _gestureStartTime, maxZoomLevel = _getMaxZoomLevel(); if ( zoomLevel < minZoomLevel ) { - + if(_options.pinchToClose && !_wasOverInitialZoom && _startZoomLevel <= self.currItem.initialZoomLevel) { // fade out background if zooming out var minusDiff = minZoomLevel - zoomLevel; @@ -1840,7 +1840,7 @@ var _gestureStartTime, } zoomLevel = minZoomLevel - zoomFriction * (minZoomLevel / 3); } - + } else if ( zoomLevel > maxZoomLevel ) { // 1.5 - extra zoom level above the max. E.g. if max is x6, real max 6 + 1.5 = 7.5 zoomFriction = (zoomLevel - maxZoomLevel) / ( minZoomLevel * 6 ); @@ -1859,7 +1859,7 @@ var _gestureStartTime, // _centerPoint - The point in the middle of two pointers _findCenterOfPoints(p, p2, _centerPoint); - + // paning with two pointers pressed _currPanDist.x += _centerPoint.x - _currCenterPoint.x; _currPanDist.y += _centerPoint.y - _currCenterPoint.y; @@ -1883,12 +1883,12 @@ var _gestureStartTime, if(_isFirstMove) { _isFirstMove = false; - // subtract drag distance that was used during the detection direction + // subtract drag distance that was used during the detection direction if( Math.abs(delta.x) >= DIRECTION_CHECK_OFFSET) { delta.x -= _currentPoints[0].x - _startPoint.x; } - + if( Math.abs(delta.y) >= DIRECTION_CHECK_OFFSET) { delta.y -= _currentPoints[0].y - _startPoint.y; } @@ -1922,7 +1922,7 @@ var _gestureStartTime, _moved = true; _currPanBounds = self.currItem.bounds; - + var mainScrollChanged = _panOrMoveMainScroll('x', delta); if(!mainScrollChanged) { _panOrMoveMainScroll('y', delta); @@ -1934,7 +1934,7 @@ var _gestureStartTime, } }, - + // Pointerup/pointercancel/touchend/touchcancel/mouseup event handler _onDragRelease = function(e) { @@ -1944,7 +1944,7 @@ var _gestureStartTime, return; } - // on Android (v4.1, 4.2, 4.3 & possibly older) + // on Android (v4.1, 4.2, 4.3 & possibly older) // ghost mousedown/up event isn't preventable via e.preventDefault, // which causes fake mousedown event // so we block mousedown/up for 600ms @@ -1954,7 +1954,7 @@ var _gestureStartTime, _oldAndroidTouchEndTimeout = 0; }, 600); } - + } _shout('pointerUp'); @@ -1967,7 +1967,7 @@ var _gestureStartTime, if(_pointerEventEnabled) { var pointerIndex = framework.arraySearch(_currPointers, e.pointerId, 'id'); - + if(pointerIndex > -1) { releasePoint = _currPointers.splice(pointerIndex, 1)[0]; @@ -1976,7 +1976,7 @@ var _gestureStartTime, } else { var MSPOINTER_TYPES = { 4: 'mouse', // event.MSPOINTER_TYPE_MOUSE - 2: 'touch', // event.MSPOINTER_TYPE_TOUCH + 2: 'touch', // event.MSPOINTER_TYPE_TOUCH 3: 'pen' // event.MSPOINTER_TYPE_PEN }; releasePoint.type = MSPOINTER_TYPES[e.pointerType]; @@ -2006,7 +2006,7 @@ var _gestureStartTime, // if second pointer released if(numPoints === 1) { _equalizePoints(_startPoint, touchList[0]); - } + } // pointer hasn't moved, send "tap release" point @@ -2016,7 +2016,7 @@ var _gestureStartTime, releasePoint = {x: e.pageX, y: e.pageY, type:'mouse'}; } else if(e.changedTouches && e.changedTouches[0]) { releasePoint = {x: e.changedTouches[0].pageX, y: e.changedTouches[0].pageY, type:'touch'}; - } + } } _shout('touchRelease', e, releasePoint); @@ -2040,7 +2040,7 @@ var _gestureStartTime, } } _lastReleaseTime = numPoints === 1 ? _getCurrentTime() : -1; - + if(releaseTimeDiff !== -1 && releaseTimeDiff < 150) { gestureType = 'zoom'; } else { @@ -2062,14 +2062,14 @@ var _gestureStartTime, // nothing to animate return; } - + _stopAllAnimations(); - + if(!_releaseAnimData) { _releaseAnimData = _initDragReleaseAnimationData(); } - + _releaseAnimData.calculateSwipeSpeed('x'); @@ -2084,7 +2084,7 @@ var _gestureStartTime, initialBgOpacity = _bgOpacity; _animateProp('verticalDrag', 0, 1, 300, framework.easing.cubic.out, function(now) { - + _panOffset.y = (self.currItem.initialPosition.y - initalPanY) * now + initalPanY; _applyBgOpacity( (1 - initialBgOpacity) * now + initialBgOpacity ); @@ -2098,7 +2098,7 @@ var _gestureStartTime, } - // main scroll + // main scroll if( (_mainScrollShifted || _mainScrollAnimating) && numPoints === 0) { var itemChanged = _finishSwipeMainScrollGesture(gestureType, _releaseAnimData); if(itemChanged) { @@ -2111,13 +2111,13 @@ var _gestureStartTime, if(_mainScrollAnimating) { return; } - - // Complete simple zoom gesture (reset zoom level if it's out of the bounds) + + // Complete simple zoom gesture (reset zoom level if it's out of the bounds) if(gestureType !== 'swipe') { _completeZoomGesture(); return; } - + // Complete pan gesture if main scroll is not shifted, and it's possible to pan current image if(!_mainScrollShifted && _currZoomLevel > self.currItem.fitRatio) { _completePanGesture(_releaseAnimData); @@ -2145,7 +2145,7 @@ var _gestureStartTime, backAnimDestination: {}, backAnimStarted: {}, calculateSwipeSpeed: function(axis) { - + if( _posPoints.length > 1) { lastFlickDuration = _getCurrentTime() - _gestureCheckSpeedTime + 50; @@ -2164,7 +2164,7 @@ var _gestureStartTime, if( Math.abs(s.lastFlickSpeed[axis]) < 0.1 ) { s.lastFlickSpeed[axis] = 0; } - + s.slowDownRatio[axis] = 0.95; s.slowDownRatioReverse[axis] = 1 - s.slowDownRatio[axis]; s.speedDecelerationRatio[axis] = 1; @@ -2175,7 +2175,7 @@ var _gestureStartTime, if(_panOffset[axis] > _currPanBounds.min[axis]) { s.backAnimDestination[axis] = _currPanBounds.min[axis]; - + } else if(_panOffset[axis] < _currPanBounds.max[axis]) { s.backAnimDestination[axis] = _currPanBounds.max[axis]; } @@ -2188,10 +2188,10 @@ var _gestureStartTime, s.lastFlickSpeed[axis] = 0; s.backAnimStarted[axis] = true; - _animateProp('bounceZoomPan'+axis,_panOffset[axis], - s.backAnimDestination[axis], - speed || 300, - framework.easing.sine.out, + _animateProp('bounceZoomPan'+axis,_panOffset[axis], + s.backAnimDestination[axis], + speed || 300, + framework.easing.sine.out, function(pos) { _panOffset[axis] = pos; _applyCurrentZoomPan(); @@ -2206,8 +2206,8 @@ var _gestureStartTime, // Reduces the speed by slowDownRatio (per 10ms) calculateAnimOffset: function(axis) { if(!s.backAnimStarted[axis]) { - s.speedDecelerationRatio[axis] = s.speedDecelerationRatio[axis] * (s.slowDownRatio[axis] + - s.slowDownRatioReverse[axis] - + s.speedDecelerationRatio[axis] = s.speedDecelerationRatio[axis] * (s.slowDownRatio[axis] + + s.slowDownRatioReverse[axis] - s.slowDownRatioReverse[axis] * s.timeDiff / 10); s.speedDecelerationRatioAbs[axis] = Math.abs(s.lastFlickSpeed[axis] * s.speedDecelerationRatio[axis]); @@ -2224,12 +2224,12 @@ var _gestureStartTime, s.now = _getCurrentTime(); s.timeDiff = s.now - s.lastNow; s.lastNow = s.now; - + s.calculateAnimOffset('x'); s.calculateAnimOffset('y'); _applyCurrentZoomPan(); - + s.calculateOverBoundsAnimOffset('x'); s.calculateOverBoundsAnimOffset('y'); @@ -2240,7 +2240,7 @@ var _gestureStartTime, _panOffset.x = Math.round(_panOffset.x); _panOffset.y = Math.round(_panOffset.y); _applyCurrentZoomPan(); - + _stopAnimation('zoomPan'); return; } @@ -2256,7 +2256,7 @@ var _gestureStartTime, animData.calculateSwipeSpeed('y'); _currPanBounds = self.currItem.bounds; - + animData.backAnimDestination = {}; animData.backAnimStarted = {}; @@ -2284,20 +2284,20 @@ var _gestureStartTime, } - + var itemsDiff; if(gestureType === 'swipe') { var totalShiftDist = _currPoint.x - _startPoint.x, isFastLastFlick = _releaseAnimData.lastFlickDist.x < 10; - // if container is shifted for more than MIN_SWIPE_DISTANCE, + // if container is shifted for more than MIN_SWIPE_DISTANCE, // and last flick gesture was in right direction - if(totalShiftDist > MIN_SWIPE_DISTANCE && + if(totalShiftDist > MIN_SWIPE_DISTANCE && (isFastLastFlick || _releaseAnimData.lastFlickOffset.x > 20) ) { // go to prev item itemsDiff = -1; - } else if(totalShiftDist < -MIN_SWIPE_DISTANCE && + } else if(totalShiftDist < -MIN_SWIPE_DISTANCE && (isFastLastFlick || _releaseAnimData.lastFlickOffset.x < -20) ) { // go to next item itemsDiff = 1; @@ -2307,7 +2307,7 @@ var _gestureStartTime, var nextCircle; if(itemsDiff) { - + _currentItemIndex += itemsDiff; if(_currentItemIndex < 0) { @@ -2323,9 +2323,9 @@ var _gestureStartTime, _currPositionIndex -= itemsDiff; itemChanged = true; } - - + + } var animateToX = _slideSize.x * _currPositionIndex; @@ -2335,10 +2335,10 @@ var _gestureStartTime, if(!itemChanged && animateToX > _mainScrollPos.x !== _releaseAnimData.lastFlickSpeed.x > 0) { // "return to current" duration, e.g. when dragging from slide 0 to -1 - finishAnimDuration = 333; + finishAnimDuration = 333; } else { - finishAnimDuration = Math.abs(_releaseAnimData.lastFlickSpeed.x) > 0 ? - animateToDist / Math.abs(_releaseAnimData.lastFlickSpeed.x) : + finishAnimDuration = Math.abs(_releaseAnimData.lastFlickSpeed.x) > 0 ? + animateToDist / Math.abs(_releaseAnimData.lastFlickSpeed.x) : 333; finishAnimDuration = Math.min(finishAnimDuration, 400); @@ -2348,22 +2348,22 @@ var _gestureStartTime, if(_currZoomedItemIndex === _currentItemIndex) { itemChanged = false; } - + _mainScrollAnimating = true; - + _shout('mainScrollAnimStart'); - _animateProp('mainScroll', _mainScrollPos.x, animateToX, finishAnimDuration, framework.easing.cubic.out, + _animateProp('mainScroll', _mainScrollPos.x, animateToX, finishAnimDuration, framework.easing.cubic.out, _moveMainScroll, function() { _stopAllAnimations(); _mainScrollAnimating = false; _currZoomedItemIndex = -1; - + if(itemChanged || _currZoomedItemIndex !== _currentItemIndex) { self.updateCurrItem(); } - + _shout('mainScrollAnimComplete'); } ); @@ -2446,7 +2446,7 @@ _registerModule('Gestures', { addEventNames('touch', 'start', 'move', 'end', 'cancel'); _likelyTouchDevice = true; } else { - addEventNames('mouse', 'down', 'move', 'up'); + addEventNames('mouse', 'down', 'move', 'up'); } _upMoveEvents = _dragMoveEvent + ' ' + _dragEndEvent + ' ' + _dragCancelEvent; @@ -2456,8 +2456,8 @@ _registerModule('Gestures', { _likelyTouchDevice = (navigator.maxTouchPoints > 1) || (navigator.msMaxTouchPoints > 1); } // make variable public - self.likelyTouchDevice = _likelyTouchDevice; - + self.likelyTouchDevice = _likelyTouchDevice; + _globalEventHandlers[_dragStartEvent] = _onDragStart; _globalEventHandlers[_dragMoveEvent] = _onDragMove; _globalEventHandlers[_dragEndEvent] = _onDragRelease; // the Kraken @@ -2496,7 +2496,7 @@ _registerModule('Gestures', { * If you're not planning to use transition for gallery at all, * you may set options hideAnimationDuration and showAnimationDuration to 0, * and just delete startAnimation function. - * + * */ @@ -2509,10 +2509,10 @@ var _showOrHideTimeout, _initialZoomRunning = true; _initialContentSet = true; - + // dimensions of small thumbnail {x:,y:,w:}. // Height is optional, as calculated based on large image. - var thumbBounds; + var thumbBounds; if(item.initialLayout) { thumbBounds = item.initialLayout; item.initialLayout = null; @@ -2568,7 +2568,7 @@ var _showOrHideTimeout, var startAnimation = function() { var closeWithRaf = _closedByScroll, fadeEverything = !self.currItem.src || self.currItem.loadError || _options.showHideOpacity; - + // apply hw-acceleration to image if(item.miniImg) { item.miniImg.style.webkitBackfaceVisibility = 'hidden'; @@ -2584,7 +2584,7 @@ var _showOrHideTimeout, } _registerStartAnimation('initialZoom'); - + if(out && !closeWithRaf) { framework.removeClass(template, 'pswp--animated-in'); } @@ -2602,15 +2602,15 @@ var _showOrHideTimeout, _showOrHideTimeout = setTimeout(function() { _shout('initialZoom' + (out ? 'Out' : 'In') ); - + if(!out) { // "in" animation always uses CSS transitions (instead of rAF). - // CSS transition work faster here, - // as developer may also want to animate other things, + // CSS transition work faster here, + // as developer may also want to animate other things, // like ui on top of sliding area, which can be animated just via CSS - + _currZoomLevel = item.initialZoomLevel; _equalizePoints(_panOffset, item.initialPosition ); _applyCurrentZoomPan(); @@ -2634,7 +2634,7 @@ var _showOrHideTimeout, initialZoomLevel = _currZoomLevel, initalBgOpacity = _bgOpacity, onUpdate = function(now) { - + if(now === 1) { _currZoomLevel = destZoomLevel; _panOffset.x = thumbBounds.x; @@ -2644,7 +2644,7 @@ var _showOrHideTimeout, _panOffset.x = (thumbBounds.x - initialPanOffset.x) * now + initialPanOffset.x; _panOffset.y = (thumbBounds.y - _currentWindowScrollY - initialPanOffset.y) * now + initialPanOffset.y; } - + _applyCurrentZoomPan(); if(fadeEverything) { template.style.opacity = 1 - now; @@ -2660,14 +2660,14 @@ var _showOrHideTimeout, _showOrHideTimeout = setTimeout(onComplete, duration + 20); } } - + }, out ? 25 : 90); // Main purpose of this delay is to give browser time to paint and // create composite layers of PhotoSwipe UI parts (background, controls, caption, arrows). // Which avoids lag at the beginning of scale transition. }; startAnimation(); - + }; /*>>show-hide-transition*/ @@ -2676,7 +2676,7 @@ var _showOrHideTimeout, /** * * Controller manages gallery items, their dimensions, and their content. -* +* */ var _items, @@ -2700,8 +2700,8 @@ var _getItemAt, _initialIsLoop, _getZeroBounds = function() { return { - center:{x:0,y:0}, - max:{x:0,y:0}, + center:{x:0,y:0}, + max:{x:0,y:0}, min:{x:0,y:0} }; }, @@ -2713,14 +2713,14 @@ var _getItemAt, bounds.center.y = Math.round((_tempPanAreaSize.y - realPanElementH) / 2) + item.vGap.top; // maximum pan position - bounds.max.x = (realPanElementW > _tempPanAreaSize.x) ? - Math.round(_tempPanAreaSize.x - realPanElementW) : + bounds.max.x = (realPanElementW > _tempPanAreaSize.x) ? + Math.round(_tempPanAreaSize.x - realPanElementW) : bounds.center.x; - - bounds.max.y = (realPanElementH > _tempPanAreaSize.y) ? - Math.round(_tempPanAreaSize.y - realPanElementH) + item.vGap.top : + + bounds.max.y = (realPanElementH > _tempPanAreaSize.y) ? + Math.round(_tempPanAreaSize.y - realPanElementH) + item.vGap.top : bounds.center.y; - + // minimum pan position bounds.min.x = (realPanElementW > _tempPanAreaSize.x) ? 0 : bounds.center.x; bounds.min.y = (realPanElementH > _tempPanAreaSize.y) ? item.vGap.top : bounds.center.y; @@ -2729,7 +2729,7 @@ var _getItemAt, if (item.src && !item.loadError) { var isInitial = !zoomLevel; - + if(isInitial) { if(!item.vGap) { item.vGap = {top:0,bottom:0}; @@ -2762,10 +2762,10 @@ var _getItemAt, } item.initialZoomLevel = zoomLevel; - + if(!item.bounds) { // reuse bounds object - item.bounds = _getZeroBounds(); + item.bounds = _getZeroBounds(); } } @@ -2789,14 +2789,14 @@ var _getItemAt, // if it's not image, we return zero bounds (content is not zoomable) return item.bounds; } - + }, - + _appendImage = function(index, item, baseDiv, img, preventAnimation, keepPlaceholder) { - + if(item.loadError) { return; @@ -2806,7 +2806,7 @@ var _getItemAt, item.imageAppended = true; _setImageSize(item, img, (item === self.currItem && _renderMaxResolution) ); - + baseDiv.appendChild(img); if(keepPlaceholder) { @@ -2819,7 +2819,7 @@ var _getItemAt, } } }, - + _preloadImage = function(item) { @@ -2842,7 +2842,7 @@ var _getItemAt, img.onerror = function() { item.loadError = true; onComplete(); - }; + }; img.src = item.src;// + '?a=' + Math.random(); @@ -2857,7 +2857,7 @@ var _getItemAt, item.container.innerHTML = _options.errorMsg.replace('%url%', item.src ); return true; - + } }, _setImageSize = function(item, img, maxRes) { @@ -2871,7 +2871,7 @@ var _getItemAt, var w = maxRes ? item.w : Math.round(item.w * item.fitRatio), h = maxRes ? item.h : Math.round(item.h * item.fitRatio); - + if(item.placeholder && !item.loaded) { item.placeholder.style.width = w + 'px'; item.placeholder.style.height = h + 'px'; @@ -2894,7 +2894,7 @@ var _getItemAt, _imagesToAppendPool = []; } }; - + _registerModule('Controller', { @@ -2962,7 +2962,7 @@ _registerModule('Controller', { item = _items[i]; // remove reference to DOM elements, for GC if(item.container) { - item.container = null; + item.container = null; } if(item.placeholder) { item.placeholder = null; @@ -2990,17 +2990,17 @@ _registerModule('Controller', { }, allowProgressiveImg: function() { - // 1. Progressive image loading isn't working on webkit/blink + // 1. Progressive image loading isn't working on webkit/blink // when hw-acceleration (e.g. translateZ) is applied to IMG element. // That's why in PhotoSwipe parent element gets zoom transform, not image itself. - // + // // 2. Progressive image loading sometimes blinks in webkit/blink when applying animation to parent element. // That's why it's disabled on touch devices (mainly because of swipe transition) - // + // // 3. Progressive image loading sometimes doesn't work in IE (up to 11). // Don't allow progressive loading on non-large touch devices - return _options.forceProgressiveLoading || !_likelyTouchDevice || _options.mouseUsed || screen.width > 1200; + return _options.forceProgressiveLoading || !_likelyTouchDevice || _options.mouseUsed || screen.width > 1200; // 1200 - to eliminate touch devices with large screen (like Chromebook Pixel) }, @@ -3014,10 +3014,10 @@ _registerModule('Controller', { if(prevItem) { prevItem.container = null; } - + var item = self.getItemAt(index), img; - + if(!item) { holder.el.innerHTML = ''; return; @@ -3030,9 +3030,9 @@ _registerModule('Controller', { holder.item = item; // base container DIV is created only once for each of 3 holders - var baseDiv = item.container = framework.createEl('pswp__zoom-wrap'); + var baseDiv = item.container = framework.createEl('pswp__zoom-wrap'); + - if(!item.src && item.html) { if(item.html.tagName) { @@ -3045,7 +3045,7 @@ _registerModule('Controller', { _checkForError(item); _calculateItemSize(item, _viewportSize); - + if(item.src && !item.loadError && !item.loaded) { item.loadComplete = function(item) { @@ -3097,24 +3097,24 @@ _registerModule('Controller', { }; if(framework.features.transform) { - - var placeholderClassName = 'pswp__img pswp__img--placeholder'; + + var placeholderClassName = 'pswp__img pswp__img--placeholder'; placeholderClassName += (item.msrc ? '' : ' pswp__img--placeholder--blank'); var placeholder = framework.createEl(placeholderClassName, item.msrc ? 'img' : ''); if(item.msrc) { placeholder.src = item.msrc; } - + _setImageSize(item, placeholder); baseDiv.appendChild(placeholder); item.placeholder = placeholder; } - - + + if(!item.loading) { _preloadImage(item); @@ -3125,17 +3125,17 @@ _registerModule('Controller', { // just append image if(!_initialContentSet && _features.transform) { _imagesToAppendPool.push({ - item:item, - baseDiv:baseDiv, - img:item.img, - index:index, + item:item, + baseDiv:baseDiv, + img:item.img, + index:index, holder:holder }); } else { _appendImage(index, item, baseDiv, item.img, true, true); } } - + } else if(item.src && !item.loadError) { // image object is created every time, due to bugs of image loading & delay when switching images img = framework.createEl('pswp__img', 'img'); @@ -3144,7 +3144,7 @@ _registerModule('Controller', { _setImageSize(item, img); _appendImage(index, item, baseDiv, img, true); } - + if(!_initialContentSet && index === _currentItemIndex) { _currZoomElementStyle = baseDiv.style; @@ -3174,17 +3174,17 @@ _registerModule('Controller', { * tap.js: * * Displatches tap and double-tap events. - * + * */ var tapTimer, tapReleasePoint = {}, - _dispatchTapEvent = function(origEvent, releasePoint, pointerType) { + _dispatchTapEvent = function(origEvent, releasePoint, pointerType) { var e = document.createEvent( 'CustomEvent' ), eDetail = { - origEvent:origEvent, - target:origEvent.target, - releasePoint: releasePoint, + origEvent:origEvent, + target:origEvent.target, + releasePoint: releasePoint, pointerType:pointerType || 'touch' }; @@ -3260,11 +3260,11 @@ _registerModule('Tap', { * - Manages "dragging", "zoomed-in", "zoom-out" classes. * (which are used for cursors and zoom icon) * - Adds toggleDesktopZoom function. - * + * */ var _wheelDelta; - + _registerModule('DesktopZoom', { publicMethods: { @@ -3293,7 +3293,7 @@ _registerModule('DesktopZoom', { _wheelDelta = {}; var events = 'wheel mousewheel DOMMouseScroll'; - + _listen('bindEvents', function() { framework.bind(template, events, self.handleMouseWheel); }); @@ -3339,7 +3339,7 @@ _registerModule('DesktopZoom', { if(!onInit) { updateZoomable(); } - + }, handleMouseWheel: function(e) { @@ -3413,7 +3413,7 @@ _registerModule('DesktopZoom', { var doubleTapZoomLevel = _options.getDoubleTapZoom(true, self.currItem); var zoomOut = _currZoomLevel === doubleTapZoomLevel; - + self.mouseZoomedIn = !zoomOut; self.zoomTo(zoomOut ? self.currItem.initialZoomLevel : doubleTapZoomLevel, centerPoint, 333); @@ -3432,12 +3432,12 @@ _registerModule('DesktopZoom', { * history.js: * * - Back button to close gallery. - * + * * - Unique URL for each slide: example.com/&pid=1&gid=3 * (where PID is picture index, and GID and gallery index) - * + * * - Switch URL when slides change. - * + * */ @@ -3489,7 +3489,7 @@ var _historyUpdateTimeout, if(!vars[i]) { continue; } - var pair = vars[i].split('='); + var pair = vars[i].split('='); if(pair.length < 2) { continue; } @@ -3526,7 +3526,7 @@ var _historyUpdateTimeout, _hashAnimCheckTimeout = setTimeout(_updateHash, 500); return; } - + if(_hashChangedByScript) { clearTimeout(_hashChangeTimeout); } else { @@ -3564,8 +3564,8 @@ var _historyUpdateTimeout, _windowLoc.hash = newHash; } } - - + + _historyChanged = true; _hashChangeTimeout = setTimeout(function() { @@ -3575,11 +3575,11 @@ var _historyUpdateTimeout, - + _registerModule('History', { - + publicMethods: { initHistory: function() { @@ -3603,7 +3603,7 @@ _registerModule('History', { _initialHash = _initialHash.split('&gid=')[0]; _initialHash = _initialHash.split('?gid=')[0]; } - + _listen('afterChange', self.updateURL); _listen('unbindEvents', function() { @@ -3631,7 +3631,7 @@ _registerModule('History', { } } } - + } _cleanHistoryTimeouts(); @@ -3654,9 +3654,9 @@ _registerModule('History', { _currentItemIndex = _parseItemIndexFromURL().pid; }); - - + + var index = _initialHash.indexOf('pid='); if(index > -1) { _initialHash = _initialHash.substring(0, index); @@ -3664,14 +3664,14 @@ _registerModule('History', { _initialHash = _initialHash.slice(0, -1); } } - + setTimeout(function() { if(_isOpen) { // hasn't destroyed yet framework.bind(window, 'hashchange', self.onHashChange); } }, 40); - + }, onHashChange: function() { @@ -3687,15 +3687,15 @@ _registerModule('History', { self.goTo( _parseItemIndexFromURL().pid ); _hashChangedByHistory = false; } - + }, updateURL: function() { - // Delay the update of URL, to avoid lag during transition, + // Delay the update of URL, to avoid lag during transition, // and to not to trigger actions like "refresh page sound" or "blinking favicon" to often - + _cleanHistoryTimeouts(); - + if(_hashChangedByHistory) { return; @@ -3707,7 +3707,7 @@ _registerModule('History', { _historyUpdateTimeout = setTimeout(_updateHash, 800); } } - + } }); diff --git a/frappe/public/js/lib/prettydate.js b/frappe/public/js/lib/prettydate.js index 239e1b1b11..87f4b590bb 100644 --- a/frappe/public/js/lib/prettydate.js +++ b/frappe/public/js/lib/prettydate.js @@ -11,10 +11,10 @@ function prettyDate(time){ var date = new Date((time || "").replace(/-/g,"/").replace(/[TZ]/g," ").replace(/\.[0-9]*/, "")), diff = (((new Date()).getTime() - date.getTime()) / 1000), day_diff = Math.floor(diff / 86400); - + if ( isNaN(day_diff) || day_diff < 0 ) return ''; - + return day_diff == 0 && ( diff < 60 && "just now" || diff < 120 && "1 minute ago" || diff --git a/frappe/public/js/libs.bundle.js b/frappe/public/js/libs.bundle.js index 876d76875b..b71cc592a0 100644 --- a/frappe/public/js/libs.bundle.js +++ b/frappe/public/js/libs.bundle.js @@ -1,15 +1,12 @@ import "./jquery-bootstrap"; import Vue from "vue/dist/vue.esm.js"; -import moment from "moment/min/moment-with-locales.js"; -import momentTimezone from "moment-timezone/builds/moment-timezone-with-data.js"; +import "./lib/moment"; import io from "socket.io-client/dist/socket.io.slim.js"; import Sortable from "./lib/Sortable.min.js"; // TODO: esbuild // Don't think jquery.hotkeys is being used anywhere. Will remove this after being sure. // import "./lib/jquery/jquery.hotkeys.js"; -window.moment = moment; -window.moment = momentTimezone; window.Vue = Vue; window.Sortable = Sortable; window.io = io; diff --git a/frappe/public/js/print_format_builder/print_format_builder.bundle.js b/frappe/public/js/print_format_builder/print_format_builder.bundle.js index b2d3372daf..c8c03d209a 100644 --- a/frappe/public/js/print_format_builder/print_format_builder.bundle.js +++ b/frappe/public/js/print_format_builder/print_format_builder.bundle.js @@ -21,7 +21,7 @@ class PrintFormatBuilder { this.$component.toggle_preview(); } ); - this.page.add_button(__("Reset Changes"), () => + let $reset_changes_btn = this.page.add_button(__("Reset Changes"), () => this.$component.$store.reset_changes() ); this.page.add_menu_item(__("Edit Print Format"), () => { @@ -46,9 +46,11 @@ class PrintFormatBuilder { if (value) { this.page.set_indicator("Not Saved", "orange"); $toggle_preview_btn.hide(); + $reset_changes_btn.show(); } else { this.page.clear_indicator(); $toggle_preview_btn.show(); + $reset_changes_btn.hide(); } }); this.$component.$watch("show_preview", value => { diff --git a/frappe/public/js/web_form.bundle.js b/frappe/public/js/web_form.bundle.js index 01969a489c..ffb7b824bd 100644 --- a/frappe/public/js/web_form.bundle.js +++ b/frappe/public/js/web_form.bundle.js @@ -1,2 +1,3 @@ +import "./lib/moment.js"; import "./frappe/utils/datetime.js"; import "./frappe/web_form/webform_script.js"; diff --git a/frappe/public/scss/common/alert.scss b/frappe/public/scss/common/alert.scss new file mode 100644 index 0000000000..ba026fa48a --- /dev/null +++ b/frappe/public/scss/common/alert.scss @@ -0,0 +1,12 @@ +// Color overrides for https://getbootstrap.com/docs/4.0/components/alerts +$alert-types: info, success, warning, danger; + +.alert { + @each $alert-type in $alert-types { + &.alert-#{$alert-type} { + color: var(--alert-text-#{$alert-type}); + background-color: var(--alert-bg-#{$alert-type}); + border: none; + } + } +} \ No newline at end of file diff --git a/frappe/public/scss/common/buttons.scss b/frappe/public/scss/common/buttons.scss index de3a4cfc20..62479e7a7a 100644 --- a/frappe/public/scss/common/buttons.scss +++ b/frappe/public/scss/common/buttons.scss @@ -62,7 +62,7 @@ background-color: var(--control-bg); color: var(--text-color); &:hover, &:active { - background-color: var(--gray-300); + background-color: var(--gray-400); color: var(--text-color); } } diff --git a/frappe/public/scss/common/color_picker.scss b/frappe/public/scss/common/color_picker.scss index 84755beb18..2e53150a41 100644 --- a/frappe/public/scss/common/color_picker.scss +++ b/frappe/public/scss/common/color_picker.scss @@ -94,7 +94,10 @@ .frappe-control[data-fieldtype='Color'] { input { - padding-left: 40px; + padding-left: 38px; + } + .control-input { + position: relative; } .selected-color { cursor: pointer; @@ -103,7 +106,7 @@ border-radius: 5px; background-color: red; position: absolute; - top: calc(50% + 1px); + top: 5px; left: 8px; content: ' '; &.no-value { @@ -113,10 +116,9 @@ } .like-disabled-input { .color-value { - padding-left: 25px; + padding-left: 26px; } .selected-color { - top: 20%; cursor: default; } } diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index e5a0052f04..5871ac3626 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -136,6 +136,8 @@ --shadow-md: 0px 8px 14px rgba(25, 39, 52, 0.08), 0px 2px 6px rgba(25, 39, 52, 0.04); --shadow-lg: 0px 18px 22px rgba(25, 39, 52, 0.1), 0px 1px 10px rgba(0, 0, 0, 0.06), 0px 0.5px 5px rgba(25, 39, 52, 0.04); + --drop-shadow: 0px 0.5px 0px rgba(0, 0, 0, 0.05), 0px 0px 0px rgba(0, 0, 0, 0), 0px 2px 4px rgba(0, 0, 0, 0.05); + --modal-shadow: var(--shadow-md); --card-shadow: var(--shadow-sm); --btn-shadow: var(--shadow-xs); @@ -189,6 +191,16 @@ --text-on-pink: var(--pink-500); --text-on-cyan: var(--cyan-600); + // alert colors + --alert-text-danger: var(--red-600); + --alert-text-warning: var(--yellow-700); + --alert-text-info: var(--blue-700); + --alert-text-success: var(--green-700); + --alert-bg-danger: var(--red-50); + --alert-bg-warning: var(--yellow-50); + --alert-bg-info: var(--blue-50); + --alert-bg-success: var(--green-50); + // Layout Colors --bg-color: var(--gray-50); --fg-color: white; @@ -221,6 +233,10 @@ --highlight-shadow: 1px 1px 10px var(--blue-50), 0px 0px 4px var(--blue-600); + // code block + --code-block-bg: var(--gray-900); + --code-block-text: var(--gray-400); + // Border Sizes --border-radius-sm: 4px; --border-radius: 6px; @@ -235,6 +251,7 @@ --checkbox-right-margin: var(--margin-xs); --checkbox-size: 14px; --checkbox-focus-shadow: 0 0 0 2px var(--gray-300); + --checkbox-gradient: linear-gradient(180deg, #4AC3F8 -124.51%, var(--primary) 100%); --right-arrow-svg: url("data: image/svg+xml;utf8, "); --left-arrow-svg: url("data: image/svg+xml;utf8, "); diff --git a/frappe/public/scss/common/global.scss b/frappe/public/scss/common/global.scss index 0324b75bfb..8a849ab51a 100644 --- a/frappe/public/scss/common/global.scss +++ b/frappe/public/scss/common/global.scss @@ -54,7 +54,7 @@ input[type="radio"] { } &:checked::before { - background-color: var(--blue-500); + background-color: var(--primary); border-radius: 16px; box-shadow: inset 0 0 0 2px white; } @@ -85,8 +85,8 @@ input[type="checkbox"] { } &:checked { - background-color: var(--blue-500); - background-image: $check-icon, linear-gradient(180deg, #4AC3F8 -124.51%, #2490EF 100%); + background-color: var(--primary); + background-image: $check-icon, var(--checkbox-gradient); background-size: 57%, 100%; box-shadow: none; border: none; diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index 1903413fbb..d1f89abbcd 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -12,6 +12,13 @@ border-bottom: 1px solid var(--table-border-color); color: var(--text-muted); font-size: var(--text-md); + + .grid-static-col { + .static-area.reqd:after { + content: ' *'; + color: var(--red-400); + } + } } .rows .grid-row .data-row, @@ -54,7 +61,7 @@ } .form-grid .grid-heading-row .template-row { - margin-left: 20px; + margin-left: 8px; } .form-grid .template-row { @@ -82,12 +89,46 @@ height: 34px; padding: 8px; max-height: 200px; + + &.search { + padding: 7px !important; + + input { + height: -webkit-fill-available; + padding: 3px 7px; + } + } +} + +.row-check { + height: 34px; + padding: 8px 3px !important; + text-align: center; + + input { + margin-right: 0 !important; + } + + &.search { + padding: 0 !important; + } } .grid-row-check { margin-top: 2px; } +.template-row-index { + float: left; + margin-left: 15px; + margin-top: 8px; + margin-right: -20px; + + span { + margin-left: 5px; + } +} + .editable-form .grid-static-col.bold { font-weight: bold; } @@ -106,7 +147,6 @@ .grid-row > .row { .col:last-child { - margin-right: calc(-1 * var(--margin-sm)); border-right: none; } @@ -192,7 +232,7 @@ margin-left: var(--margin-xs); button { - height: 27px; + height: 24px; } } @@ -409,6 +449,7 @@ } .page-number { + background-color: var(--fg-color); padding: 0 3px; } diff --git a/frappe/public/scss/common/modal.scss b/frappe/public/scss/common/modal.scss index ec582591f2..c9217a075e 100644 --- a/frappe/public/scss/common/modal.scss +++ b/frappe/public/scss/common/modal.scss @@ -225,6 +225,11 @@ body.modal-open[style^="padding-right"] { } } +// modal is xs (for grids) +.modal .hidden-xs { + display: none !important; +} + .dialog-assignment-row { display: flex; align-items: center; diff --git a/frappe/public/scss/common/quill.scss b/frappe/public/scss/common/quill.scss index a243acba5f..3d5ce61c15 100644 --- a/frappe/public/scss/common/quill.scss +++ b/frappe/public/scss/common/quill.scss @@ -42,6 +42,7 @@ height: 300px; border-bottom-left-radius: var(--border-radius); border-bottom-right-radius: var(--border-radius); + resize: vertical; } .ql-stroke { stroke: var(--icon-stroke); @@ -85,10 +86,22 @@ margin-bottom: 8px; } +.ql-code-block-container { + background-color: var(--code-block-bg); + color: var(--code-block-text); + padding: var(--padding-xs) var(--padding-sm) !important; + margin-bottom: var(--margin-xs) !important; + margin-top: var(--margin-xs)!important; + border-radius: var(--border-radius-sm); +} + .ql-bubble .ql-editor { min-height: 100px; max-height: 300px; border-radius: var(--border-radius-sm); + .ql-code-block-container { + @extend .ql-code-block-container; + } } .ql-mention-list-container { diff --git a/frappe/public/scss/desk/css_variables.scss b/frappe/public/scss/desk/css_variables.scss index 0912cb278b..a06ba3e9b0 100644 --- a/frappe/public/scss/desk/css_variables.scss +++ b/frappe/public/scss/desk/css_variables.scss @@ -54,4 +54,6 @@ $input-height: 28px !default; // skeleton --skeleton-bg: var(--gray-100); + // progress bar + --progress-bar-bg: var(--primary); } diff --git a/frappe/public/scss/desk/dark.scss b/frappe/public/scss/desk/dark.scss index f894704ca2..6e5ebdb694 100644 --- a/frappe/public/scss/desk/dark.scss +++ b/frappe/public/scss/desk/dark.scss @@ -63,6 +63,16 @@ --text-on-light-gray: var(--gray-100); --text-on-purple: var(--purple-100); + // alert colors + --alert-text-danger: var(--red-300); + --alert-text-warning: var(--yellow-300); + --alert-text-info: var(--blue-300); + --alert-text-success: var(--green-300); + --alert-bg-danger: var(--red-900); + --alert-bg-warning: var(--yellow-900); + --alert-bg-info: var(--blue-900); + --alert-bg-success: var(--green-900); + --sidebar-select-color: var(--gray-800); --scrollbar-thumb-color: var(--gray-600); @@ -77,6 +87,8 @@ --highlight-shadow: 1px 1px 10px var(--blue-900), 0px 0px 4px var(--blue-500); + --shadow-base: 0px 4px 8px rgba(114, 176, 233, 0.06), 0px 0px 4px rgba(112, 172, 228, 0.12); + // input --input-disabled-bg: none; diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index 6ab01a744c..e2684706f0 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -107,13 +107,20 @@ body { } } +.divider { + height: 30%; + position: absolute; + top: 18px; + right: 0; + border-right: 1px solid var(--gray-400); +} + .widget { @include flex(flex, null, null, column); min-height: 1px; - padding: 15px; + padding: 7px; border-radius: var(--border-radius-md); height: 100%; - box-shadow: var(--card-shadow); background-color: var(--card-bg); .btn { @@ -143,10 +150,12 @@ body { font-weight: 500; line-height: 1.3em; color: var(--heading-color); + cursor: default; svg { flex: none; margin-right: 6px; + margin-left: -2px; box-shadow: none; } } @@ -329,9 +338,28 @@ body { } &.onboarding-widget-box { - margin-top: var(--margin-xs); margin-bottom: var(--margin-2xl); - padding: var(--padding-lg); + padding: var(--padding-lg) !important; + background-color: var(--bg-color); + + &.edit-mode:hover { + background-color: var(--fg-color); + + .onboarding-step { + &.active, + &:hover { + background-color: var(--bg-color); + + .step-index.step-pending { + background-color: var(--fg-color); + } + } + + .step-index { + background-color: var(--bg-color); + } + } + } .widget-head { display: flex; @@ -390,12 +418,6 @@ body { .step-index.step-pending { display: flex; } - - &.active { - .step-index.step-pending { - background-color: var(--fg-color); - } - } } &.complete { @@ -418,7 +440,11 @@ body { &.active, &:hover { - background-color: var(--bg-light-gray); + background-color: var(--fg-color); + + .step-index { + background-color: var(--bg-color); + } .step-skip { visibility: visible; @@ -434,7 +460,7 @@ body { height: 20px; width: 20px; color: var(--text-on-light-gray); - background-color: var(--bg-light-gray); + background-color: var(--fg-color); margin-right: var(--margin-sm); border-radius: var(--border-radius-full); @@ -447,7 +473,7 @@ body { display: none; background-color: var(--primary); .icon use { - stroke: var(--white); + stroke: var(--var(--fg-color)); } } @@ -496,7 +522,7 @@ body { } } - @media (max-width: map-get($grid-breakpoints, "sm")) { + @media (max-width: map-get($grid-breakpoints, "md")) { .widget-body { flex-direction: column; .onboarding-steps-wrapper { @@ -513,9 +539,19 @@ body { &.shortcut-widget-box { cursor: pointer; - .widget-head { - margin-top: var(--margin-xs); - margin-bottom: 5px; + &:hover { + .widget-title { + color: var(--blue-500) !important; + } + + svg.icon-xs { + stroke: var(--blue-500) !important; + } + } + + .widget-title { + cursor: pointer !important; + font-size: var(--text-base) !important; } .indicator-pill { @@ -525,21 +561,29 @@ body { } &.links-widget-box { + padding: 18px 12px; + .link-item { display: flex; text-decoration: none; + font-size: var(--text-md); color: var(--text-color); - padding: var(--padding-xs); - margin-left: -5px; + padding: 4px; + margin-left: -4px; + margin-bottom: 4px; border-radius: var(--border-radius-md); cursor: pointer; &:hover { - background-color: var(--bg-color); + background-color: var(--fg-hover-color); + + .indicator-pill { + background-color: var(--fg-color); + } } &:first-child { - margin-top: 15px; + margin-top: 18px; } &:last-child { @@ -566,6 +610,8 @@ body { .indicator-pill { margin-right: var(--margin-sm); + height: 20px; + padding: 3px 8px; } } } @@ -631,8 +677,8 @@ body { width: 18px; .icon-xs { - width: 8px; - height: 7px; + width: 10px; + height: 10px; } } @@ -757,6 +803,25 @@ body { } } +.workspace-sidebar-skeleton { + transition: ease; + .sidebar-box { + height: 40px; + margin-bottom: 10px; + margin-left: 10px; + background-color: var(--skeleton-bg); + + &.child { + margin-left: 30px; + } + + &.section { + height: 25px; + margin-left: 0px; + } + } +} + [data-page-route="Workspaces"] { @media (min-width: map-get($grid-breakpoints, "lg")) { .layout-main { @@ -764,7 +829,6 @@ body { .layout-side-section, .layout-main-section-wrapper { height: 100%; overflow-y: auto; - padding-right: 25px; scrollbar-color: var(--gray-200) transparent; [data-theme="dark"] & { scrollbar-color: var(--gray-800) transparent; @@ -783,7 +847,12 @@ body { } .layout-side-section { - margin-right: 20px; + padding-right: 15px; + } + + .layout-main-section { + padding: var(--padding-md); + margin-bottom: var(--margin-sm); } .desk-sidebar { @@ -792,9 +861,21 @@ body { } } + .layout-main-section-wrapper { + margin-top: -5px; + padding-top: 5px; + } + + .layout-main-section { + background-color: var(--fg-color); + box-shadow: var(--card-shadow); + border-radius: var(--border-radius-lg); + padding: var(--padding-sm); + } + .block-menu-item-icon svg{ - width: 12px; - height: 12px; + width: 18px; + height: 18px; margin-right: 5px; } @@ -803,7 +884,6 @@ body { padding: 0px; .sidebar-item-control { - > * { align-self: center; margin-left: 3px; @@ -816,7 +896,7 @@ body { display: none; } - .delete-page { + .setting-btn, .duplicate-page { display: none; } @@ -824,13 +904,13 @@ body { padding: 10px 12px 10px 2px; } - .sidebar-info { - display: none; - } - svg { margin-right: 0; } + + .dropdown-list { + top: 42px; + } } .sidebar-item-label { @@ -846,6 +926,7 @@ body { } .sidebar-item-container { + position: relative; .sidebar-item-container{ margin-left: 10px; @@ -863,19 +944,14 @@ body { display: inline-block; } - .delete-page { - display: inline-block; - margin-right: 8px; - } - - .sidebar-info { + .setting-btn, .duplicate-page { display: inline-block; margin-right: 8px; } .drop-icon { padding: 10px 8px 10px 2px; - margin-left: -4px; + margin-left: -8px; } } @@ -899,11 +975,81 @@ body { margin: 0px -7px; padding-bottom: 20px !important; - .ce-block{ + .ce-block { width: 100%; padding-left: 0; padding-right: 0; + .ce-header b { + font-weight: 600 !important; + } + + .new-block-button { + position: absolute; + top: 14px; + left: -22px; + cursor: pointer; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.5s ease-in-out; + } + + .edit-mode { + .widget-control > *, .paragraph-control > * { + width: 0px; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.5s ease-in-out; + } + + .link-item { + pointer-events: none; + } + } + + &:hover { + .widget-control > *, .new-block-button { + width: auto; + visibility: visible; + opacity: 1; + } + } + + &.ce-block--focused { + .widget { + box-shadow: var(--shadow-base) !important; + + .widget-control > * { + width: auto; + visibility: visible; + opacity: 1; + } + + &.shortcut, &.header { + background-color: var(--fg-color) !important; + } + + &.onboarding { + background-color: var(--fg-color); + + .onboarding-step { + &.active, + &:hover { + background-color: var(--bg-color); + + .step-index.step-pending { + background-color: var(--fg-color); + } + } + + .step-index { + background-color: var(--bg-color); + } + } + } + } + } + &.ce-block--selected { .ce-block__content { background-color: inherit; @@ -923,50 +1069,125 @@ body { pointer-events: none; } - .ce-header { - padding: 0 !important; - margin-bottom: 0 !important; - flex: 1; + .resizer { + width: 10px; + height: 100%; + position:absolute; + right: 0; + bottom: 0; + cursor: col-resize; + border-color: transparent; + transition: border-color 0.3s ease-in-out; + + &:hover { + border-right: 3px solid var(--gray-400) !important; + } } - .widget{ - &.header { - display: flex; - justify-content: center; - flex: 1; - padding-left: 15px !important; - padding-right: 15px !important; - min-height: 50px; - box-shadow: none; - background-color: var(--control-bg); - color: var(--text-muted); - } + .ce-header { + padding-left: 7px !important; + margin-bottom: 0 !important; + flex: 1; &:focus { outline: none; } + } - &.new-widget { - align-items: inherit; + .block-list-container { + left: 20px; + top: 55px !important; + width: 200px !important; + } + + .dropdown-title { + padding: 6px 10px; + font-size: smaller; + cursor: default; + } + + .ce-paragraph[data-placeholder]:empty::before { + opacity: 1; + } + + .widget { + &.edit-mode { + padding: 7px 12px; + + &:hover { + box-shadow: var(--shadow-base); + background-color: var(--fg-color); + } + + &.spacer { + align-items: inherit; + color: var(--text-muted); + border: 1px dashed var(--gray-400); + cursor: pointer; + + .widget-control > * { + width: auto; + } + + .spacer-left { + min-width: 74px; + } + } + } + + &.spacer { + height: 18px !important; } &.ce-paragraph { display: block; } - .paragraph-control { - display: flex; - flex-direction: row-reverse; - position: absolute; - right: 20px; - gap: 5px; - background-color: var(--card-bg); - padding-left: 5px; + &.paragraph { + cursor: text; - .drag-handle { - cursor: all-scroll; - cursor: grabbing; + .ce-paragraph { + padding: 2px; } + + .paragraph-control { + display: flex; + flex-direction: row-reverse; + position: absolute; + right: 20px; + gap: 5px; + background-color: var(--card-bg); + padding-left: 5px; + + .drag-handle { + cursor: all-scroll; + cursor: grabbing; + } + } + } + + &.header { + display: flex; + justify-content: center; + flex: 1; + padding-left: 0px !important; + min-height: 40px; + box-shadow: none; + background-color: var(--control-bg); + color: var(--text-muted); + cursor: text; + + .ce-header { + padding-left: 14px !important; + } + } + + &.shortcut { + background-color: var(--control-bg); + } + + &:focus { + outline: none; } } } @@ -978,14 +1199,21 @@ body { } .ce-toolbar { + + &.ce-toolbar--opened { + display: none; + } + svg { fill: currentColor; } .icon { stroke: none; - width: fit-content; - height: fit-content; + + &.icon--plus { + width: 14px; + } } .ce-settings { @@ -993,6 +1221,10 @@ body { .ce-settings__button, .cdx-settings-button { color: #707684; + + .icon { + width: 14px; + } } .cdx-settings-button--active { @@ -1024,6 +1256,10 @@ body { .icon { fill: currentColor; } + + svg { + stroke: none; + } } @media (min-width: 1199px) { @@ -1037,25 +1273,63 @@ body { } } - @media (max-width: 1199px) { - .ce-block.col-4 { - flex: 0 0 50%; - max-width: 50%; - } - } - - @media (max-width: 750px) { - .ce-block.col-4 { - flex: 0 0 100%; - max-width: 100%; - } - } - @media (max-width: 750px) { - .ce-block.col-6 { - flex: 0 0 100%; - max-width: 100%; - } - } - } + + .cdx-marker { + background: rgba(245,235,111,0.29); + padding: 3px 0; + } + + .header-inline-tool { + border: none; + background-color: transparent; + margin-bottom: 2px; + } + + .header-level-select { + display: flex; + flex-direction: column; + padding: 6px; + } + + .header-level-select .header-level { + border: none; + background-color: transparent; + border-radius: var(--border-radius-sm); + padding: 6px; + margin: 2px 0px; + + &:hover { + background-color: var(--fg-hover-color); + } + } + + .dropdown-btn { + position: relative; + } + + .dropdown-list { + position: absolute; + background-color: var(--fg-color); + box-shadow: var(--shadow-base) !important; + border-radius: var(--border-radius-sm); + padding: 6px; + top: 30px; + right: 0; + width: 150px; + z-index: 1; + } + + .dropdown-list .dropdown-item { + cursor: pointer; + padding: 6px 10px; + font-size: small; + border-radius: var(--border-radius-sm); + margin: 1px 0px; + } + + .dropdown-item-icon { + margin-right: 5px; + } + } diff --git a/frappe/public/scss/desk/frappe_datatable.scss b/frappe/public/scss/desk/frappe_datatable.scss index 48645bbbfc..cfec31b72e 100644 --- a/frappe/public/scss/desk/frappe_datatable.scss +++ b/frappe/public/scss/desk/frappe_datatable.scss @@ -58,7 +58,7 @@ } .link-btn { - top: 6px; + top: 0px; } select { @@ -77,7 +77,7 @@ padding: 0; border: var(--dt-focus-border-width) solid #9bccf8; - input { + input[type="text"] { font-size: inherit; height: 27px; @@ -87,6 +87,11 @@ } } + .dt-cell__resize-handle { + right: -3px !important; + left: unset !important; + } + .dt-row.dt-row-totalRow { font-weight: bold; } diff --git a/frappe/public/scss/desk/index.scss b/frappe/public/scss/desk/index.scss index 1d1124bd58..5d712f7ad6 100644 --- a/frappe/public/scss/desk/index.scss +++ b/frappe/public/scss/desk/index.scss @@ -2,6 +2,7 @@ @import "../common/mixins.scss"; @import "../common/global.scss"; @import "../common/icons.scss"; +@import "../common/alert.scss"; @import "~bootstrap/scss/bootstrap"; @import "global"; diff --git a/frappe/public/scss/desk/list.scss b/frappe/public/scss/desk/list.scss index a49b5a463e..c71dbdca89 100644 --- a/frappe/public/scss/desk/list.scss +++ b/frappe/public/scss/desk/list.scss @@ -187,7 +187,31 @@ $level-margin-right: 8px; } .list-paging-area, .footnote-area { - border-top: 1px sol var(--border-color); + border-top: 1px solid var(--border-color); + + .btn-group { + box-shadow: var(--drop-shadow); + border-radius: var(--border-radius-md); + + &> .btn:nth-child(2) { + border-left: none; + border-right: none; + } + + .btn-paging { + box-shadow: none; + margin-left: 0px !important; + border: 1px solid var(--dark-border-color); + + &.btn-info { + background-color: var(--gray-400); + border-color: var(--gray-400); + color: var(--white); + font-weight: var(--text-bold); + } + } + } + } .frappe-card { diff --git a/frappe/public/scss/desk/page.scss b/frappe/public/scss/desk/page.scss index 91bd942889..2df349cb6c 100644 --- a/frappe/public/scss/desk/page.scss +++ b/frappe/public/scss/desk/page.scss @@ -88,6 +88,7 @@ top: var(--navbar-height); background: var(--bg-color); margin-bottom: 5px; + transition: 0.5s top; .page-head-content { height: var(--page-head-height); } @@ -164,7 +165,7 @@ // To compensate for perceived centering .null-state { - height: 85px; + height: 60px; width: auto; margin-bottom: var(--margin-md); img { diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 7430044878..0bb6ba5f40 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -104,10 +104,10 @@ body[data-route^="Module"] .main-menu { } .sidebar-image-section { + width: min(100%, 170px); cursor: pointer; .sidebar-image { - width: 100%; height: auto; max-height: 170px; object-fit: cover; diff --git a/frappe/public/scss/desk/timeline.scss b/frappe/public/scss/desk/timeline.scss index 1861ee018b..a99f2648e8 100644 --- a/frappe/public/scss/desk/timeline.scss +++ b/frappe/public/scss/desk/timeline.scss @@ -57,35 +57,6 @@ $threshold: 34; } } } - .timeline-actions { - display: inline-flex; - width: 100%; - margin-bottom: var(--timeline-item-bottom-margin); - padding: var(--padding-sm); - position: relative; - .action-btn { - margin-left: var(--margin-md); - display: flex; - align-items: center; - line-height: var(--text-xl); - .icon { - margin-right: var(--margin-xs); - } - } - .action-btn:first-of-type { - margin-left: var(--timeline-item-left-margin); - } - } - .document-email-link-container { - @extend .ellipsis; - position: relative; - padding: var(--padding-sm); - font-size: var(--text-sm); - margin-bottom: var(--timeline-item-bottom-margin); - span:first-of-type { - margin-left: var(--timeline-item-left-margin); - } - } .timeline-item { font-size: var(--text-md); position: relative; @@ -94,6 +65,23 @@ $threshold: 34; color: var(--text-color); font-weight: var(--text-bold); } + .action-buttons { + display: inline-flex; + white-space: nowrap; + overflow: auto; + .action-btn { + margin-left: var(--margin-md); + display: flex; + align-items: center; + line-height: var(--text-xl); + .icon { + margin-right: var(--margin-xs); + } + } + .action-btn:first-of-type { + margin-left: 0; + } + } .timeline-content { max-width: var(--timeline-content-max-width); padding: var(--padding-sm); diff --git a/frappe/public/scss/desk/toast.scss b/frappe/public/scss/desk/toast.scss index d10cfd871e..862b42cd76 100644 --- a/frappe/public/scss/desk/toast.scss +++ b/frappe/public/scss/desk/toast.scss @@ -9,7 +9,27 @@ } } -#alert-container .desk-alert { +.desk-alert { + &.red { + --toast-bg: var(--alert-bg-danger); + } + + &.yellow { + --toast-bg: var(--alert-bg-warning); + } + + &.orange { + --toast-bg: var(--alert-bg-warning); + } + + &.blue { + --toast-bg: var(--alert-bg-info); + } + + &.green { + --toast-bg: var(--alert-bg-success); + } + box-shadow: var(--modal-shadow); width: 400px; min-height: 50px; @@ -46,7 +66,7 @@ .alert-subtitle { font-size: var(--text-md); padding-left: 34px; - color: var(--text-muted); + color: var(--text-light); } } diff --git a/frappe/public/scss/desk/variables.scss b/frappe/public/scss/desk/variables.scss index 2855277ccd..abc63cd637 100644 --- a/frappe/public/scss/desk/variables.scss +++ b/frappe/public/scss/desk/variables.scss @@ -118,6 +118,9 @@ $custom-control-label-color: var(--text-color); $custom-switch-indicator-size: 8px; $custom-control-indicator-border-width: 2px; +// progress bar +$progress-bar-bg: var(--progress-bar-bg); + $navbar-nav-link-padding-x: 1rem !default; $navbar-padding-y: 1rem !default; $card-border-radius: 0.75rem !default; diff --git a/frappe/public/scss/login.bundle.scss b/frappe/public/scss/login.bundle.scss index 0c8c2d58e2..8d0a32846f 100644 --- a/frappe/public/scss/login.bundle.scss +++ b/frappe/public/scss/login.bundle.scss @@ -150,7 +150,7 @@ body { min-width: 50%; padding: 0 4px; margin-bottom: var(--margin-md); - + &:last-child { margin-bottom: 0; } diff --git a/frappe/public/scss/website/blog.scss b/frappe/public/scss/website/blog.scss index e599210435..6b0acb9d83 100644 --- a/frappe/public/scss/website/blog.scss +++ b/frappe/public/scss/website/blog.scss @@ -163,18 +163,18 @@ padding: var(--padding-lg); box-shadow: var(--card-shadow); border-radius: var(--border-radius-md); - + .new-comment-fields { flex: 1; - + .form-label { font-weight: var(--text-bold); } - + .comment-text-area textarea { resize: none; } - + @media (min-width: 576px) { .comment-by { padding-right: 0px !important; @@ -184,7 +184,7 @@ } } } - + #comment-list { position: relative; @@ -206,7 +206,7 @@ top: 10px; left: -17px; } - + .comment-content { box-shadow: var(--card-shadow); border-radius: var(--border-radius-md); diff --git a/frappe/public/scss/website/error-state.scss b/frappe/public/scss/website/error-state.scss index 6f88009ecb..c869e9e1df 100644 --- a/frappe/public/scss/website/error-state.scss +++ b/frappe/public/scss/website/error-state.scss @@ -9,7 +9,7 @@ width: 80% } } - + .back-to-home { font-size: var(--text-base); } diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss index 69a7b205c4..e36e649eb7 100644 --- a/frappe/public/scss/website/index.scss +++ b/frappe/public/scss/website/index.scss @@ -4,6 +4,7 @@ @import "../common/mixins"; @import "../common/global"; @import "../common/icons"; +@import "../common/alert"; @import 'base'; @import "../common/flex"; @import "../common/buttons"; @@ -30,12 +31,6 @@ @import 'my_account'; -body { - @include media-breakpoint-up(sm) { - background-color: var(--bg-color); - } -} - .ql-editor.read-mode { padding: 0; line-height: 1.6; @@ -85,6 +80,8 @@ body { .dropdown-menu { padding: 0.25rem; + box-shadow: var(--shadow-lg); + border-color: var(--gray-200); } .dropdown-item { @@ -137,7 +134,7 @@ body { a { color: var(--text-color) } - + li.disabled { a { color: var(--text-muted); @@ -313,4 +310,17 @@ h5.modal-title { .empty-list-icon { height: 70px; -} \ No newline at end of file +} + +.null-state { + height: 60px; + width: auto; + margin-bottom: var(--margin-md); + img { + fill: var(--fg-color); + } +} + +.no-result { + min-height: #{"calc(100vh - 284px)"}; +} diff --git a/frappe/public/scss/website/my_account.scss b/frappe/public/scss/website/my_account.scss index bdc52588aa..22b29cc3ec 100644 --- a/frappe/public/scss/website/my_account.scss +++ b/frappe/public/scss/website/my_account.scss @@ -1,7 +1,8 @@ //styles for my account and edit-profile page @include media-breakpoint-up(sm) { body[data-path="me"], - body[data-path="list"] { + body[data-path="list"], + body[data-path="update-profile"] { background-color: var(--bg-color); } } diff --git a/frappe/public/scss/website/navbar.scss b/frappe/public/scss/website/navbar.scss index d70d064d58..effdce3d5e 100644 --- a/frappe/public/scss/website/navbar.scss +++ b/frappe/public/scss/website/navbar.scss @@ -46,7 +46,7 @@ .navbar-toggler { border-color: rgba(255,255,255, 0.1); - + .icon { stroke: none; } diff --git a/frappe/public/scss/website/portal.scss b/frappe/public/scss/website/portal.scss index 33371d5693..1b724c6816 100644 --- a/frappe/public/scss/website/portal.scss +++ b/frappe/public/scss/website/portal.scss @@ -1,6 +1,6 @@ .portal-row { padding: 1rem 0; - + a { color: $body-color; } diff --git a/frappe/public/scss/website/web_form.scss b/frappe/public/scss/website/web_form.scss index cb79f88266..b7df649f59 100644 --- a/frappe/public/scss/website/web_form.scss +++ b/frappe/public/scss/website/web_form.scss @@ -3,10 +3,10 @@ [data-doctype="Web Form"] { .page-content-wrapper { - + .breadcrumb-container.container { @include media-breakpoint-up(sm) { - padding-left: var(--padding-sm); + padding-left: 0; } } @@ -50,6 +50,10 @@ &:last-child { padding-right: 0; } + + @include media-breakpoint-down(sm) { + padding: 0; + } } } diff --git a/frappe/query_builder/__init__.py b/frappe/query_builder/__init__.py index bf7be84c51..5b58e70c4e 100644 --- a/frappe/query_builder/__init__.py +++ b/frappe/query_builder/__init__.py @@ -1,8 +1,21 @@ -from frappe.query_builder.terms import ParameterizedValueWrapper, ParameterizedFunction -import pypika +import pypika.terms +from pypika import * +from pypika import Field +from pypika.utils import ignore_copy + +from frappe.query_builder.terms import ParameterizedFunction, ParameterizedValueWrapper +from frappe.query_builder.utils import ( + Column, + DocType, + get_query_builder, + patch_query_aggregation, + patch_query_execute, +) pypika.terms.ValueWrapper = ParameterizedValueWrapper pypika.terms.Function = ParameterizedFunction -from pypika import * -from frappe.query_builder.utils import Column, DocType, get_query_builder, patch_query_execute, patch_query_aggregation +# * Overrides the field() method and replaces it with the a `PseudoColumn` 'field' for consistency +pypika.queries.Selectable.__getattr__ = ignore_copy(lambda table, x: Field(x, table=table)) +pypika.queries.Selectable.__getitem__ = ignore_copy(lambda table, x: Field(x, table=table)) +pypika.queries.Selectable.field = pypika.terms.PseudoColumn("field") diff --git a/frappe/query_builder/builder.py b/frappe/query_builder/builder.py index a65d50fdeb..d2fdeab324 100644 --- a/frappe/query_builder/builder.py +++ b/frappe/query_builder/builder.py @@ -1,8 +1,12 @@ from pypika import MySQLQuery, Order, PostgreSQLQuery, terms -from pypika.queries import Schema, Table -from frappe.utils import get_table_name +from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder +from pypika.queries import QueryBuilder, Schema, Table from pypika.terms import Function +from frappe.query_builder.terms import ParameterizedValueWrapper +from frappe.utils import get_table_name + + class Base: terms = terms desc = Order.desc @@ -19,13 +23,13 @@ class Base: return Table(table_name, *args, **kwargs) @classmethod - def into(cls, table, *args, **kwargs): + def into(cls, table, *args, **kwargs) -> QueryBuilder: if isinstance(table, str): table = cls.DocType(table) return super().into(table, *args, **kwargs) @classmethod - def update(cls, table, *args, **kwargs): + def update(cls, table, *args, **kwargs) -> QueryBuilder: if isinstance(table, str): table = cls.DocType(table) return super().update(table, *args, **kwargs) @@ -34,6 +38,10 @@ class Base: class MariaDB(Base, MySQLQuery): Field = terms.Field + @classmethod + def _builder(cls, *args, **kwargs) -> "MySQLQueryBuilder": + return super()._builder(*args, wrapper_cls=ParameterizedValueWrapper, **kwargs) + @classmethod def from_(cls, table, *args, **kwargs): if isinstance(table, str): @@ -53,6 +61,10 @@ class Postgres(Base, PostgreSQLQuery): # they are two different objects. The quick fix used here is to replace the # Field names in the "Field" function. + @classmethod + def _builder(cls, *args, **kwargs) -> "PostgreSQLQueryBuilder": + return super()._builder(*args, wrapper_cls=ParameterizedValueWrapper, **kwargs) + @classmethod def Field(cls, field_name, *args, **kwargs): if field_name in cls.field_translation: diff --git a/frappe/query_builder/functions.py b/frappe/query_builder/functions.py index c98df775b7..9d12358f0d 100644 --- a/frappe/query_builder/functions.py +++ b/frappe/query_builder/functions.py @@ -1,5 +1,5 @@ from pypika.functions import * -from pypika.terms import Function +from pypika.terms import Function, CustomFunction, ArithmeticExpression, Arithmetic from frappe.query_builder.utils import ImportMapper, db_type_is from frappe.query_builder.custom import GROUP_CONCAT, STRING_AGG, MATCH, TO_TSVECTOR from frappe.database.query import Query @@ -25,6 +25,24 @@ Match = ImportMapper( } ) +class _PostgresTimestamp(ArithmeticExpression): + def __init__(self, datepart, timepart, alias=None): + if isinstance(datepart, str): + datepart = Cast(datepart, "date") + if isinstance(timepart, str): + timepart = Cast(timepart, "time") + + super().__init__(operator=Arithmetic.add, + left=datepart, right=timepart, alias=alias) + + +CombineDatetime = ImportMapper( + { + db_type_is.MARIADB: CustomFunction("TIMESTAMP", ["date", "time"]), + db_type_is.POSTGRES: _PostgresTimestamp, + } +) + def _aggregate(function, dt, fieldname, filters, **kwargs): return ( @@ -46,4 +64,4 @@ def _avg(dt, fieldname, filters=None, **kwargs): return _aggregate(Avg, dt, fieldname, filters, **kwargs) def _sum(dt, fieldname, filters=None, **kwargs): - return _aggregate(Sum, dt, fieldname, filters, **kwargs) \ No newline at end of file + return _aggregate(Sum, dt, fieldname, filters, **kwargs) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index 2032cd8497..205f1f9dcd 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -1,33 +1,77 @@ +from datetime import timedelta from typing import Any, Dict, Optional +from frappe.utils.data import format_timedelta from pypika.terms import Function, ValueWrapper from pypika.utils import format_alias_sql -class NamedParameterWrapper(): - def __init__(self, parameters: Dict[str, Any]): - self.parameters = parameters +class NamedParameterWrapper: + """Utility class to hold parameter values and keys""" - def update_parameters(self, param_key: Any, param_value: Any, **kwargs): + def __init__(self) -> None: + self.parameters = {} + + def get_sql(self, param_value: Any, **kwargs) -> str: + """returns SQL for a parameter, while adding the real value in a dict + + Args: + param_value (Any): Value of the parameter + + Returns: + str: parameter used in the SQL query + """ + param_key = f"%(param{len(self.parameters) + 1})s" self.parameters[param_key[2:-2]] = param_value + return param_key - def get_sql(self, **kwargs): - return f'%(param{len(self.parameters) + 1})s' + def get_parameters(self) -> Dict[str, Any]: + """get dict with parameters and values + + Returns: + Dict[str, Any]: parameter dict + """ + return self.parameters class ParameterizedValueWrapper(ValueWrapper): - def get_sql(self, quote_char: Optional[str] = None, secondary_quote_char: str = "'", param_wrapper= None, **kwargs: Any) -> str: - if param_wrapper is None: - sql = self.get_value_sql(quote_char=quote_char, secondary_quote_char=secondary_quote_char, **kwargs) - return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) + """ + Class to monkey patch ValueWrapper + + Adds functionality to parameterize queries when a `param wrapper` is passed in get_sql() + """ + + def get_sql( + self, + quote_char: Optional[str] = None, + secondary_quote_char: str = "'", + param_wrapper: Optional[NamedParameterWrapper] = None, + **kwargs: Any, + ) -> str: + if param_wrapper and isinstance(self.value, str): + # add quotes if it's a string value + value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) + sql = param_wrapper.get_sql(param_value=value_sql, **kwargs) else: - value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) if not isinstance(self.value,int) else self.value - param_sql = param_wrapper.get_sql(**kwargs) - param_wrapper.update_parameters(param_key=param_sql, param_value=value_sql, **kwargs) - return format_alias_sql(param_sql, self.alias, quote_char=quote_char, **kwargs) + # * BUG: pypika doesen't parse timedeltas + if isinstance(self.value, timedelta): + self.value = format_timedelta(self.value) + sql = self.get_value_sql( + quote_char=quote_char, + secondary_quote_char=secondary_quote_char, + param_wrapper=param_wrapper, + **kwargs, + ) + return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) class ParameterizedFunction(Function): + """ + Class to monkey patch pypika.terms.Functions + + Only to pass `param_wrapper` in `get_function_sql`. + """ + def get_sql(self, **kwargs: Any) -> str: with_alias = kwargs.pop("with_alias", False) with_namespace = kwargs.pop("with_namespace", False) @@ -35,15 +79,24 @@ class ParameterizedFunction(Function): dialect = kwargs.pop("dialect", None) param_wrapper = kwargs.pop("param_wrapper", None) - function_sql = self.get_function_sql(with_namespace=with_namespace, quote_char=quote_char, param_wrapper=param_wrapper, dialect=dialect) + function_sql = self.get_function_sql( + with_namespace=with_namespace, + quote_char=quote_char, + param_wrapper=param_wrapper, + dialect=dialect, + ) if self.schema is not None: function_sql = "{schema}.{function}".format( - schema=self.schema.get_sql(quote_char=quote_char, dialect=dialect, **kwargs), + schema=self.schema.get_sql( + quote_char=quote_char, dialect=dialect, **kwargs + ), function=function_sql, ) if with_alias: - return format_alias_sql(function_sql, self.alias, quote_char=quote_char, **kwargs) + return format_alias_sql( + function_sql, self.alias, quote_char=quote_char, **kwargs + ) return function_sql diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index 2767e90242..1ddf4fc034 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -1,17 +1,17 @@ from enum import Enum -from typing import Any, Callable, Dict, Union, get_type_hints from importlib import import_module +from typing import Any, Callable, Dict, Union, get_type_hints from pypika import Query from pypika.queries import Column - -import frappe - -from .builder import MariaDB, Postgres from pypika.terms import PseudoColumn +import frappe from frappe.query_builder.terms import NamedParameterWrapper +from .builder import MariaDB, Postgres + + class db_type_is(Enum): MARIADB = "mariadb" POSTGRES = "postgres" @@ -59,11 +59,29 @@ def patch_query_execute(): return frappe.db.sql(query, params, *args, **kwargs) # nosemgrep def prepare_query(query): - params = {} - query = query.get_sql(param_wrapper = NamedParameterWrapper(params)) + import inspect + + param_collector = NamedParameterWrapper() + query = query.get_sql(param_wrapper=param_collector) if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"): - raise frappe.PermissionError('Only SELECT SQL allowed in scripting') - return query, params + callstack = inspect.stack() + if len(callstack) >= 3 and ".py" in callstack[2].filename: + # ignore any query builder methods called from python files + # assumption is that those functions are whitelisted already. + + # since query objects are patched everywhere any query.run() + # will have callstack like this: + # frame0: this function prepare_query() + # frame1: execute_query() + # frame2: frame that called `query.run()` + # + # if frame2 is server script it wont have a filename and hence + # it shouldn't be allowed. + # ps. stack() returns `""` as filename. + pass + else: + raise frappe.PermissionError('Only SELECT SQL allowed in scripting') + return query, param_collector.get_parameters() query_class = get_attr(str(frappe.qb).split("'")[1]) builder_class = get_type_hints(query_class._builder).get('return') @@ -78,7 +96,7 @@ def patch_query_execute(): def patch_query_aggregation(): """Patch aggregation functions to frappe.qb """ - from frappe.query_builder.functions import _max, _min, _avg, _sum + from frappe.query_builder.functions import _avg, _max, _min, _sum frappe.qb.max = _max frappe.qb.min = _min diff --git a/frappe/realtime.py b/frappe/realtime.py index aa0e2fddad..940a3220a4 100644 --- a/frappe/realtime.py +++ b/frappe/realtime.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import frappe +from frappe.utils.data import cstr import os import redis @@ -65,7 +66,7 @@ def publish_realtime(event=None, message=None, room=None, if after_commit: params = [event, message, room] - if not params in frappe.local.realtime_log: + if params not in frappe.local.realtime_log: frappe.local.realtime_log.append(params) else: emit_via_redis(event, message, room) @@ -118,7 +119,7 @@ def get_user_info(): } def get_doc_room(doctype, docname): - return ''.join([frappe.local.site, ':doc:', doctype, '/', docname]) + return ''.join([frappe.local.site, ':doc:', doctype, '/', cstr(docname)]) def get_user_room(user): return ''.join([frappe.local.site, ':user:', user]) diff --git a/frappe/search/full_text_search.py b/frappe/search/full_text_search.py index 1d4f3fef32..79ccd3c6d5 100644 --- a/frappe/search/full_text_search.py +++ b/frappe/search/full_text_search.py @@ -66,7 +66,7 @@ class FullTextSearch: ix = self.get_index() with ix.searcher(): - writer = ix.writer() + writer = AsyncWriter(ix) writer.delete_by_term(self.id, doc_name) writer.commit(optimize=True) @@ -98,7 +98,7 @@ class FullTextSearch: def build_index(self): """Build index for all parsed documents""" ix = self.create_index() - writer = ix.writer() + writer = AsyncWriter(ix) for i, document in enumerate(self.documents): if document: diff --git a/frappe/sessions.py b/frappe/sessions.py index 6c9acdba13..6a5771b617 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -112,7 +112,7 @@ def get_expired_sessions(): frappe.db.get_values( sessions, filters=( - PseudoColumn(f"({Now() - sessions.lastupdate})") + PseudoColumn(f"({Now()} - {sessions.lastupdate.get_sql()})") > get_expiry_period_for_query(device) ) & (sessions.device == device), @@ -334,7 +334,7 @@ class Session: sessions, filters=(sessions.sid == self.sid) & ( - PseudoColumn(f"({Now() - sessions.lastupdate})") + PseudoColumn(f"({Now()} - {sessions.lastupdate.get_sql()})") < get_expiry_period_for_query(self.device) ), fieldname=["user", "sessiondata"], @@ -374,7 +374,7 @@ class Session: # database persistence is secondary, don't update it too often updated_in_db = False - if force or (time_diff==None) or (time_diff > 600): + if force or (time_diff is None) or (time_diff > 600): # update sessions table frappe.db.sql("""update `tabSessions` set sessiondata=%s, lastupdate=NOW() where sid=%s""" , (str(self.data['data']), diff --git a/frappe/social/doctype/energy_point_log/energy_point_log.py b/frappe/social/doctype/energy_point_log/energy_point_log.py index 86843302e9..5e6a9df16f 100644 --- a/frappe/social/doctype/energy_point_log/energy_point_log.py +++ b/frappe/social/doctype/energy_point_log/energy_point_log.py @@ -164,6 +164,7 @@ def get_alert_dict(doc): return alert_dict + def create_energy_points_log(ref_doctype, ref_name, doc, apply_only_once=False): doc = frappe._dict(doc) @@ -171,7 +172,7 @@ def create_energy_points_log(ref_doctype, ref_name, doc, apply_only_once=False): ref_name, doc.rule, None if apply_only_once else doc.user) if log_exists: - return + return frappe.get_doc('Energy Point Log', log_exists) new_log = frappe.new_doc('Energy Point Log') new_log.reference_doctype = ref_doctype diff --git a/frappe/social/doctype/energy_point_log/test_energy_point_log.py b/frappe/social/doctype/energy_point_log/test_energy_point_log.py index a1f4503c34..a7d45ef61f 100644 --- a/frappe/social/doctype/energy_point_log/test_energy_point_log.py +++ b/frappe/social/doctype/energy_point_log/test_energy_point_log.py @@ -2,12 +2,14 @@ # Copyright (c) 2019, Frappe Technologies and Contributors # License: MIT. See LICENSE import frappe -import unittest + +from frappe.tests.utils import FrappeTestCase from .energy_point_log import get_energy_points as _get_energy_points, create_review_points_log, review from frappe.utils.testutils import add_custom_field, clear_custom_fields from frappe.desk.form.assign_to import add as assign_to -class TestEnergyPointLog(unittest.TestCase): + +class TestEnergyPointLog(FrappeTestCase): @classmethod def setUpClass(cls): settings = frappe.get_single('Energy Point Settings') @@ -140,9 +142,10 @@ class TestEnergyPointLog(unittest.TestCase): # for criticism criticism_points = 2 + todo = create_a_todo(description='Bad patch') energy_points_before_review = energy_points_after_review review_points_before_review = review_points_after_review - review(created_todo, criticism_points, 'test@example.com', 'You could have done better.', 'Criticism') + review(todo, criticism_points, 'test@example.com', 'You could have done better.', 'Criticism') energy_points_after_review = get_points('test@example.com') review_points_after_review = get_points('test2@example.com', 'review_points') self.assertEqual(energy_points_after_review, energy_points_before_review - criticism_points) @@ -332,11 +335,14 @@ def create_energy_point_rule_for_todo(multiplier_field=None, for_doc_event='Cust 'apply_only_once': apply_once }).insert(ignore_permissions=1) -def create_a_todo(): + +def create_a_todo(description=None): + if not description: + description = 'Fix a bug' return frappe.get_doc({ 'doctype': 'ToDo', - 'description': 'Fix a bug', - }).insert() + 'description': description, + }).insert(ignore_permissions=True) def get_points(user, point_type='energy_points'): diff --git a/frappe/social/doctype/energy_point_rule/energy_point_rule.py b/frappe/social/doctype/energy_point_rule/energy_point_rule.py index b48ec396de..55bf55a3b0 100644 --- a/frappe/social/doctype/energy_point_rule/energy_point_rule.py +++ b/frappe/social/doctype/energy_point_rule/energy_point_rule.py @@ -57,11 +57,11 @@ class EnergyPointRule(Document): def rule_condition_satisfied(self, doc): if self.for_doc_event == 'New': # indicates that this was a new doc - return doc.get_doc_before_save() == None + return doc.get_doc_before_save() is None if self.for_doc_event == 'Submit': - return doc.docstatus == 1 + return doc.docstatus.is_submitted() if self.for_doc_event == 'Cancel': - return doc.docstatus == 2 + return doc.docstatus.is_cancelled() if self.for_doc_event == 'Value Change': field_to_check = self.field_to_check if not field_to_check: return False @@ -96,7 +96,7 @@ def process_energy_points(doc, state): old_doc = doc.get_doc_before_save() # check if doc has been cancelled - if old_doc and old_doc.docstatus == 1 and doc.docstatus == 2: + if old_doc and old_doc.docstatus.is_submitted() and doc.docstatus.is_cancelled(): return revert_points_for_cancelled_doc(doc) for d in frappe.cache_manager.get_doctype_map('Energy Point Rule', doc.doctype, diff --git a/frappe/social/doctype/post/post.js b/frappe/social/doctype/post/post.js deleted file mode 100644 index 0294691812..0000000000 --- a/frappe/social/doctype/post/post.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('Post', { - refresh: function() { - - } -}); diff --git a/frappe/social/doctype/post/post.json b/frappe/social/doctype/post/post.json deleted file mode 100644 index af70ce0793..0000000000 --- a/frappe/social/doctype/post/post.json +++ /dev/null @@ -1,343 +0,0 @@ -{ - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-09-25 11:39:04.533626", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "content", - "fieldtype": "Text Editor", - "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": "Content", - "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, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "comments", - "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": "comments", - "length": 0, - "no_copy": 0, - "options": "Post Comment", - "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, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "liked_by", - "fieldtype": "Small Text", - "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": "Liked By", - "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, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "is_pinned", - "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 Pinned", - "length": 0, - "no_copy": 0, - "permlevel": 2, - "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, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "is_globally_pinned", - "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 Globally Pinned", - "length": 0, - "no_copy": 0, - "permlevel": 1, - "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, - "translatable": 0, - "unique": 0 - } - ], - "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": "2019-03-11 16:32:20.638805", - "modified_by": "Administrator", - "module": "Social", - "name": "Post", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 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, - "write": 1 - }, - { - "amend": 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": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 1, - "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, - "write": 1 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 1, - "import": 0, - "permlevel": 2, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 2, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 1, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 1 -} \ No newline at end of file diff --git a/frappe/social/doctype/post/post.py b/frappe/social/doctype/post/post.py deleted file mode 100644 index a00f647b55..0000000000 --- a/frappe/social/doctype/post/post.py +++ /dev/null @@ -1,104 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# License: MIT. See LICENSE - -import frappe -import requests -from bs4 import BeautifulSoup -from frappe.model.document import Document - -class Post(Document): - def on_update(self): - if (self.is_globally_pinned): - frappe.publish_realtime('global_pin', after_commit=True) - - def after_insert(self): - frappe.publish_realtime('new_post', self.owner, after_commit=True) - -@frappe.whitelist() -def toggle_like(post_name, user=None): - liked_by = frappe.db.get_value('Post', post_name, 'liked_by') - liked_by = liked_by.split('\n') if liked_by else [] - user = user or frappe.session.user - - if user in liked_by: - liked_by.remove(user) - else: - liked_by.append(user) - - liked_by = '\n'.join(liked_by) - frappe.db.set_value('Post', post_name, 'liked_by', liked_by) - frappe.publish_realtime('update_liked_by' + post_name, liked_by, after_commit=True) - -@frappe.whitelist() -def frequently_visited_links(): - return frappe.get_all('Route History', fields=['route', 'count(name) as count'], filters={ - 'user': frappe.session.user - }, group_by="route", order_by="count desc", limit=5) - -@frappe.whitelist() -def get_link_info(url): - cached_link_info = frappe.cache().hget("link_info", url) - if cached_link_info: - return cached_link_info - - try: - page = requests.get(url) - except (requests.exceptions.MissingSchema, requests.exceptions.ConnectionError): - frappe.cache().hset("link_info", url, {}) - return {} - - soup = BeautifulSoup(page.text) - - meta_obj = {} - for meta in soup.findAll('meta'): - meta_name = meta.get('property') or meta.get('name', '').lower() - if meta_name: - meta_obj[meta_name] = meta.get('content') - - frappe.cache().hset("link_info", url, meta_obj) - - return meta_obj - -@frappe.whitelist() -def delete_post(post_name): - post = frappe.get_doc('Post', post_name) - post.delete() - frappe.publish_realtime('delete_post' + post_name, after_commit=True) - -def get_unseen_post_count(): - post_count = frappe.db.count('Post') - view_post_count = get_viewed_posts(True) - - return post_count - view_post_count - -@frappe.whitelist() -def get_posts(filters=None, limit_start=0): - filters = frappe.utils.get_safe_filters(filters) - posts = frappe.get_list('Post', - fields= ['name', 'content', 'owner', 'creation', 'liked_by', 'is_pinned', 'is_globally_pinned'], - filters=filters, - limit_start=limit_start, - limit=20, - order_by= 'is_globally_pinned desc, creation desc') - viewed_posts = get_viewed_posts() - for post in posts: - post['seen'] = post.name in viewed_posts - return posts - -def get_viewed_posts(only_count=False): - view_logs = frappe.db.get_all('View Log', filters={ - 'reference_doctype': 'Post', - 'viewed_by': frappe.session.user - }, fields=['reference_name']) - - return len(view_logs) if only_count else [log.reference_name for log in view_logs] - -@frappe.whitelist() -def set_seen(post_name): - frappe.get_doc({ - 'doctype': 'View Log', - 'reference_doctype': 'Post', - 'reference_name': post_name, - 'viewed_by': frappe.session.user - }).insert(ignore_permissions=True) diff --git a/frappe/social/doctype/post_comment/post_comment.js b/frappe/social/doctype/post_comment/post_comment.js deleted file mode 100644 index f03e12d977..0000000000 --- a/frappe/social/doctype/post_comment/post_comment.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('Post Comment', { - refresh: function() { - - } -}); diff --git a/frappe/social/doctype/post_comment/post_comment.json b/frappe/social/doctype/post_comment/post_comment.json deleted file mode 100644 index 882bf1fb5f..0000000000 --- a/frappe/social/doctype/post_comment/post_comment.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-10-14 10:16:22.852930", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "content", - "fieldtype": "Text", - "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": "Content", - "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, - "translatable": 0, - "unique": 0 - } - ], - "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": "2018-11-01 10:16:03.433920", - "modified_by": "Administrator", - "module": "Social", - "name": "Post Comment", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 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, - "write": 1 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 1, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 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, - "track_views": 0 -} \ No newline at end of file diff --git a/frappe/social/doctype/post_comment/post_comment.py b/frappe/social/doctype/post_comment/post_comment.py deleted file mode 100644 index a3394f3ac9..0000000000 --- a/frappe/social/doctype/post_comment/post_comment.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# License: MIT. See LICENSE - -import frappe -from frappe.model.document import Document -from frappe.core.doctype.user.user import extract_mentions - -class PostComment(Document): - def after_insert(self): - mentions = extract_mentions(self.content) - for mention in mentions: - if mention == self.owner: continue - frappe.publish_realtime('mention', """{} mentioned you! -
Check Social""" - .format(frappe.utils.get_fullname(self.owner)), - user=mention, - after_commit=True) - frappe.publish_realtime('new_post_comment' + self.parent, self, after_commit=True) diff --git a/frappe/templates/base.html b/frappe/templates/base.html index bc1f802cf7..8d892b5de6 100644 --- a/frappe/templates/base.html +++ b/frappe/templates/base.html @@ -105,8 +105,6 @@ // for backward compatibility of some libs frappe.sys_defaults = frappe.boot.sysdefaults; - - {{ include_script('frappe-web.bundle.js') }} {% endblock %} diff --git a/frappe/templates/emails/standard.html b/frappe/templates/emails/standard.html index 4a47c9cf90..2a2093e1e9 100644 --- a/frappe/templates/emails/standard.html +++ b/frappe/templates/emails/standard.html @@ -37,7 +37,6 @@
Id + Id Time State Info - Progress + Progress / Wait Event

{{ content }}

-

{{ signature }}

diff --git a/frappe/templates/includes/comments/comments.py b/frappe/templates/includes/comments/comments.py index 99afb580d8..8d485423bf 100644 --- a/frappe/templates/includes/comments/comments.py +++ b/frappe/templates/includes/comments/comments.py @@ -6,6 +6,7 @@ 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.utils.html_utils import clean_html from frappe import _ @@ -29,7 +30,7 @@ def add_comment(comment, comment_email, comment_by, reference_doctype, reference return False comment = doc.add_comment( - text=comment, + text=clean_html(comment), comment_email=comment_email, comment_by=comment_by) diff --git a/frappe/templates/includes/feedback/feedback.html b/frappe/templates/includes/feedback/feedback.html index 7925d4dccd..77bb20aceb 100644 --- a/frappe/templates/includes/feedback/feedback.html +++ b/frappe/templates/includes/feedback/feedback.html @@ -38,6 +38,6 @@ like } }); - } + } }); \ No newline at end of file diff --git a/frappe/templates/includes/footer/footer_info.html b/frappe/templates/includes/footer/footer_info.html index a186247c9a..ba04949ec0 100644 --- a/frappe/templates/includes/footer/footer_info.html +++ b/frappe/templates/includes/footer/footer_info.html @@ -12,7 +12,11 @@ {# powered #} diff --git a/frappe/templates/includes/navbar/navbar_items.html b/frappe/templates/includes/navbar/navbar_items.html index 34cc24fe1a..dbc086f1f4 100644 --- a/frappe/templates/includes/navbar/navbar_items.html +++ b/frappe/templates/includes/navbar/navbar_items.html @@ -70,7 +70,7 @@ {% endif %} {% if show_sidebar and sidebar_items %} -
+

{% for item in sidebar_items -%}