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}
+
+ ${__('Show Traceback')}
+
+ `;
+ }
+ 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(`
+
+
+ ${__('Row Number')}
+ ${__('Status')}
+ ${__('Message')}
+
+ ${rows}
+
+ `);
+ }
+ });
+ },
+
+ 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}
-
- ${__('Show Traceback')}
-
- `;
+ 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(`
-
-
- ${__('Row Number')}
- ${__('Status')}
- ${__('Message')}
-
- ${rows}
-
- `);
+ }
+ });
},
});
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:\nfield:[fieldname] - By Fieldnaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat: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:\nfield:[fieldname] - By Fieldautoincrement - Uses Databases' Auto Increment featurenaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat: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 \nClient 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}
- Id
+ Id
Time
State
Info
- Progress
+ Progress / Wait Event
${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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
+
@@ -133,6 +314,7 @@
fill="#12283A" mask="url(#a)">
+
-
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 `
- ${__(value)}
+ ${__(label || value)}
${frappe.utils.icon('close')}
`;
}
+ 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 `
- ${__(value)}
+ ${__(pill_name)}
${frappe.utils.icon('close')}
`;
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 = $(`
-
+
-
${message}
+
+ ${message}
+
`);
- this.timeline_wrapper.append(this.document_email_link_wrapper);
+ this.timeline_actions_wrapper.append(this.document_email_link_wrapper);
this.document_email_link_wrapper
.find('.document-email-link')
@@ -452,7 +454,10 @@ class FormTimeline extends BaseTimeline {
let edit_box = this.make_editable(edit_wrapper);
let content_wrapper = comment_wrapper.find('.content');
let more_actions_wrapper = comment_wrapper.find('.more-actions');
- if (frappe.model.can_delete("Comment")) {
+ if (frappe.model.can_delete("Comment") && (
+ frappe.session.user == doc.owner ||
+ frappe.user.has_role("System Manager")
+ )) {
const delete_option = $(`
diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js
index 1459b38df6..7ec6677c7f 100644
--- a/frappe/public/js/frappe/form/form.js
+++ b/frappe/public/js/frappe/form/form.js
@@ -246,10 +246,12 @@ frappe.ui.form.Form = class FrappeForm {
var me = this;
// on main doc
- frappe.model.on(me.doctype, "*", function(fieldname, value, doc) {
+ frappe.model.on(me.doctype, "*", function(fieldname, value, doc, skip_dirty_trigger=false) {
// set input
- if(doc.name===me.docname) {
- me.dirty();
+ if (cstr(doc.name) === me.docname) {
+ if (!skip_dirty_trigger) {
+ me.dirty();
+ }
let field = me.fields_dict[fieldname];
field && field.refresh(fieldname);
@@ -334,7 +336,7 @@ frappe.ui.form.Form = class FrappeForm {
this.doc = frappe.get_doc(this.doctype, this.docname);
// check permissions
- if(!this.has_read_permission()) {
+ if (!this.has_read_permission()) {
frappe.show_not_permitted(__(this.doctype) + " " + __(this.docname));
return;
}
@@ -860,36 +862,32 @@ frappe.ui.form.Form = class FrappeForm {
}
_cancel(btn, callback, on_error, skip_confirm) {
+ const me = this;
const cancel_doc = () => {
frappe.validated = true;
- this.script_manager.trigger("before_cancel").then(() => {
+ me.script_manager.trigger("before_cancel").then(() => {
if (!frappe.validated) {
- return this.handle_save_fail(btn, on_error);
+ return me.handle_save_fail(btn, on_error);
}
- const original_name = this.docname;
- const after_cancel = (r) => {
+ var after_cancel = function(r) {
if (r.exc) {
- this.handle_save_fail(btn, on_error);
+ me.handle_save_fail(btn, on_error);
} else {
frappe.utils.play_sound("cancel");
+ me.refresh();
callback && callback();
- this.script_manager.trigger("after_cancel");
- frappe.run_serially([
- () => this.rename_notify(this.doctype, original_name, r.docs[0].name),
- () => frappe.router.clear_re_route(this.doctype, original_name),
- () => this.refresh(),
- ]);
+ me.script_manager.trigger("after_cancel");
}
};
- frappe.ui.form.save(this, "cancel", after_cancel, btn);
+ frappe.ui.form.save(me, "cancel", after_cancel, btn);
});
}
if (skip_confirm) {
cancel_doc();
} else {
- frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, this.handle_save_fail(btn, on_error));
+ frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, me.handle_save_fail(btn, on_error));
}
};
@@ -911,7 +909,7 @@ frappe.ui.form.Form = class FrappeForm {
'docname': this.doc.name
}).then(is_amended => {
if (is_amended) {
- frappe.throw(__('This document is already amended, you cannot amend it again'));
+ frappe.throw(__('This document is already amended, you cannot ammend it again'));
}
this.validate_form_action("Amend");
var me = this;
@@ -943,7 +941,10 @@ frappe.ui.form.Form = class FrappeForm {
// re-enable buttons
resolve();
}
- frappe.throw (__("No permission to '{0}' {1}", [__(action), __(this.doc.doctype)]));
+
+ frappe.throw(
+ __("No permission to '{0}' {1}", [__(action), __(this.doc.doctype)], "{0} = verb, {1} = object")
+ );
}
}
@@ -954,10 +955,12 @@ frappe.ui.form.Form = class FrappeForm {
this.toolbar.set_primary_action();
}
- disable_save() {
+ disable_save(set_dirty=false) {
// IMPORTANT: this function should be called in refresh event
this.save_disabled = true;
this.toolbar.current_status = null;
+ // field changes should make form dirty
+ this.set_dirty = set_dirty;
this.page.clear_primary_action();
}
@@ -1103,13 +1106,13 @@ frappe.ui.form.Form = class FrappeForm {
let list_view = frappe.get_list_view(this.doctype);
if (list_view) {
filters = list_view.get_filters_for_args();
- sort_field = list_view.sort_field;
+ sort_field = list_view.sort_by;
sort_order = list_view.sort_order;
} else {
let list_settings = frappe.get_user_settings(this.doctype)['List'];
if (list_settings) {
filters = list_settings.filters;
- sort_field = list_settings.sort_field;
+ sort_field = list_settings.sort_by;
sort_order = list_settings.sort_order;
}
}
@@ -1146,8 +1149,7 @@ frappe.ui.form.Form = class FrappeForm {
subject: __(this.meta.name) + ': ' + this.docname,
recipients: this.doc.email || this.doc.email_id || this.doc.contact_email,
attach_document_print: true,
- message: message,
- real_name: this.doc.real_name || this.doc.contact_display || this.doc.contact_name
+ message: message
});
}
@@ -1365,6 +1367,7 @@ frappe.ui.form.Form = class FrappeForm {
set_df_property(fieldname, property, value, docname, table_field, table_row_name=null) {
let df;
+
if (!docname || !table_field) {
df = this.get_docfield(fieldname);
} else {
@@ -1374,8 +1377,10 @@ frappe.ui.form.Form = class FrappeForm {
df = frappe.meta.get_docfield(filtered_fields[0].parent, table_field, table_row_name);
}
}
+
if (df && df[property] != value) {
df[property] = value;
+
if (table_field && table_row_name) {
if (this.fields_dict[fieldname].grid.grid_rows_by_docname[table_row_name]) {
this.fields_dict[fieldname].grid.grid_rows_by_docname[table_row_name].refresh_field(fieldname);
@@ -1446,7 +1451,7 @@ frappe.ui.form.Form = class FrappeForm {
return doc;
}
- set_value(field, value, if_missing) {
+ set_value(field, value, if_missing, skip_dirty_trigger=false) {
var me = this;
var _set = function(f, v) {
var fieldobj = me.fields_dict[f];
@@ -1466,7 +1471,7 @@ frappe.ui.form.Form = class FrappeForm {
me.refresh_field(f);
return Promise.resolve();
} else {
- return frappe.model.set_value(me.doctype, me.doc.name, f, v);
+ return frappe.model.set_value(me.doctype, me.doc.name, f, v, me.fieldtype, skip_dirty_trigger);
}
}
} else {
@@ -1510,7 +1515,9 @@ frappe.ui.form.Form = class FrappeForm {
// update child doc
opts.child = locals[opts.child.doctype][opts.child.name];
- var std_field_list = ["doctype"].concat(frappe.model.std_fields_list);
+ var std_field_list = ["doctype"]
+ .concat(frappe.model.std_fields_list)
+ .concat(frappe.model.child_table_field_list);
for (var key in r.message) {
if (std_field_list.indexOf(key)===-1) {
opts.child[key] = r.message[key];
@@ -1661,23 +1668,17 @@ frappe.ui.form.Form = class FrappeForm {
// make new doctype from the current form
// will handover to `make_methods` if defined
// or will create and match link fields
- var me = this;
+ let me = this;
if(this.make_methods && this.make_methods[doctype]) {
return this.make_methods[doctype](this);
} else if(this.custom_make_buttons && this.custom_make_buttons[doctype]) {
this.custom_buttons[__(this.custom_make_buttons[doctype])].trigger('click');
} else {
frappe.model.with_doctype(doctype, function() {
- var new_doc = frappe.model.get_new_doc(doctype);
+ let new_doc = frappe.model.get_new_doc(doctype, null, null, true);
// set link fields (if found)
- frappe.get_meta(doctype).fields.forEach(function(df) {
- if(df.fieldtype==='Link' && df.options===me.doctype) {
- new_doc[df.fieldname] = me.doc.name;
- } else if (['Link', 'Dynamic Link'].includes(df.fieldtype) && me.doc[df.fieldname]) {
- new_doc[df.fieldname] = me.doc[df.fieldname];
- }
- });
+ me.set_link_field(doctype, new_doc);
frappe.ui.form.make_quick_entry(doctype, null, null, new_doc);
// frappe.set_route('Form', doctype, new_doc.name);
@@ -1685,6 +1686,20 @@ frappe.ui.form.Form = class FrappeForm {
}
}
+ set_link_field(doctype, new_doc) {
+ let me = this;
+ frappe.get_meta(doctype).fields.forEach(function(df) {
+ if (df.fieldtype === 'Link' && df.options === me.doctype) {
+ new_doc[df.fieldname] = me.doc.name;
+ } else if (['Link', 'Dynamic Link'].includes(df.fieldtype) && me.doc[df.fieldname]) {
+ new_doc[df.fieldname] = me.doc[df.fieldname];
+ } else if (df.fieldtype === 'Table' && df.options && df.reqd) {
+ let row = new_doc[df.fieldname][0];
+ me.set_link_field(df.options, row);
+ }
+ });
+ }
+
update_in_all_rows(table_fieldname, fieldname, value) {
// update the child value in all tables where it is missing
if(!value) return;
diff --git a/frappe/public/js/frappe/form/form_tour.js b/frappe/public/js/frappe/form/form_tour.js
index 7fefb59ac6..2bb888e17c 100644
--- a/frappe/public/js/frappe/form/form_tour.js
+++ b/frappe/public/js/frappe/form/form_tour.js
@@ -151,7 +151,7 @@ frappe.ui.form.FormTour = class FormTour {
const curr_step = step_info;
const next_step = this.tour.steps[curr_step.idx];
- const is_next_field_in_curr_table = next_step.parent_field == curr_step.field;
+ const is_next_field_in_curr_table = next_step.parent_fieldname == curr_step.fieldname;
if (!is_next_field_in_curr_table) return;
diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js
index fd3fcb1bc7..2b0f996661 100644
--- a/frappe/public/js/frappe/form/formatters.js
+++ b/frappe/public/js/frappe/form/formatters.js
@@ -21,6 +21,9 @@ frappe.form.formatters = {
}
return value==null ? "" : value;
},
+ Autocomplete: function(value) {
+ return __(frappe.form.formatters["Data"](value));
+ },
Select: function(value) {
return __(frappe.form.formatters["Data"](value));
},
@@ -110,12 +113,14 @@ frappe.form.formatters = {
Link: function(value, docfield, options, doc) {
var doctype = docfield._options || docfield.options;
var original_value = value;
+ let link_title = frappe.utils.get_link_title(doctype, value);
+
if(value && value.match && value.match(/^['"].*['"]$/)) {
value.replace(/^.(.*).$/, "$1");
}
if(options && (options.for_print || options.only_value)) {
- return value;
+ return link_title || value;
}
if(frappe.form.link_formatters[doctype]) {
@@ -139,13 +144,14 @@ frappe.form.formatters = {
return `
- ${__(options && options.label || value)} `;
+ data-name="${original_value}"
+ data-value="${original_value}">
+ ${__(options && options.label || link_title || value)}`;
} else {
- return value;
+ return link_title || value;
}
} else {
- return value;
+ return link_title || value;
}
},
Date: function(value) {
diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js
index 8b615f3c59..7bbe4b123a 100644
--- a/frappe/public/js/frappe/form/grid.js
+++ b/frappe/public/js/frappe/form/grid.js
@@ -35,7 +35,7 @@ export default class Grid {
&& this.frm.meta.__form_grid_templates[this.df.fieldname]) {
this.template = this.frm.meta.__form_grid_templates[this.df.fieldname];
}
-
+ this.filter = {};
this.is_grid = true;
this.debounced_refresh = this.refresh.bind(this);
this.debounced_refresh = frappe.utils.debounce(this.debounced_refresh, 100);
@@ -274,6 +274,8 @@ export default class Grid {
}
make_head() {
+ if (this.prevent_build) return;
+
// labels
if (this.header_row) {
$(this.parent).find(".grid-heading-row .grid-row").remove();
@@ -286,12 +288,42 @@ export default class Grid {
grid: this,
configure_columns: true
});
+
+ this.header_search = new GridRow({
+ parent: $(this.parent).find(".grid-heading-row"),
+ parent_df: this.df,
+ docfields: this.docfields,
+ frm: this.frm,
+ grid: this,
+ show_search: true
+ });
+
+ Object.keys(this.filter).length !== 0 &&
+ this.update_search_columns();
}
- refresh(force) {
+ update_search_columns() {
+ for (const field in this.filter) {
+ if (this.filter[field] && !this.header_search.search_columns[field]) {
+ delete this.filter[field];
+ this.data = this.get_data(Object.keys(this.filter).length !== 0);
+ break;
+ }
+
+ if (this.filter[field] && this.filter[field].value) {
+ let $input = this.header_search.row_index.find('input');
+ if (field && field !== 'row-index') {
+ $input = this.header_search.search_columns[field].find('input');
+ }
+ $input.val(this.filter[field].value);
+ }
+ }
+ }
+
+ refresh() {
if (this.frm && this.frm.setting_dependency) return;
- this.data = this.get_data();
+ this.data = this.get_data(Object.keys(this.filter).length !== 0);
!this.wrapper && this.make();
let $rows = $(this.parent).find('.rows');
@@ -453,7 +485,7 @@ export default class Grid {
}
make_sortable($rows) {
- new Sortable($rows.get(0), {
+ this.grid_sortable = new Sortable($rows.get(0), {
group: { name: this.df.fieldname },
handle: '.sortable-handle',
draggable: '.grid-row',
@@ -484,14 +516,78 @@ export default class Grid {
$(this.frm.wrapper).trigger("grid-make-sortable", [this.frm]);
}
- get_data() {
- var data = this.frm ?
- this.frm.doc[this.df.fieldname] || []
- : this.df.data || this.get_modal_data();
- // data.sort(function(a, b) { return a.idx - b.idx});
+ get_data(filter_field) {
+ let data = [];
+ if (filter_field) {
+ data = this.get_filtered_data();
+ } else {
+ data = this.frm ?
+ this.frm.doc[this.df.fieldname] || []
+ : this.df.data || this.get_modal_data();
+ }
return data;
}
+ get_filtered_data() {
+ if (!this.frm) return;
+
+ let all_data = this.frm.doc[this.df.fieldname];
+
+ for (const field in this.filter) {
+ all_data = all_data.filter(data => {
+ let {df, value} = this.filter[field];
+ return this.get_data_based_on_fieldtype(df, data, value.toLowerCase());
+ });
+ }
+
+ return all_data;
+ }
+
+ get_data_based_on_fieldtype(df, data, value) {
+ let fieldname = df.fieldname;
+ let fieldtype = df.fieldtype;
+ let fieldvalue = data[fieldname];
+
+ if (fieldtype === "Check") {
+ value = frappe.utils.string_to_boolean(value);
+ return (Boolean(fieldvalue) === value) && data;
+ } else if (fieldtype === "Sr No" && data.idx.toString().includes(value)) {
+ return data;
+ } else if (fieldtype === "Duration" && fieldvalue) {
+ let formatted_duration = frappe.utils.get_formatted_duration(fieldvalue);
+
+ if (formatted_duration.includes(value)) {
+ return data;
+ }
+ } else if (fieldtype === "Barcode" && fieldvalue) {
+ let barcode = fieldvalue.startsWith(' -1) {
+ return data;
+ }
+ } else if (fieldvalue && fieldvalue.toLowerCase().includes(value)) {
+ return data;
+ }
+ }
+
get_modal_data() {
return this.df.get_data ? this.df.get_data().filter(data => {
if (!this.deleted_docs || !in_list(this.deleted_docs, data.name)) {
@@ -501,11 +597,10 @@ export default class Grid {
}
set_column_disp(fieldname, show) {
- if ($.isArray(fieldname)) {
- for (var i = 0, l = fieldname.length; i < l; i++) {
- var fname = fieldname[i];
- this.get_docfield(fname).hidden = show ? 0 : 1;
- this.set_editable_grid_column_disp(fname, show);
+ if (Array.isArray(fieldname)) {
+ for (let field of fieldname) {
+ this.update_docfield_property(field, "hidden", show ? 0 : 1);
+ this.set_editable_grid_column_disp(field, show);
}
} else {
this.get_docfield(fieldname).hidden = show ? 0 : 1;
@@ -555,17 +650,17 @@ export default class Grid {
}
toggle_reqd(fieldname, reqd) {
- this.get_docfield(fieldname).reqd = reqd;
+ this.update_docfield_property(fieldname, "reqd", reqd);
this.debounced_refresh();
}
toggle_enable(fieldname, enable) {
- this.get_docfield(fieldname).read_only = enable ? 0 : 1;
+ this.update_docfield_property(fieldname, "read_only", enable ? 0 : 1);
this.debounced_refresh();
}
toggle_display(fieldname, show) {
- this.get_docfield(fieldname).hidden = show ? 0 : 1;
+ this.update_docfield_property(fieldname, "hidden", show ? 0 : 1);
this.debounced_refresh();
}
@@ -747,7 +842,7 @@ export default class Grid {
var df = this.visible_columns[i][0];
var colsize = this.visible_columns[i][1];
if (colsize > 1 && colsize < 11
- && !in_list(frappe.model.std_fields_list, df.fieldname)) {
+ && frappe.model.is_non_std_field(df.fieldname)) {
if (passes < 3 && ["Int", "Currency", "Float", "Check", "Percent"].indexOf(df.fieldtype) !== -1) {
// don't increase col size of these fields in first 3 passes
@@ -776,18 +871,19 @@ export default class Grid {
}
setup_user_defined_columns() {
- if (this.frm) {
- let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView');
- if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) {
- this.user_defined_columns = user_settings[this.doctype].map(row => {
- let column = frappe.meta.get_docfield(this.doctype, row.fieldname);
- if (column) {
- column.in_list_view = 1;
- column.columns = row.columns;
- return column;
- }
- });
- }
+ if (!this.frm) return;
+
+ let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView');
+ if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) {
+ this.user_defined_columns = user_settings[this.doctype].map(row => {
+ let column = frappe.meta.get_docfield(this.doctype, row.fieldname);
+
+ if (column) {
+ column.in_list_view = 1;
+ column.columns = row.columns;
+ return column;
+ }
+ });
}
}
diff --git a/frappe/public/js/frappe/form/grid_pagination.js b/frappe/public/js/frappe/form/grid_pagination.js
index 76a5f7b50b..2be708a87b 100644
--- a/frappe/public/js/frappe/form/grid_pagination.js
+++ b/frappe/public/js/frappe/form/grid_pagination.js
@@ -66,7 +66,7 @@ export default class GridPagination {
}
// only allow numbers from 0-9 and up, down, left, right arrow keys
- if (charCode > 31 && (charCode < 48 || charCode > 57) &&
+ if (charCode > 31 && (charCode < 48 || charCode > 57) &&
![37, 38, 39, 40].includes(charCode)) {
return false;
}
diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js
index a40f428969..c12ac23319 100644
--- a/frappe/public/js/frappe/form/grid_row.js
+++ b/frappe/public/js/frappe/form/grid_row.js
@@ -5,14 +5,10 @@ export default class GridRow {
this.on_grid_fields_dict = {};
this.on_grid_fields = [];
$.extend(this, opts);
- if (this.doc && this.parent_df.options) {
- frappe.meta.make_docfield_copy_for(this.parent_df.options, this.doc.name, this.docfields);
- const docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name);
- this.docfields = docfields.length ? docfields : opts.docfields;
- }
+ this.set_docfields();
this.columns = {};
this.columns_list = [];
- this.row_check_html = ' ';
+ this.row_check_html = ' ';
this.make();
}
make() {
@@ -41,6 +37,22 @@ export default class GridRow {
this.set_data();
}
}
+
+ set_docfields(update=false) {
+ if (this.doc && this.parent_df.options) {
+ frappe.meta.make_docfield_copy_for(this.parent_df.options, this.doc.name, this.docfields);
+ const docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name);
+ if (update) {
+ // to maintain references
+ this.docfields.forEach(df => {
+ Object.assign(df, docfields.find(d => d.fieldname === df.fieldname));
+ });
+ } else {
+ this.docfields = docfields;
+ }
+ }
+ }
+
set_data() {
this.wrapper.data({
"doc": this.doc
@@ -148,6 +160,11 @@ export default class GridRow {
}, __('Move To'), 'Update');
}
refresh() {
+ // update docfields for new record
+ if (this.frm && this.doc && this.doc.__islocal) {
+ this.set_docfields(true);
+ }
+
if(this.frm && this.doc) {
this.doc = locals[this.doc.doctype][this.doc.name];
}
@@ -166,21 +183,20 @@ export default class GridRow {
render_template() {
this.set_row_index();
- if(this.row_display) {
+ if (this.row_display) {
this.row_display.remove();
}
// row index
- if(this.doc) {
- if(!this.row_index) {
- this.row_index = $(''+this.row_check_html+'
').appendTo(this.row);
- }
+ if (!this.row_index) {
+ this.row_index = $(`${this.row_check_html}
`).appendTo(this.row);
+ }
+
+ if (this.doc) {
this.row_index.find('span').html(this.doc.idx);
}
- this.row_display = $(''+
- +'
').appendTo(this.row)
+ this.row_display = $('
').appendTo(this.row)
.html(frappe.render(this.grid.template, {
doc: this.doc ? frappe.get_format_helper(this.doc) : null,
frm: this.frm,
@@ -188,23 +204,65 @@ export default class GridRow {
}));
}
render_row(refresh) {
- var me = this;
+ if (this.show_search && !this.show_search_row()) return;
+
+ let me = this;
this.set_row_index();
// index (1, 2, 3 etc)
- if(!this.row_index) {
+ if (!this.row_index && !this.show_search) {
// REDESIGN-TODO: Make translation contextual, this No is Number
var txt = (this.doc ? this.doc.idx : __("No."));
- this.row_index = $(
- `
+
+ this.row_check = $(
+ `
${this.row_check_html}
- ${txt}
`)
+
`)
+ .appendTo(this.row);
+
+ this.row_index = $(
+ `
+ ${txt}
+
`)
.appendTo(this.row)
.on('click', function(e) {
if(!$(e.target).hasClass('grid-row-check')) {
me.toggle_view();
}
});
+ } else if (this.show_search) {
+ this.row_check = $(
+ `
`
+ ).appendTo(this.row);
+
+ this.row_index = $(
+ `
+
+
`
+ ).appendTo(this.row);
+
+ this.row_index.find('input').on('keyup', frappe.utils.debounce((e) => {
+ let df = {
+ fieldtype: "Sr No"
+ };
+
+ this.grid.filter['row-index'] = {
+ df: df,
+ value: e.target.value
+ };
+
+ if (e.target.value == "") {
+ delete this.grid.filter['row-index'];
+ }
+
+ this.grid.grid_sortable
+ .option('disabled', Object.keys(this.grid.filter).length !== 0);
+
+ this.grid.prevent_build = true;
+ me.grid.refresh();
+ this.grid.prevent_build = false;
+ }, 500));
+ frappe.utils.only_allow_num_decimal(this.row_index.find('input'));
} else {
this.row_index.find('span').html(txt);
}
@@ -323,7 +381,7 @@ export default class GridRow {
-
+
+ ${__('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 @@
-
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 -%}
diff --git a/frappe/templates/includes/website_theme/navbar.css b/frappe/templates/includes/website_theme/navbar.css
index fe3bb6cfe3..39117b6b39 100644
--- a/frappe/templates/includes/website_theme/navbar.css
+++ b/frappe/templates/includes/website_theme/navbar.css
@@ -95,8 +95,6 @@
min-width: 200px;
padding: 0px;
font-size: 85%;
-
- // only rounded bottoms
border-radius: 0px 0px 4px 4px;
}
diff --git a/frappe/templates/pages/integrations/payment-success.html b/frappe/templates/pages/integrations/payment-success.html
index 45bc303a75..76c5db93bf 100644
--- a/frappe/templates/pages/integrations/payment-success.html
+++ b/frappe/templates/pages/integrations/payment-success.html
@@ -11,7 +11,7 @@
{{ payment_message or _("Your payment was successfully accepted") }}
{% if not payment_message %}
-
{{ _("Continue") }}
diff --git a/frappe/templates/print_format/print_format.css b/frappe/templates/print_format/print_format.css
index 480cd19439..baaf5b087d 100644
--- a/frappe/templates/print_format/print_format.css
+++ b/frappe/templates/print_format/print_format.css
@@ -57,6 +57,10 @@ body {
padding-left: {{ print_format.margin_left | int }}mm;
padding-bottom: {{ print_format.margin_bottom | int }}mm;
}
+
+ .child-table {
+ overflow-x: auto;
+ }
}
.section:not(:first-child) {
diff --git a/frappe/templates/print_formats/standard.html b/frappe/templates/print_formats/standard.html
index 796663a248..f5ce1d3f5a 100644
--- a/frappe/templates/print_formats/standard.html
+++ b/frappe/templates/print_formats/standard.html
@@ -3,7 +3,7 @@
{% for page in layout %}
{% if print_settings.repeat_header_footer %}
diff --git a/frappe/templates/print_formats/standard_macros.html b/frappe/templates/print_formats/standard_macros.html
index 580a41f959..a4129501b0 100644
--- a/frappe/templates/print_formats/standard_macros.html
+++ b/frappe/templates/print_formats/standard_macros.html
@@ -186,12 +186,12 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
{%- endif -%}
{% endmacro %}
-{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None) -%}
+{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}
{% if letter_head and not no_letterhead %}
{{ letter_head }}
{% endif %}
- {% if doc.print_heading_template %}
- {{ frappe.render_template(doc.print_heading_template, {"doc":doc}) }}
+ {% if print_heading_template %}
+ {{ frappe.render_template(print_heading_template, {"doc":doc}) }}
{% else %}
diff --git a/frappe/test_runner.py b/frappe/test_runner.py
index 1839f15ae8..6c1aae8907 100644
--- a/frappe/test_runner.py
+++ b/frappe/test_runner.py
@@ -30,7 +30,7 @@ def xmlrunner_wrapper(output):
def main(app=None, module=None, doctype=None, verbose=False, tests=(),
force=False, profile=False, junit_xml_output=None, ui_tests=False,
- doctype_list_path=None, skip_test_records=False, failfast=False):
+ doctype_list_path=None, skip_test_records=False, failfast=False, case=None):
global unittest_runner
if doctype_list_path:
@@ -55,9 +55,6 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(),
if not frappe.db:
frappe.connect()
- # if not frappe.conf.get("db_name").startswith("test_"):
- # raise Exception, 'db_name must start with "test_"'
-
# workaround! since there is no separate test db
frappe.clear_cache()
scheduler_disabled_by_user = frappe.utils.scheduler.is_scheduler_disabled()
@@ -76,7 +73,7 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(),
if doctype:
ret = run_tests_for_doctype(doctype, verbose, tests, force, profile, failfast=failfast, junit_xml_output=junit_xml_output)
elif module:
- ret = run_tests_for_module(module, verbose, tests, profile, failfast=failfast, junit_xml_output=junit_xml_output)
+ ret = run_tests_for_module(module, verbose, tests, profile, failfast=failfast, junit_xml_output=junit_xml_output, case=case)
else:
ret = run_all_tests(app, verbose, profile, ui_tests, failfast=failfast, junit_xml_output=junit_xml_output)
@@ -182,16 +179,16 @@ def run_tests_for_doctype(doctypes, verbose=False, tests=(), force=False, profil
return _run_unittest(modules, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output)
-def run_tests_for_module(module, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False):
+def run_tests_for_module(module, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False, case=None):
module = importlib.import_module(module)
if hasattr(module, "test_dependencies"):
for doctype in module.test_dependencies:
make_test_records(doctype, verbose=verbose)
frappe.db.commit()
- return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output)
+ return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output, case=case)
-def _run_unittest(modules, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False):
+def _run_unittest(modules, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False, case=None):
frappe.db.begin()
test_suite = unittest.TestSuite()
@@ -200,7 +197,10 @@ def _run_unittest(modules, verbose=False, tests=(), profile=False, failfast=Fals
modules = [modules]
for module in modules:
- module_test_cases = unittest.TestLoader().loadTestsFromModule(module)
+ if case:
+ module_test_cases = unittest.TestLoader().loadTestsFromTestCase(getattr(module, case))
+ else:
+ module_test_cases = unittest.TestLoader().loadTestsFromModule(module)
if tests:
for each in module_test_cases:
for test_case in each.__dict__["_tests"]:
@@ -282,7 +282,7 @@ def make_test_records(doctype, verbose=0, force=False):
if options == "[Select]":
continue
- if not options in frappe.local.test_objects:
+ if options not in frappe.local.test_objects:
frappe.local.test_objects[options] = []
make_test_records(options, verbose, force)
make_test_records_for_doctype(options, verbose, force)
@@ -337,7 +337,7 @@ def make_test_records_for_doctype(doctype, verbose=0, force=False):
elif hasattr(test_module, "test_records"):
if doctype in frappe.local.test_objects:
frappe.local.test_objects[doctype] += make_test_objects(doctype, test_module.test_records, verbose, force)
- else:
+ else:
frappe.local.test_objects[doctype] = make_test_objects(doctype, test_module.test_records, verbose, force)
else:
@@ -389,7 +389,7 @@ def make_test_objects(doctype, test_records=None, verbose=None, reset=False):
try:
d.run_method("before_test_insert")
- d.insert()
+ d.insert(ignore_if_duplicate=True)
if docstatus == 1:
d.submit()
@@ -422,7 +422,7 @@ def add_to_test_record_log(doctype):
'''Add `doctype` to site/.test_log
`.test_log` is a cache of all doctypes for which test records are created'''
test_record_log = get_test_record_log()
- if not doctype in test_record_log:
+ if doctype not in test_record_log:
frappe.flags.test_record_log.append(doctype)
with open(frappe.get_site_path('.test_log'), 'w') as f:
f.write('\n'.join(filter(None, frappe.flags.test_record_log)))
diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py
index 32a5ebbd72..d7d0de6a0d 100644
--- a/frappe/tests/test_api.py
+++ b/frappe/tests/test_api.py
@@ -1,77 +1,119 @@
+import sys
import unittest
+from contextlib import contextmanager
from random import choice
+from threading import Thread
+from typing import Dict, Optional, Tuple
+from unittest.mock import patch
import requests
from semantic_version import Version
+from werkzeug.test import TestResponse
import frappe
-from frappe.utils import get_site_url
+from frappe.utils import get_site_url, get_test_client
+
+try:
+ _site = frappe.local.site
+except Exception:
+ _site = None
+
+authorization_token = None
+
+@contextmanager
+def suppress_stdout():
+ """Supress stdout for tests which expectedly make noise
+ but that you don't need in tests"""
+ sys.stdout = None
+ try:
+ yield
+ finally:
+ sys.stdout = sys.__stdout__
-def maintain_state(f):
- def wrapper(*args, **kwargs):
- frappe.db.rollback()
- r = f(*args, **kwargs)
- frappe.db.commit()
- return r
-
- return wrapper
+def make_request(target: str, args: Optional[Tuple] = None, kwargs: Optional[Dict] = None) -> TestResponse:
+ t = ThreadWithReturnValue(target=target, args=args, kwargs=kwargs)
+ t.start()
+ t.join()
+ return t._return
-class TestResourceAPI(unittest.TestCase):
- SITE_URL = get_site_url(frappe.local.site)
+def patch_request_header(key, *args, **kwargs):
+ if key == "Authorization":
+ return f"token {authorization_token}"
+
+
+class ThreadWithReturnValue(Thread):
+ def __init__(self, group=None, target=None, name=None, args=(), kwargs={}):
+ Thread.__init__(self, group, target, name, args, kwargs)
+ self._return = None
+
+ def run(self):
+ if self._target is not None:
+ with patch("frappe.app.get_site_name", return_value=_site):
+ header_patch = patch("frappe.get_request_header", new=patch_request_header)
+ if authorization_token:
+ header_patch.start()
+ self._return = self._target(*self._args, **self._kwargs)
+ if authorization_token:
+ header_patch.stop()
+
+ def join(self, *args):
+ Thread.join(self, *args)
+ return self._return
+
+
+class FrappeAPITestCase(unittest.TestCase):
+ SITE = frappe.local.site
+ SITE_URL = get_site_url(SITE)
RESOURCE_URL = f"{SITE_URL}/api/resource"
+ TEST_CLIENT = get_test_client()
+
+ @property
+ def sid(self) -> str:
+ if not getattr(self, "_sid", None):
+ from frappe.auth import CookieManager, LoginManager
+ from frappe.utils import set_request
+
+ set_request(path="/")
+ frappe.local.cookie_manager = CookieManager()
+ frappe.local.login_manager = LoginManager()
+ frappe.local.login_manager.login_as('Administrator')
+ self._sid = frappe.session.sid
+
+ return self._sid
+
+ def get(self, path: str, params: Optional[Dict] = None) -> TestResponse:
+ return make_request(target=self.TEST_CLIENT.get, args=(path, ), kwargs={"data": params})
+
+ def post(self, path, data) -> TestResponse:
+ return make_request(target=self.TEST_CLIENT.post, args=(path, ), kwargs={"data": data})
+
+ def put(self, path, data) -> TestResponse:
+ return make_request(target=self.TEST_CLIENT.put, args=(path, ), kwargs={"data": data})
+
+ def delete(self, path) -> TestResponse:
+ return make_request(target=self.TEST_CLIENT.delete, args=(path, ))
+
+
+class TestResourceAPI(FrappeAPITestCase):
DOCTYPE = "ToDo"
GENERATED_DOCUMENTS = []
@classmethod
- @maintain_state
- def setUpClass(self):
+ def setUpClass(cls):
for _ in range(10):
doc = frappe.get_doc(
{"doctype": "ToDo", "description": frappe.mock("paragraph")}
).insert()
- self.GENERATED_DOCUMENTS.append(doc.name)
+ cls.GENERATED_DOCUMENTS.append(doc.name)
+ frappe.db.commit()
@classmethod
- @maintain_state
- def tearDownClass(self):
- for name in self.GENERATED_DOCUMENTS:
- frappe.delete_doc_if_exists(self.DOCTYPE, name)
-
- def setUp(self):
- # commit to ensure consistency in session (postgres CI randomly fails)
- if frappe.conf.db_type == "postgres":
- frappe.db.commit()
-
- @property
- def sid(self):
- if not getattr(self, "_sid", None):
- self._sid = requests.post(
- f"{self.SITE_URL}/api/method/login",
- data={
- "usr": "Administrator",
- "pwd": frappe.conf.admin_password or "admin",
- },
- ).cookies.get("sid")
-
- return self._sid
-
- def get(self, path, params=""):
- return requests.get(f"{self.RESOURCE_URL}/{path}?sid={self.sid}{params}")
-
- def post(self, path, data):
- return requests.post(
- f"{self.RESOURCE_URL}/{path}?sid={self.sid}", data=frappe.as_json(data)
- )
-
- def put(self, path, data):
- return requests.put(
- f"{self.RESOURCE_URL}/{path}?sid={self.sid}", data=frappe.as_json(data)
- )
-
- def delete(self, path):
- return requests.delete(f"{self.RESOURCE_URL}/{path}?sid={self.sid}")
+ def tearDownClass(cls):
+ for name in cls.GENERATED_DOCUMENTS:
+ frappe.delete_doc_if_exists(cls.DOCTYPE, name)
+ frappe.db.commit()
def test_unauthorized_call(self):
# test 1: fetch documents without auth
@@ -80,88 +122,113 @@ class TestResourceAPI(unittest.TestCase):
def test_get_list(self):
# test 2: fetch documents without params
- response = self.get(self.DOCTYPE)
+ response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid})
self.assertEqual(response.status_code, 200)
- self.assertIsInstance(response.json(), dict)
- self.assertIn("data", response.json())
+ self.assertIsInstance(response.json, dict)
+ self.assertIn("data", response.json)
def test_get_list_limit(self):
# test 3: fetch data with limit
- response = self.get(self.DOCTYPE, "&limit=2")
+ response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "limit": 2})
self.assertEqual(response.status_code, 200)
- self.assertEqual(len(response.json()["data"]), 2)
+ self.assertEqual(len(response.json["data"]), 2)
def test_get_list_dict(self):
# test 4: fetch response as (not) dict
- response = self.get(self.DOCTYPE, "&as_dict=True")
- json = frappe._dict(response.json())
+ response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": True})
+ json = frappe._dict(response.json)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(json.data, list)
self.assertIsInstance(json.data[0], dict)
- response = self.get(self.DOCTYPE, "&as_dict=False")
- json = frappe._dict(response.json())
+ response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": False})
+ json = frappe._dict(response.json)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(json.data, list)
self.assertIsInstance(json.data[0], list)
def test_get_list_debug(self):
# test 5: fetch response with debug
- response = self.get(self.DOCTYPE, "&debug=true")
+ response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "debug": True})
self.assertEqual(response.status_code, 200)
- self.assertIn("exc", response.json())
- self.assertIsInstance(response.json()["exc"], str)
- self.assertIsInstance(eval(response.json()["exc"]), list)
+ self.assertIn("exc", response.json)
+ self.assertIsInstance(response.json["exc"], str)
+ self.assertIsInstance(eval(response.json["exc"]), list)
def test_get_list_fields(self):
# test 6: fetch response with fields
- response = self.get(self.DOCTYPE, r'&fields=["description"]')
+ response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "fields": '["description"]'})
self.assertEqual(response.status_code, 200)
- json = frappe._dict(response.json())
+ json = frappe._dict(response.json)
self.assertIn("description", json.data[0])
def test_create_document(self):
# test 7: POST method on /api/resource to create doc
- data = {"description": frappe.mock("paragraph")}
- response = self.post(self.DOCTYPE, data)
+ data = {"description": frappe.mock("paragraph"), "sid": self.sid}
+ response = self.post(f"/api/resource/{self.DOCTYPE}", data)
self.assertEqual(response.status_code, 200)
- docname = response.json()["data"]["name"]
+ docname = response.json["data"]["name"]
self.assertIsInstance(docname, str)
self.GENERATED_DOCUMENTS.append(docname)
def test_update_document(self):
# test 8: PUT method on /api/resource to update doc
generated_desc = frappe.mock("paragraph")
- data = {"description": generated_desc}
+ data = {"description": generated_desc, "sid": self.sid}
random_doc = choice(self.GENERATED_DOCUMENTS)
desc_before_update = frappe.db.get_value(self.DOCTYPE, random_doc, "description")
- response = self.put(f"{self.DOCTYPE}/{random_doc}", data=data)
+ response = self.put(f"/api/resource/{self.DOCTYPE}/{random_doc}", data=data)
self.assertEqual(response.status_code, 200)
- self.assertNotEqual(response.json()["data"]["description"], desc_before_update)
- self.assertEqual(response.json()["data"]["description"], generated_desc)
+ self.assertNotEqual(response.json["data"]["description"], desc_before_update)
+ self.assertEqual(response.json["data"]["description"], generated_desc)
def test_delete_document(self):
# test 9: DELETE method on /api/resource
doc_to_delete = choice(self.GENERATED_DOCUMENTS)
- response = self.delete(f"{self.DOCTYPE}/{doc_to_delete}")
+ response = self.delete(f"/api/resource/{self.DOCTYPE}/{doc_to_delete}")
self.assertEqual(response.status_code, 202)
- self.assertDictEqual(response.json(), {"message": "ok"})
+ self.assertDictEqual(response.json, {"message": "ok"})
self.GENERATED_DOCUMENTS.remove(doc_to_delete)
non_existent_doc = frappe.generate_hash(length=12)
- response = self.delete(f"{self.DOCTYPE}/{non_existent_doc}")
+ with suppress_stdout():
+ response = self.delete(f"/api/resource/{self.DOCTYPE}/{non_existent_doc}")
self.assertEqual(response.status_code, 404)
- self.assertDictEqual(response.json(), {})
+ self.assertDictEqual(response.json, {})
+
+ def test_run_doc_method(self):
+ # test 10: Run whitelisted method on doc via /api/resource
+ # status_code is 403 if no other tests are run before this - it's not logged in
+ self.post("/api/resource/Website Theme/Standard", {"run_method": "get_apps"})
+ response = self.get("/api/resource/Website Theme/Standard", {"run_method": "get_apps"})
+ self.assertIn(response.status_code, (403, 200))
+
+ if response.status_code == 403:
+ self.assertTrue(set(response.json.keys()) == {'exc_type', 'exception', 'exc', '_server_messages'})
+ self.assertEqual(response.json.get('exc_type'), 'PermissionError')
+ self.assertEqual(response.json.get('exception'), 'frappe.exceptions.PermissionError: Not permitted')
+ self.assertIsInstance(response.json.get('exc'), str)
+
+ elif response.status_code == 200:
+ data = response.json.get("data")
+ self.assertIsInstance(data, list)
+ self.assertIsInstance(data[0], dict)
-class TestMethodAPI(unittest.TestCase):
- METHOD_URL = f"{get_site_url(frappe.local.site)}/api/method"
+class TestMethodAPI(FrappeAPITestCase):
+ METHOD_PATH = "/api/method"
+
+ def setUp(self):
+ if self._testMethodName == "test_auth_cycle":
+ from frappe.core.doctype.user.user import generate_keys
+ generate_keys("Administrator")
+ frappe.db.commit()
def test_version(self):
# test 1: test for /api/method/version
- response = requests.get(f"{self.METHOD_URL}/version")
- json = frappe._dict(response.json())
+ response = self.get(f"{self.METHOD_PATH}/version")
+ json = frappe._dict(response.json)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(json, dict)
@@ -170,7 +237,27 @@ class TestMethodAPI(unittest.TestCase):
def test_ping(self):
# test 2: test for /api/method/ping
- response = requests.get(f"{self.METHOD_URL}/ping")
+ response = self.get(f"{self.METHOD_PATH}/ping")
self.assertEqual(response.status_code, 200)
- self.assertIsInstance(response.json(), dict)
- self.assertEqual(response.json()['message'], "pong")
+ self.assertIsInstance(response.json, dict)
+ self.assertEqual(response.json["message"], "pong")
+
+ def test_get_user_info(self):
+ # test 3: test for /api/method/frappe.realtime.get_user_info
+ response = self.get(f"{self.METHOD_PATH}/frappe.realtime.get_user_info")
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response.json, dict)
+ self.assertIn(response.json.get("message").get("user"), ("Administrator", "Guest"))
+
+ def test_auth_cycle(self):
+ # test 4: Pass authorization token in request
+ global authorization_token
+ user = frappe.get_doc("User", "Administrator")
+ api_key, api_secret = user.api_key, user.get_password("api_secret")
+ authorization_token = f"{api_key}:{api_secret}"
+ response = self.get("/api/method/frappe.auth.get_logged_user")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json["message"], "Administrator")
+
+ authorization_token = None
diff --git a/frappe/tests/test_base_document.py b/frappe/tests/test_base_document.py
new file mode 100644
index 0000000000..fda795b5b6
--- /dev/null
+++ b/frappe/tests/test_base_document.py
@@ -0,0 +1,18 @@
+import unittest
+
+from frappe.model.base_document import BaseDocument
+
+
+class TestBaseDocument(unittest.TestCase):
+ def test_docstatus(self):
+ doc = BaseDocument({"docstatus": 0})
+ self.assertTrue(doc.docstatus.is_draft())
+ self.assertEqual(doc.docstatus, 0)
+
+ doc.docstatus = 1
+ self.assertTrue(doc.docstatus.is_submitted())
+ self.assertEqual(doc.docstatus, 1)
+
+ doc.docstatus = 2
+ self.assertTrue(doc.docstatus.is_cancelled())
+ self.assertEqual(doc.docstatus, 2)
diff --git a/frappe/tests/test_child_table.py b/frappe/tests/test_child_table.py
new file mode 100644
index 0000000000..8cdfd08599
--- /dev/null
+++ b/frappe/tests/test_child_table.py
@@ -0,0 +1,66 @@
+import frappe
+from frappe.model import child_table_fields
+
+import unittest
+from typing import Callable
+
+
+class TestChildTable(unittest.TestCase):
+ def tearDown(self) -> None:
+ try:
+ frappe.delete_doc("DocType", self.doctype_name, force=1)
+ except Exception:
+ pass
+
+ def test_child_table_doctype_creation_and_transitioning(self) -> None:
+ '''
+ This method tests the creation of child table doctype
+ as well as it's transitioning from child table to normal and normal to child table doctype
+ '''
+
+ self.doctype_name = "Test Newy Child Table"
+
+ try:
+ doc = frappe.get_doc({
+ "doctype": "DocType",
+ "name": self.doctype_name,
+ "istable": 1,
+ "custom": 1,
+ "module": "Integrations",
+ "fields": [{
+ "label": "Some Field",
+ "fieldname": "some_fieldname",
+ "fieldtype": "Data",
+ "reqd": 1
+ }]
+ }).insert(ignore_permissions=True)
+ except Exception:
+ self.fail("Not able to create Child Table Doctype")
+
+
+ for column in child_table_fields:
+ self.assertTrue(frappe.db.has_column(self.doctype_name, column))
+
+ # check transitioning from child table to normal doctype
+ doc.istable = 0
+ try:
+ doc.save(ignore_permissions=True)
+ except Exception:
+ self.fail("Not able to transition from Child Table Doctype to Normal Doctype")
+
+ self.check_valid_columns(self.assertFalse)
+
+ # check transitioning from normal to child table doctype
+ doc.istable = 1
+ try:
+ doc.save(ignore_permissions=True)
+ except Exception:
+ self.fail("Not able to transition from Normal Doctype to Child Table Doctype")
+
+ self.check_valid_columns(self.assertTrue)
+
+
+ def check_valid_columns(self, assertion_method: Callable) -> None:
+ valid_columns = frappe.get_meta(self.doctype_name).get_valid_columns()
+ for column in child_table_fields:
+ assertion_method(column in valid_columns)
diff --git a/frappe/tests/test_client.py b/frappe/tests/test_client.py
index aed8dc8581..40639e4f98 100644
--- a/frappe/tests/test_client.py
+++ b/frappe/tests/test_client.py
@@ -101,3 +101,33 @@ class TestClient(unittest.TestCase):
execute_cmd,
frappe.local.form_dict.cmd
)
+
+ def test_array_values_in_request_args(self):
+ import requests
+ from frappe.auth import CookieManager, LoginManager
+
+ frappe.utils.set_request(path="/")
+ frappe.local.cookie_manager = CookieManager()
+ frappe.local.login_manager = LoginManager()
+ frappe.local.login_manager.login_as('Administrator')
+ params = {
+ 'doctype': 'DocType',
+ 'fields': ['name', 'modified'],
+ 'sid': frappe.session.sid,
+ }
+ headers = {
+ 'accept': 'application/json',
+ 'content-type': 'application/json',
+ }
+ url = f'http://{frappe.local.site}:{frappe.conf.webserver_port}/api/method/frappe.client.get_list'
+ res = requests.post(
+ url,
+ json=params,
+ headers=headers
+ )
+ self.assertEqual(res.status_code, 200)
+ data = res.json()
+ first_item = data['message'][0]
+ self.assertTrue('name' in first_item)
+ self.assertTrue('modified' in first_item)
+ frappe.local.login_manager.logout()
diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py
index 408d644042..68605444f1 100644
--- a/frappe/tests/test_commands.py
+++ b/frappe/tests/test_commands.py
@@ -3,25 +3,37 @@
# imports - standard imports
import gzip
+import importlib
import json
import os
import shlex
import shutil
import subprocess
-from typing import List
import unittest
+from contextlib import contextmanager
+from functools import wraps
from glob import glob
+from typing import List, Optional
from unittest.case import skipIf
+from unittest.mock import patch
+
+# imports - third party imports
+import click
+from click.testing import CliRunner, Result
+from click import Command
# imports - module imports
import frappe
+import frappe.commands.site
+import frappe.commands.utils
import frappe.recorder
from frappe.installer import add_to_installed_apps, remove_app
from frappe.utils import add_to_date, get_bench_path, get_bench_relative_path, now
from frappe.utils.backups import fetch_latest_backups
-# imports - third party imports
-import click
+_result: Optional[Result] = None
+TEST_SITE = "commands-site-O4PN2QKA.test" # added random string tag to avoid collisions
+CLI_CONTEXT = frappe._dict(sites=[TEST_SITE])
def clean(value) -> str:
@@ -76,7 +88,61 @@ def exists_in_backup(doctypes: List, file: os.PathLike) -> bool:
return len(missing_doctypes) == 0
+@contextmanager
+def maintain_locals():
+ pre_site = frappe.local.site
+ pre_flags = frappe.local.flags.copy()
+ pre_db = frappe.local.db
+
+ try:
+ yield
+ finally:
+ post_site = getattr(frappe.local, "site", None)
+ if not post_site or post_site != pre_site:
+ frappe.init(site=pre_site)
+ frappe.local.db = pre_db
+ frappe.local.flags.update(pre_flags)
+
+
+def pass_test_context(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ return f(CLI_CONTEXT, *args, **kwargs)
+ return decorated_function
+
+
+@contextmanager
+def cli(cmd: Command, args: Optional[List] = None):
+ with maintain_locals():
+ global _result
+
+ patch_ctx = patch("frappe.commands.pass_context", pass_test_context)
+ _module = cmd.callback.__module__
+ _cmd = cmd.callback.__qualname__
+
+ __module = importlib.import_module(_module)
+ patch_ctx.start()
+ importlib.reload(__module)
+ click_cmd = getattr(__module, _cmd)
+
+ try:
+ _result = CliRunner().invoke(click_cmd, args=args)
+ _result.command = str(cmd)
+ yield _result
+ finally:
+ patch_ctx.stop()
+ __module = importlib.import_module(_module)
+ importlib.reload(__module)
+ importlib.invalidate_caches()
+
+
class BaseTestCommands(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.setup_test_site()
+ return super().setUpClass()
+
+ @classmethod
def execute(self, command, kwargs=None):
site = {"site": frappe.local.site}
cmd_input = None
@@ -102,16 +168,48 @@ class BaseTestCommands(unittest.TestCase):
self.stderr = clean(self._proc.stderr)
self.returncode = clean(self._proc.returncode)
+ @classmethod
+ def setup_test_site(cls):
+ cmd_config = {
+ "test_site": TEST_SITE,
+ "admin_password": frappe.conf.admin_password,
+ "root_login": frappe.conf.root_login,
+ "root_password": frappe.conf.root_password,
+ "db_type": frappe.conf.db_type,
+ }
+
+ if not os.path.exists(
+ os.path.join(TEST_SITE, "site_config.json")
+ ):
+ cls.execute(
+ "bench new-site {test_site} --admin-password {admin_password} --db-type"
+ " {db_type}",
+ cmd_config,
+ )
+
def _formatMessage(self, msg, standardMsg):
output = super(BaseTestCommands, self)._formatMessage(msg, standardMsg)
+
+ if not hasattr(self, "command") and _result:
+ command = _result.command
+ stdout = _result.stdout_bytes.decode() if _result.stdout_bytes else None
+ stderr = _result.stderr_bytes.decode() if _result.stderr_bytes else None
+ returncode = _result.exit_code
+ else:
+ command = self.command
+ stdout = self.stdout
+ stderr = self.stderr
+ returncode = self.returncode
+
cmd_execution_summary = "\n".join([
"-" * 70,
"Last Command Execution Summary:",
- "Command: {}".format(self.command) if self.command else "",
- "Standard Output: {}".format(self.stdout) if self.stdout else "",
- "Standard Error: {}".format(self.stderr) if self.stderr else "",
- "Return Code: {}".format(self.returncode) if self.returncode else "",
+ "Command: {}".format(command) if command else "",
+ "Standard Output: {}".format(stdout) if stdout else "",
+ "Standard Error: {}".format(stderr) if stderr else "",
+ "Return Code: {}".format(returncode) if returncode else "",
]).strip()
+
return "{}\n\n{}".format(output, cmd_execution_summary)
@@ -135,6 +233,7 @@ class TestCommands(BaseTestCommands):
self.assertEqual(self.returncode, 0)
self.assertEqual(self.stdout[1:-1], frappe.bold(text="DocType"))
+ @unittest.skip
def test_restore(self):
# step 0: create a site to run the test on
global_config = {
@@ -143,35 +242,30 @@ class TestCommands(BaseTestCommands):
"root_password": frappe.conf.root_password,
"db_type": frappe.conf.db_type,
}
- site_data = {"another_site": f"{frappe.local.site}-restore.test", **global_config}
+ site_data = {"test_site": TEST_SITE, **global_config}
for key, value in global_config.items():
if value:
self.execute(f"bench set-config {key} {value} -g")
- self.execute(
- "bench new-site {another_site} --admin-password {admin_password} --db-type"
- " {db_type}",
- site_data,
- )
# test 1: bench restore from full backup
- self.execute("bench --site {another_site} backup --ignore-backup-conf", site_data)
+ self.execute("bench --site {test_site} backup --ignore-backup-conf", site_data)
self.execute(
- "bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups",
+ "bench --site {test_site} execute frappe.utils.backups.fetch_latest_backups",
site_data,
)
site_data.update({"database": json.loads(self.stdout)["database"]})
- self.execute("bench --site {another_site} restore {database}", site_data)
+ self.execute("bench --site {test_site} restore {database}", site_data)
# test 2: restore from partial backup
- self.execute("bench --site {another_site} backup --exclude 'ToDo'", site_data)
+ self.execute("bench --site {test_site} backup --exclude 'ToDo'", site_data)
site_data.update({"kw": "\"{'partial':True}\""})
self.execute(
- "bench --site {another_site} execute"
+ "bench --site {test_site} execute"
" frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
site_data,
)
site_data.update({"database": json.loads(self.stdout)["database"]})
- self.execute("bench --site {another_site} restore {database}", site_data)
+ self.execute("bench --site {test_site} restore {database}", site_data)
self.assertEqual(self.returncode, 1)
def test_partial_restore(self):
@@ -226,7 +320,8 @@ class TestCommands(BaseTestCommands):
def test_list_apps(self):
# test 1: sanity check for command
self.execute("bench --site all list-apps")
- self.assertEqual(self.returncode, 0)
+ self.assertIsNotNone(self.returncode)
+ self.assertIsInstance(self.stdout or self.stderr, str)
# test 2: bare functionality for single site
self.execute("bench --site {site} list-apps")
@@ -242,14 +337,12 @@ class TestCommands(BaseTestCommands):
self.assertSetEqual(list_apps, installed_apps)
# test 3: parse json format
- self.execute("bench --site all list-apps --format json")
+ self.execute("bench --site {site} list-apps --format json")
self.assertEqual(self.returncode, 0)
self.assertIsInstance(json.loads(self.stdout), dict)
- self.execute("bench --site {site} list-apps --format json")
- self.assertIsInstance(json.loads(self.stdout), dict)
-
self.execute("bench --site {site} list-apps -f json")
+ self.assertEqual(self.returncode, 0)
self.assertIsInstance(json.loads(self.stdout), dict)
def test_show_config(self):
@@ -358,7 +451,7 @@ class TestCommands(BaseTestCommands):
)
def test_bench_drop_site_should_archive_site(self):
# TODO: Make this test postgres compatible
- site = 'test_site.localhost'
+ site = TEST_SITE
self.execute(
f"bench new-site {site} --force --verbose "
@@ -585,3 +678,18 @@ class TestRemoveApp(unittest.TestCase):
# nothing to assert, if this fails rest of the test suite will crumble.
remove_app("frappe", dry_run=True, yes=True, no_backup=True)
+
+
+class TestSiteMigration(BaseTestCommands):
+ def test_migrate_cli(self):
+ with cli(frappe.commands.site.migrate) as result:
+ self.assertTrue(TEST_SITE in result.stdout)
+ self.assertEqual(result.exit_code, 0)
+ self.assertEqual(result.exception, None)
+
+
+class TestBenchBuild(BaseTestCommands):
+ def test_build_assets(self):
+ with cli(frappe.commands.utils.build) as result:
+ self.assertEqual(result.exit_code, 0)
+ self.assertEqual(result.exception, None)
diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py
index cdef4354ed..10c601db00 100644
--- a/frappe/tests/test_db.py
+++ b/frappe/tests/test_db.py
@@ -1,21 +1,21 @@
-# -*- 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 datetime
+import inspect
import unittest
from random import choice
-import datetime
+from unittest.mock import patch
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
-from frappe.utils import random_string
-from frappe.utils.testutils import clear_custom_fields
-from frappe.query_builder import Field
from frappe.database import savepoint
-
-from .test_query_builder import run_only_if, db_type_is
+from frappe.database.database import Database
+from frappe.query_builder import Field
from frappe.query_builder.functions import Concat_ws
+from frappe.tests.test_query_builder import db_type_is, run_only_if
+from frappe.utils import add_days, now, random_string, cint
+from frappe.utils.testutils import clear_custom_fields
class TestDB(unittest.TestCase):
@@ -84,19 +84,26 @@ class TestDB(unittest.TestCase):
),
)
- def test_set_value(self):
- todo1 = frappe.get_doc(dict(doctype='ToDo', description = 'test_set_value 1')).insert()
- todo2 = frappe.get_doc(dict(doctype='ToDo', description = 'test_set_value 2')).insert()
+ def test_get_value_limits(self):
- frappe.db.set_value('ToDo', todo1.name, 'description', 'test_set_value change 1')
- self.assertEqual(frappe.db.get_value('ToDo', todo1.name, 'description'), 'test_set_value change 1')
+ # check both dict and list style filters
+ filters = [{"enabled": 1}, [["enabled", "=", 1]]]
+ for filter in filters:
+ self.assertEqual(1, len(frappe.db.get_values("User", filters=filter, limit=1)))
+ # count of last touched rows as per DB-API 2.0 https://peps.python.org/pep-0249/#rowcount
+ self.assertGreaterEqual(1, cint(frappe.db._cursor.rowcount))
+ self.assertEqual(2, len(frappe.db.get_values("User", filters=filter, limit=2)))
+ self.assertGreaterEqual(2, cint(frappe.db._cursor.rowcount))
- # multiple set-value
- frappe.db.set_value('ToDo', dict(description=('like', '%test_set_value%')),
- 'description', 'change 2')
+ # without limits length == count
+ self.assertEqual(len(frappe.db.get_values("User", filters=filter)),
+ frappe.db.count("User", filter))
- self.assertEqual(frappe.db.get_value('ToDo', todo1.name, 'description'), 'change 2')
- self.assertEqual(frappe.db.get_value('ToDo', todo2.name, 'description'), 'change 2')
+ frappe.db.get_value("User", filters=filter)
+ self.assertGreaterEqual(1, cint(frappe.db._cursor.rowcount))
+
+ frappe.db.exists("User", filter)
+ self.assertGreaterEqual(1, cint(frappe.db._cursor.rowcount))
def test_escape(self):
frappe.db.escape("香港濟生堂製藥有限公司 - IT".encode("utf-8"))
@@ -246,7 +253,6 @@ class TestDB(unittest.TestCase):
frappe.delete_doc(test_doctype, doc)
clear_custom_fields(test_doctype)
-
def test_savepoints(self):
frappe.db.rollback()
save_point = "todonope"
@@ -294,6 +300,42 @@ class TestDB(unittest.TestCase):
for d in created_docs:
self.assertTrue(frappe.db.exists("ToDo", d))
+ def test_transaction_writes_error(self):
+ from frappe.database.database import Database
+ frappe.db.rollback()
+
+ frappe.db.MAX_WRITES_PER_TRANSACTION = 1
+ note = frappe.get_last_doc("ToDo")
+ note.description = "changed"
+ with self.assertRaises(frappe.TooManyWritesError) as tmw:
+ note.save()
+
+ frappe.db.MAX_WRITES_PER_TRANSACTION = Database.MAX_WRITES_PER_TRANSACTION
+
+ def test_pk_collision_ignoring(self):
+ # note has `name` generated from title
+ for _ in range(3):
+ frappe.get_doc(doctype="Note", title="duplicate name").insert(ignore_if_duplicate=True)
+
+ with savepoint():
+ self.assertRaises(frappe.DuplicateEntryError, frappe.get_doc(doctype="Note", title="duplicate name").insert)
+ # recover transaction to continue other tests
+ raise Exception
+
+ def test_exists(self):
+ dt, dn = "User", "Administrator"
+ self.assertEqual(frappe.db.exists(dt, dn, cache=True), dn)
+ self.assertEqual(frappe.db.exists(dt, dn), dn)
+ self.assertEqual(frappe.db.exists(dt, {"name": ("=", dn)}), dn)
+
+ filters = {"doctype": dt, "name": ("like", "Admin%")}
+ self.assertEqual(frappe.db.exists(filters), dn)
+ self.assertEqual(
+ filters["doctype"], dt
+ ) # make sure that doctype was not removed from filters
+
+ self.assertEqual(frappe.db.exists(dt, [["name", "=", dn]]), dn)
+
@run_only_if(db_type_is.MARIADB)
class TestDDLCommandsMaria(unittest.TestCase):
@@ -350,7 +392,144 @@ class TestDDLCommandsMaria(unittest.TestCase):
WHERE Key_name = '{index_name}';
"""
)
- self.assertEquals(len(indexs_in_table), 2)
+ self.assertEqual(len(indexs_in_table), 2)
+
+
+class TestDBSetValue(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.todo1 = frappe.get_doc(doctype="ToDo", description="test_set_value 1").insert()
+ cls.todo2 = frappe.get_doc(doctype="ToDo", description="test_set_value 2").insert()
+
+ def test_update_single_doctype_field(self):
+ value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions")
+ changed_value = not value
+
+ frappe.db.set_value("System Settings", "System Settings", "deny_multiple_sessions", changed_value)
+ current_value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions")
+ self.assertEqual(current_value, changed_value)
+
+ changed_value = not current_value
+ frappe.db.set_value("System Settings", None, "deny_multiple_sessions", changed_value)
+ current_value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions")
+ self.assertEqual(current_value, changed_value)
+
+ changed_value = not current_value
+ frappe.db.set_single_value("System Settings", "deny_multiple_sessions", changed_value)
+ current_value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions")
+ self.assertEqual(current_value, changed_value)
+
+ def test_update_single_row_single_column(self):
+ frappe.db.set_value("ToDo", self.todo1.name, "description", "test_set_value change 1")
+ updated_value = frappe.db.get_value("ToDo", self.todo1.name, "description")
+ self.assertEqual(updated_value, "test_set_value change 1")
+
+ def test_update_single_row_multiple_columns(self):
+ description, status = "Upated by test_update_single_row_multiple_columns", "Closed"
+
+ frappe.db.set_value("ToDo", self.todo1.name, {
+ "description": description,
+ "status": status,
+ }, update_modified=False)
+
+ updated_desciption, updated_status = frappe.db.get_value("ToDo",
+ filters={"name": self.todo1.name},
+ fieldname=["description", "status"]
+ )
+
+ self.assertEqual(description, updated_desciption)
+ self.assertEqual(status, updated_status)
+
+ def test_update_multiple_rows_single_column(self):
+ frappe.db.set_value("ToDo", {"description": ("like", "%test_set_value%")}, "description", "change 2")
+
+ self.assertEqual(frappe.db.get_value("ToDo", self.todo1.name, "description"), "change 2")
+ self.assertEqual(frappe.db.get_value("ToDo", self.todo2.name, "description"), "change 2")
+
+ def test_update_multiple_rows_multiple_columns(self):
+ todos_to_update = frappe.get_all("ToDo", filters={
+ "description": ("like", "%test_set_value%"),
+ "status": ("!=", "Closed")
+ }, pluck="name")
+
+ frappe.db.set_value("ToDo", {
+ "description": ("like", "%test_set_value%"),
+ "status": ("!=", "Closed")
+ }, {
+ "status": "Closed",
+ "priority": "High"
+ })
+
+ test_result = frappe.get_all("ToDo", filters={"name": ("in", todos_to_update)}, fields=["status", "priority"])
+
+ self.assertTrue(all(x for x in test_result if x["status"] == "Closed"))
+ self.assertTrue(all(x for x in test_result if x["priority"] == "High"))
+
+ def test_update_modified_options(self):
+ self.todo2.reload()
+
+ todo = self.todo2
+ updated_description = f"{todo.description} - by `test_update_modified_options`"
+ custom_modified = datetime.datetime.fromisoformat(add_days(now(), 10))
+ custom_modified_by = "user_that_doesnt_exist@example.com"
+
+ frappe.db.set_value("ToDo", todo.name, "description", updated_description, update_modified=False)
+ self.assertEqual(updated_description, frappe.db.get_value("ToDo", todo.name, "description"))
+ self.assertEqual(todo.modified, frappe.db.get_value("ToDo", todo.name, "modified"))
+
+ frappe.db.set_value("ToDo", todo.name, "description", "test_set_value change 1", modified=custom_modified, modified_by=custom_modified_by)
+ self.assertTupleEqual(
+ (custom_modified, custom_modified_by),
+ frappe.db.get_value("ToDo", todo.name, ["modified", "modified_by"])
+ )
+
+ def test_for_update(self):
+ self.todo1.reload()
+
+ with patch.object(Database, "sql") as sql_called:
+ frappe.db.set_value(
+ self.todo1.doctype,
+ self.todo1.name,
+ "description",
+ f"{self.todo1.description}-edit by `test_for_update`"
+ )
+ first_query = sql_called.call_args_list[0].args[0]
+ second_query = sql_called.call_args_list[1].args[0]
+
+ self.assertTrue(sql_called.call_count == 2)
+ self.assertTrue("FOR UPDATE" in first_query)
+ if frappe.conf.db_type == "postgres":
+ from frappe.database.postgres.database import modify_query
+ self.assertTrue(modify_query("UPDATE `tabToDo` SET") in second_query)
+ if frappe.conf.db_type == "mariadb":
+ self.assertTrue("UPDATE `tabToDo` SET" in second_query)
+
+ def test_cleared_cache(self):
+ self.todo2.reload()
+
+ with patch.object(frappe, "clear_document_cache") as clear_cache:
+ frappe.db.set_value(
+ self.todo2.doctype,
+ self.todo2.name,
+ "description",
+ f"{self.todo2.description}-edit by `test_cleared_cache`"
+ )
+ clear_cache.assert_called()
+
+ def test_update_alias(self):
+ args = (self.todo1.doctype, self.todo1.name, "description", "Updated by `test_update_alias`")
+ kwargs = {"for_update": False, "modified": None, "modified_by": None, "update_modified": True, "debug": False}
+
+ self.assertTrue("return self.set_value(" in inspect.getsource(frappe.db.update))
+
+ with patch.object(Database, "set_value") as set_value:
+ frappe.db.update(*args, **kwargs)
+ set_value.assert_called_once()
+ set_value.assert_called_with(*args, **kwargs)
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
@run_only_if(db_type_is.POSTGRES)
@@ -417,4 +596,51 @@ class TestDDLCommandsPost(unittest.TestCase):
AND indexname = '{index_name}' ;
""",
)
- self.assertEquals(len(indexs_in_table), 1)
+ self.assertEqual(len(indexs_in_table), 1)
+
+ @run_only_if(db_type_is.POSTGRES)
+ def test_modify_query(self):
+ from frappe.database.postgres.database import modify_query
+
+ query = "select * from `tabtree b` where lft > 13 and rgt <= 16 and name =1.0 and parent = 4134qrsdc and isgroup = 1.00045"
+ self.assertEqual(
+ "select * from \"tabtree b\" where lft > \'13\' and rgt <= '16' and name = '1' and parent = 4134qrsdc and isgroup = 1.00045",
+ modify_query(query)
+ )
+
+ query = "select locate(\".io\", \"frappe.io\"), locate(\"3\", cast(3 as varchar)), locate(\"3\", 3::varchar)"
+ self.assertEqual(
+ "select strpos( \"frappe.io\", \".io\"), strpos( cast(3 as varchar), \"3\"), strpos( 3::varchar, \"3\")",
+ modify_query(query)
+ )
+
+ @run_only_if(db_type_is.POSTGRES)
+ def test_modify_values(self):
+ from frappe.database.postgres.database import modify_values
+
+ self.assertEqual(
+ {"abcd": "23", "efgh": "23", "ijkl": 23.0345, "mnop": "wow"},
+ modify_values({"abcd": 23, "efgh": 23.0, "ijkl": 23.0345, "mnop": "wow"})
+ )
+ self.assertEqual(
+ ["23", "23", 23.00004345, "wow"],
+ modify_values((23, 23.0, 23.00004345, "wow"))
+ )
+
+ def test_sequence_table_creation(self):
+ from frappe.core.doctype.doctype.test_doctype import new_doctype
+
+ dt = new_doctype("autoinc_dt_seq_test", autoincremented=True).insert(ignore_permissions=True)
+
+ if frappe.db.db_type == "postgres":
+ self.assertTrue(
+ frappe.db.sql("""select sequence_name FROM information_schema.sequences
+ where sequence_name ilike 'autoinc_dt_seq_test%'""")[0][0]
+ )
+ else:
+ self.assertTrue(
+ frappe.db.sql("""select data_type FROM information_schema.tables
+ where table_type = 'SEQUENCE' and table_name like 'autoinc_dt_seq_test%'""")[0][0]
+ )
+
+ dt.delete(ignore_permissions=True)
diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py
index 5cd6690209..b4c7c7cce7 100644
--- a/frappe/tests/test_db_query.py
+++ b/frappe/tests/test_db_query.py
@@ -97,6 +97,12 @@ class TestReportview(unittest.TestCase):
self.assertFalse(result
in DatabaseQuery("DocType").execute(filters={"name": ["not in", 'DocType,DocField']}))
+ def test_none_filter(self):
+ query = frappe.db.query.get_sql("DocType", fields="name", filters={"restrict_to_domain": None})
+ sql = str(query).replace('`', '').replace('"', '')
+ condition = 'restrict_to_domain IS NULL'
+ self.assertIn(condition, sql)
+
def test_or_filters(self):
data = DatabaseQuery("DocField").execute(
filters={"parent": "DocType"}, fields=["fieldname", "fieldtype"],
@@ -149,7 +155,6 @@ class TestReportview(unittest.TestCase):
filters={"creation": ["between", ["2016-07-06", "2016-07-07"]]},
fields=["name"])
-
def test_ignore_permissions_for_get_filters_cond(self):
frappe.set_user('test2@example.com')
self.assertRaises(frappe.PermissionError, get_filters_cond, 'DocType', dict(istable=1), [])
@@ -351,7 +356,6 @@ class TestReportview(unittest.TestCase):
self.assertTrue(len(data) == 0)
self.assertTrue(len(frappe.get_all('Nested DocType', {'name': ('not ancestors of', 'Root')})) == len(frappe.get_all('Nested DocType')))
-
def test_is_set_is_not_set(self):
res = DatabaseQuery('DocType').execute(filters={'autoname': ['is', 'not set']})
self.assertTrue({'name': 'Integration Request'} in res)
@@ -490,6 +494,27 @@ class TestReportview(unittest.TestCase):
response = execute_cmd("frappe.desk.reportview.get")
self.assertListEqual(response["keys"], ["field_label", "field_name", "_aggregate_column", 'columns'])
+ def test_cast_name(self):
+ from frappe.core.doctype.doctype.test_doctype import new_doctype
+
+ dt = new_doctype("autoinc_dt_test", autoincremented=True).insert(ignore_permissions=True)
+
+ query = DatabaseQuery("autoinc_dt_test").execute(
+ fields=["locate('1', `tabautoinc_dt_test`.`name`)", "`tabautoinc_dt_test`.`name`"],
+ filters={"name": 1},
+ run=False
+ )
+
+ if frappe.db.db_type == "postgres":
+ self.assertTrue("strpos( cast( \"tabautoinc_dt_test\".\"name\" as varchar), \'1\')" in query)
+ self.assertTrue("where cast(\"tabautoinc_dt_test\".name as varchar) = \'1\'" in query)
+ else:
+ self.assertTrue("locate(\'1\', `tabautoinc_dt_test`.`name`)" in query)
+ self.assertTrue("where `tabautoinc_dt_test`.name = 1" in query)
+
+ dt.delete(ignore_permissions=True)
+
+
def add_child_table_to_blog_post():
child_table = frappe.get_doc({
'doctype': 'DocType',
diff --git a/frappe/tests/test_db_update.py b/frappe/tests/test_db_update.py
index 4ae33a2fab..66eb05391a 100644
--- a/frappe/tests/test_db_update.py
+++ b/frappe/tests/test_db_update.py
@@ -34,6 +34,58 @@ class TestDBUpdate(unittest.TestCase):
self.assertEqual(fieldtype, table_column.type)
self.assertIn(cstr(table_column.default) or 'NULL', [cstr(default), "'{}'".format(default)])
+ def test_index_and_unique_constraints(self):
+ doctype = "User"
+ frappe.reload_doctype('User', force=True)
+ frappe.model.meta.trim_tables('User')
+
+ make_property_setter(doctype, 'restrict_ip', 'unique', '1', 'Int')
+ frappe.db.updatedb(doctype)
+ restrict_ip_in_table = get_table_column("User", "restrict_ip")
+ self.assertTrue(restrict_ip_in_table.unique)
+
+ make_property_setter(doctype, 'restrict_ip', 'unique', '0', 'Int')
+ frappe.db.updatedb(doctype)
+ restrict_ip_in_table = get_table_column("User", "restrict_ip")
+ self.assertFalse(restrict_ip_in_table.unique)
+
+ make_property_setter(doctype, 'restrict_ip', 'search_index', '1', 'Int')
+ frappe.db.updatedb(doctype)
+ restrict_ip_in_table = get_table_column("User", "restrict_ip")
+ self.assertTrue(restrict_ip_in_table.index)
+
+ make_property_setter(doctype, 'restrict_ip', 'search_index', '0', 'Int')
+ frappe.db.updatedb(doctype)
+ restrict_ip_in_table = get_table_column("User", "restrict_ip")
+ self.assertFalse(restrict_ip_in_table.index)
+
+ make_property_setter(doctype, 'restrict_ip', 'search_index', '1', 'Int')
+ make_property_setter(doctype, 'restrict_ip', 'unique', '1', 'Int')
+ frappe.db.updatedb(doctype)
+ restrict_ip_in_table = get_table_column("User", "restrict_ip")
+ self.assertTrue(restrict_ip_in_table.index)
+ self.assertTrue(restrict_ip_in_table.unique)
+
+ make_property_setter(doctype, 'restrict_ip', 'search_index', '1', 'Int')
+ make_property_setter(doctype, 'restrict_ip', 'unique', '0', 'Int')
+ frappe.db.updatedb(doctype)
+ restrict_ip_in_table = get_table_column("User", "restrict_ip")
+ self.assertTrue(restrict_ip_in_table.index)
+ self.assertFalse(restrict_ip_in_table.unique)
+
+ make_property_setter(doctype, 'restrict_ip', 'search_index', '0', 'Int')
+ make_property_setter(doctype, 'restrict_ip', 'unique', '1', 'Int')
+ frappe.db.updatedb(doctype)
+ restrict_ip_in_table = get_table_column("User", "restrict_ip")
+ self.assertFalse(restrict_ip_in_table.index)
+ self.assertTrue(restrict_ip_in_table.unique)
+
+ # explicitly make a text index
+ frappe.db.add_index(doctype, ["email_signature(200)"])
+ frappe.db.updatedb(doctype)
+ email_sig_column = get_table_column("User", "email_signature")
+ self.assertEqual(email_sig_column.index, 1)
+
def get_fieldtype_from_def(field_def):
fieldtuple = frappe.db.type_map.get(field_def.fieldtype, ('', 0))
fieldtype = fieldtuple[0]
@@ -51,10 +103,7 @@ def get_other_fields_meta(meta):
default_fields_map = {
'name': ('Data', 0),
'owner': ('Data', 0),
- 'parent': ('Data', 0),
- 'parentfield': ('Data', 0),
'modified_by': ('Data', 0),
- 'parenttype': ('Data', 0),
'creation': ('Datetime', 0),
'modified': ('Datetime', 0),
'idx': ('Int', 8),
@@ -65,8 +114,16 @@ def get_other_fields_meta(meta):
if meta.track_seen:
optional_fields.append('_seen')
+ child_table_fields_map = {}
+ if meta.istable:
+ child_table_fields_map.update({field: ('Data', 0) for field in frappe.db.CHILD_TABLE_COLUMNS})
+
optional_fields_map = {field: ('Text', 0) for field in optional_fields}
- fields = dict(default_fields_map, **optional_fields_map)
+ fields = dict(default_fields_map, **optional_fields_map, **child_table_fields_map)
field_map = [frappe._dict({'fieldname': field, 'fieldtype': _type, 'length': _length}) for field, (_type, _length) in fields.items()]
- return field_map
\ No newline at end of file
+ return field_map
+
+def get_table_column(doctype, fieldname):
+ table_columns = frappe.db.get_table_columns_description('tab{}'.format(doctype))
+ return find(table_columns, lambda d: d.get('name') == fieldname)
diff --git a/frappe/tests/test_docstatus.py b/frappe/tests/test_docstatus.py
new file mode 100644
index 0000000000..7692bca48b
--- /dev/null
+++ b/frappe/tests/test_docstatus.py
@@ -0,0 +1,26 @@
+import unittest
+
+from frappe.model.docstatus import DocStatus
+
+
+class TestDocStatus(unittest.TestCase):
+ def test_draft(self):
+ self.assertEqual(DocStatus(0), DocStatus.draft())
+
+ self.assertTrue(DocStatus.draft().is_draft())
+ self.assertFalse(DocStatus.draft().is_cancelled())
+ self.assertFalse(DocStatus.draft().is_submitted())
+
+ def test_submitted(self):
+ self.assertEqual(DocStatus(1), DocStatus.submitted())
+
+ self.assertFalse(DocStatus.submitted().is_draft())
+ self.assertTrue(DocStatus.submitted().is_submitted())
+ self.assertFalse(DocStatus.submitted().is_cancelled())
+
+ def test_cancelled(self):
+ self.assertEqual(DocStatus(2), DocStatus.cancelled())
+
+ self.assertFalse(DocStatus.cancelled().is_draft())
+ self.assertFalse(DocStatus.cancelled().is_submitted())
+ self.assertTrue(DocStatus.cancelled().is_cancelled())
diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py
index 34a1dd070c..169d1ebb2c 100644
--- a/frappe/tests/test_document.py
+++ b/frappe/tests/test_document.py
@@ -1,11 +1,20 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
-import os
import unittest
+from contextlib import contextmanager
+from datetime import timedelta
+from unittest.mock import patch
import frappe
-from frappe.utils import cint
-from frappe.model.naming import revert_series_if_last, make_autoname, parse_naming_series
+from frappe.desk.doctype.note.note import Note
+from frappe.model.naming import make_autoname, parse_naming_series, revert_series_if_last
+from frappe.utils import cint, now_datetime
+
+
+class CustomTestNote(Note):
+ @property
+ def age(self):
+ return now_datetime() - self.creation
class TestDocument(unittest.TestCase):
@@ -251,9 +260,80 @@ class TestDocument(unittest.TestCase):
'doctype': 'Test Formatted',
'currency': 100000
})
- self.assertEquals(d.get_formatted('currency', currency='INR', format="#,###.##"), '₹ 100,000.00')
+ self.assertEqual(d.get_formatted('currency', currency='INR', format="#,###.##"), '₹ 100,000.00')
def test_limit_for_get(self):
doc = frappe.get_doc("DocType", "DocType")
- # assuming DocType has more that 3 Data fields
- self.assertEquals(len(doc.get("fields", filters={"fieldtype": "Data"}, limit=3)), 3)
\ No newline at end of file
+ # assuming DocType has more than 3 Data fields
+ self.assertEqual(len(doc.get("fields", limit=3)), 3)
+
+ # limit with filters
+ self.assertEqual(len(doc.get("fields", filters={"fieldtype": "Data"}, limit=3)), 3)
+
+ def test_virtual_fields(self):
+ """Virtual fields are accessible via API and Form views, whenever .as_dict is invoked
+ """
+ frappe.db.delete("Custom Field", {"dt": "Note", "fieldname":"age"})
+ note = frappe.new_doc("Note")
+ note.content = "some content"
+ note.title = frappe.generate_hash(length=20)
+ note.insert()
+
+ def patch_note():
+ return patch("frappe.controllers", new={frappe.local.site: {'Note': CustomTestNote}})
+
+ @contextmanager
+ def customize_note(with_options=False):
+ options = "frappe.utils.now_datetime() - doc.creation" if with_options else ""
+ custom_field = frappe.get_doc({
+ "doctype": "Custom Field",
+ "dt": "Note",
+ "fieldname": "age",
+ "fieldtype": "Data",
+ "read_only": True,
+ "is_virtual": True,
+ "options": options,
+ })
+
+ try:
+ yield custom_field.insert(ignore_if_duplicate=True)
+ finally:
+ custom_field.delete()
+
+ with patch_note():
+ doc = frappe.get_last_doc("Note")
+ self.assertIsInstance(doc, CustomTestNote)
+ self.assertIsInstance(doc.age, timedelta)
+ self.assertIsNone(doc.as_dict().get("age"))
+ self.assertIsNone(doc.get_valid_dict().get("age"))
+
+ with customize_note(), patch_note():
+ doc = frappe.get_last_doc("Note")
+ self.assertIsInstance(doc, CustomTestNote)
+ self.assertIsInstance(doc.age, timedelta)
+ self.assertIsInstance(doc.as_dict().get("age"), timedelta)
+ self.assertIsInstance(doc.get_valid_dict().get("age"), timedelta)
+
+ with customize_note(with_options=True):
+ doc = frappe.get_last_doc("Note")
+ self.assertIsInstance(doc, Note)
+ self.assertIsInstance(doc.as_dict().get("age"), timedelta)
+ self.assertIsInstance(doc.get_valid_dict().get("age"), timedelta)
+
+ def test_run_method(self):
+ doc = frappe.get_last_doc("User")
+
+ # Case 1: Override with a string
+ doc.as_dict = ""
+
+ # run_method should throw TypeError
+ self.assertRaisesRegex(TypeError, "not callable", doc.run_method, "as_dict")
+
+ # Case 2: Override with a function
+ def my_as_dict(*args, **kwargs):
+ return "success"
+
+ doc.as_dict = my_as_dict
+
+ # run_method should get overridden
+ self.assertEqual(doc.run_method("as_dict"), "success")
diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py
index ef9515f5ba..51a0786c2f 100644
--- a/frappe/tests/test_email.py
+++ b/frappe/tests/test_email.py
@@ -1,8 +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 unittest, frappe, re, email
+from frappe.email.doctype.email_account.test_email_account import TestEmailAccount
+
test_dependencies = ['Email Account']
class TestEmail(unittest.TestCase):
@@ -173,12 +175,37 @@ class TestEmail(unittest.TestCase):
frappe.db.delete("Communication", {"sender": "sukh@yyy.com"})
with open(frappe.get_app_path('frappe', 'tests', 'data', 'email_with_image.txt'), 'r') as raw:
- mails = email_account.get_inbound_mails(test_mails=[raw.read()])
+ messages = {
+ '"INBOX"': {
+ 'latest_messages': [
+ raw.read()
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ changed_flag = False
+ if not email_account.enable_incoming:
+ email_account.enable_incoming = True
+ changed_flag = True
+ mails = TestEmailAccount.mocked_get_inbound_mails(email_account, messages)
+
+ # TODO: fix this flaky test! - 'IndexError: list index out of range' for `.process()` line
+ if not mails:
+ raise self.skipTest("No inbound mails found / Email Account wasn't patched properly")
+
communication = mails[0].process()
self.assertTrue(re.search(''' ]*src=["']/private/files/rtco1.png[^>]*>''', communication.content))
self.assertTrue(re.search(''' ]*src=["']/private/files/rtco2.png[^>]*>''', communication.content))
+ if changed_flag:
+ email_account.enable_incoming = False
+
if __name__ == '__main__':
frappe.connect()
diff --git a/frappe/tests/test_form_load.py b/frappe/tests/test_form_load.py
index d59e8f1570..92694cf022 100644
--- a/frappe/tests/test_form_load.py
+++ b/frappe/tests/test_form_load.py
@@ -1,9 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import frappe, unittest
-from frappe.desk.form.load import getdoctype, getdoc
+from frappe.desk.form.load import getdoctype, getdoc, get_docinfo
from frappe.core.page.permission_manager.permission_manager import update, reset, add
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
+from frappe.utils.file_manager import save_file
test_dependencies = ['Blog Category', 'Blogger']
@@ -141,9 +142,52 @@ class TestFormLoad(unittest.TestCase):
contact.delete()
+ def test_get_doc_info(self):
+ note = frappe.new_doc("Note")
+ note.content = "some content"
+ note.title = frappe.generate_hash(length=20)
+ note.insert()
+
+ note.content = "new content"
+ # trigger a version
+ note.save(ignore_version=False)
+
+ note.add_comment(text="test")
+
+ note.add_tag("test_tag")
+ note.add_tag("more_tag")
+
+ # empty attachment
+ save_file("test_file", b"", note.doctype, note.name, decode=True)
+
+ frappe.get_doc({
+ "doctype": "Communication",
+ "communication_type": "Communication",
+ "content": "test email",
+ "reference_doctype": note.doctype,
+ "reference_name": note.name,
+ }).insert()
+
+ get_docinfo(note)
+ docinfo = frappe.response["docinfo"]
+
+ self.assertEqual(len(docinfo.comments), 1)
+ self.assertIn("test", docinfo.comments[0].content)
+
+ self.assertGreaterEqual(len(docinfo.versions), 2)
+
+ self.assertEqual(set(docinfo.tags.split(",")), {"more_tag", "test_tag"})
+
+ self.assertEqual(len(docinfo.attachments), 1)
+ self.assertIn("test_file", docinfo.attachments[0].file_name)
+
+ self.assertEqual(len(docinfo.communications), 1)
+ self.assertIn("email", docinfo.communications[0].content)
+ note.delete()
+
def get_blog(blog_name):
frappe.response.docs = []
getdoc('Blog Post', blog_name)
doc = frappe.response.docs[0]
- return doc
\ No newline at end of file
+ return doc
diff --git a/frappe/tests/test_frappe_client.py b/frappe/tests/test_frappe_client.py
index e84163eb41..e4588a16f1 100644
--- a/frappe/tests/test_frappe_client.py
+++ b/frappe/tests/test_frappe_client.py
@@ -1,16 +1,30 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
-import unittest, frappe
-from frappe.core.doctype.user.user import generate_keys
-from frappe.frappeclient import FrappeClient, FrappeException
-from frappe.utils.data import get_url
+import base64
+import unittest
import requests
-import base64
+
+import frappe
+from frappe.core.doctype.user.user import generate_keys
+from frappe.frappeclient import AuthError, FrappeClient, FrappeException
+from frappe.utils.data import get_url
+
class TestFrappeClient(unittest.TestCase):
- PASSWORD = "admin"
+ PASSWORD = frappe.conf.admin_password or "admin"
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ site_url = get_url()
+ try:
+ FrappeClient(site_url, "Administrator", cls.PASSWORD, verify=False)
+ except AuthError:
+ raise unittest.SkipTest(f"AuthError raised for {site_url} [usr=Administrator, pwd={cls.PASSWORD}]")
+
+ return super().setUpClass()
+
def test_insert_many(self):
server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False)
frappe.db.delete("Note", {"title": ("in", ('Sing','a','song','of','sixpence'))})
@@ -169,7 +183,6 @@ class TestFrappeClient(unittest.TestCase):
res = requests.post(get_url() + "/api/method/frappe.auth.get_logged_user", headers=header)
self.assertEqual(res.status_code, 403)
-
# random api key and api secret
api_key = "@3djdk3kld"
api_secret = "ksk&93nxoe3os"
diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py
index 3031d3e344..0c5387ccf2 100644
--- a/frappe/tests/test_naming.py
+++ b/frappe/tests/test_naming.py
@@ -10,11 +10,11 @@ from frappe.model.naming import append_number_if_name_exists, revert_series_if_l
from frappe.model.naming import determine_consecutive_week_number, parse_naming_series
class TestNaming(unittest.TestCase):
+ def setUp(self):
+ frappe.db.delete('Note')
+
def tearDown(self):
- # Reset ToDo autoname to hash
- todo_doctype = frappe.get_doc('DocType', 'ToDo')
- todo_doctype.autoname = 'hash'
- todo_doctype.save()
+ frappe.db.rollback()
def test_append_number_if_name_exists(self):
'''
@@ -144,6 +144,7 @@ class TestNaming(unittest.TestCase):
current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
self.assertEqual(current_index.get('current'), 2)
+
frappe.db.delete("Series", {"name": series})
def test_naming_for_cancelled_and_amended_doc(self):
@@ -166,25 +167,20 @@ class TestNaming(unittest.TestCase):
doc.submit()
doc.cancel()
cancelled_name = doc.name
- self.assertEqual(cancelled_name, "{}-CANC-0".format(original_name))
+ self.assertEqual(cancelled_name, original_name)
amended_doc = frappe.copy_doc(doc)
amended_doc.docstatus = 0
amended_doc.amended_from = doc.name
amended_doc.save()
- self.assertEqual(amended_doc.name, original_name)
+ self.assertEqual(amended_doc.name, "{}-1".format(original_name))
amended_doc.submit()
amended_doc.cancel()
- self.assertEqual(amended_doc.name, "{}-CANC-1".format(original_name))
+ self.assertEqual(amended_doc.name, "{}-1".format(original_name))
submittable_doctype.delete()
- def test_parse_naming_series_for_consecutive_week_number(self):
- week = determine_consecutive_week_number(now_datetime())
- name = parse_naming_series('PREFIX-.WW.-SUFFIX')
- expected_name = 'PREFIX-{}-SUFFIX'.format(week)
- self.assertEqual(name, expected_name)
def test_determine_consecutive_week_number(self):
from datetime import datetime
@@ -208,3 +204,61 @@ class TestNaming(unittest.TestCase):
dt = datetime.fromisoformat("2021-12-31")
w = determine_consecutive_week_number(dt)
self.assertEqual(w, "52")
+
+ def test_naming_validations(self):
+ # case 1: check same name as doctype
+ # set name via prompt
+ tag = frappe.get_doc({
+ 'doctype': 'Tag',
+ '__newname': 'Tag'
+ })
+ self.assertRaises(frappe.NameError, tag.insert)
+
+ # set by passing set_name as ToDo
+ self.assertRaises(frappe.NameError, make_invalid_todo)
+
+ # set new name - Note
+ note = frappe.get_doc({
+ 'doctype': 'Note',
+ 'title': 'Note'
+ })
+ self.assertRaises(frappe.NameError, note.insert)
+
+ # case 2: set name with "New ---"
+ tag = frappe.get_doc({
+ 'doctype': 'Tag',
+ '__newname': 'New Tag'
+ })
+ self.assertRaises(frappe.NameError, tag.insert)
+
+ # case 3: set name with special characters
+ tag = frappe.get_doc({
+ 'doctype': 'Tag',
+ '__newname': 'Tag<>'
+ })
+ self.assertRaises(frappe.NameError, tag.insert)
+
+ # case 4: no name specified
+ tag = frappe.get_doc({
+ 'doctype': 'Tag',
+ '__newname': ''
+ })
+ self.assertRaises(frappe.ValidationError, tag.insert)
+
+ def test_autoincremented_naming(self):
+ from frappe.core.doctype.doctype.test_doctype import new_doctype
+
+ doctype = "autoinc_doctype" + frappe.generate_hash(length=5)
+ dt = new_doctype(doctype, autoincremented=True).insert(ignore_permissions=True)
+
+ for i in range(1, 20):
+ self.assertEqual(frappe.new_doc(doctype).save(ignore_permissions=True).name, i)
+
+ dt.delete(ignore_permissions=True)
+
+
+def make_invalid_todo():
+ frappe.get_doc({
+ 'doctype': 'ToDo',
+ 'description': 'Test'
+ }).insert(set_name='ToDo')
diff --git a/frappe/tests/test_patches.py b/frappe/tests/test_patches.py
index 7f4efc700c..32e7b7ff3a 100644
--- a/frappe/tests/test_patches.py
+++ b/frappe/tests/test_patches.py
@@ -1,7 +1,53 @@
-import unittest, frappe
+import unittest
+import frappe
from frappe.modules import patch_handler
+from unittest.mock import mock_open, patch
+
+
+EMTPY_FILE = ""
+EMTPY_SECTION = """
+[pre_model_sync]
+
+[post_model_sync]
+"""
+FILLED_SECTIONS = """
+[pre_model_sync]
+app.module.patch1
+app.module.patch2
+
+[post_model_sync]
+app.module.patch3
+
+"""
+OLD_STYLE_PATCH_TXT = """
+app.module.patch1
+app.module.patch2
+app.module.patch3
+"""
+
+EDGE_CASES = """
+[pre_model_sync]
+App.module.patch1
+app.module.patch2 # rerun
+execute:frappe.db.updatedb("Item")
+execute:frappe.function(arg="1")
+
+[post_model_sync]
+app.module.patch3
+"""
+
+COMMENTED_OUT = """
+[pre_model_sync]
+app.module.patch1
+# app.module.patch2 # rerun
+app.module.patch3
+
+[post_model_sync]
+app.module.patch4
+"""
+
class TestPatches(unittest.TestCase):
def test_patch_module_names(self):
frappe.flags.final_patches = []
@@ -15,3 +61,74 @@ class TestPatches(unittest.TestCase):
self.assertTrue(frappe.get_attr(patchmodule.split()[0] + ".execute"))
frappe.flags.in_install = False
+
+ def test_get_patch_list(self):
+ pre = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync)
+ post = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync)
+ all_patches = patch_handler.get_patches_from_app("frappe")
+ self.assertGreater(len(pre), 0)
+ self.assertGreater(len(post), 0)
+
+ self.assertEqual(len(all_patches), len(pre) + len(post))
+
+ def test_all_patches_are_marked_completed(self):
+ all_patches = patch_handler.get_patches_from_app("frappe")
+ finished_patches = frappe.db.count("Patch Log")
+
+ self.assertGreaterEqual(finished_patches, len(all_patches))
+
+
+
+class TestPatchReader(unittest.TestCase):
+
+ def get_patches(self):
+ return (
+ patch_handler.get_patches_from_app("frappe"),
+ patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync),
+ patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync)
+ )
+
+ @patch("builtins.open", new_callable=mock_open, read_data=EMTPY_FILE)
+ def test_empty_file(self, _file):
+ all, pre, post = self.get_patches()
+ self.assertEqual(all, [])
+ self.assertEqual(pre, [])
+ self.assertEqual(post, [])
+
+
+ @patch("builtins.open", new_callable=mock_open, read_data=EMTPY_SECTION)
+ def test_empty_sections(self, _file):
+ all, pre, post = self.get_patches()
+ self.assertEqual(all, [])
+ self.assertEqual(pre, [])
+ self.assertEqual(post, [])
+
+ @patch("builtins.open", new_callable=mock_open, read_data=FILLED_SECTIONS)
+ def test_new_style(self, _file):
+ all, pre, post = self.get_patches()
+ self.assertEqual(all, ["app.module.patch1", "app.module.patch2", "app.module.patch3"])
+ self.assertEqual(pre, ["app.module.patch1", "app.module.patch2"])
+ self.assertEqual(post, ["app.module.patch3",])
+
+ @patch("builtins.open", new_callable=mock_open, read_data=OLD_STYLE_PATCH_TXT)
+ def test_old_style(self, _file):
+ all, pre, post = self.get_patches()
+ self.assertEqual(all, ["app.module.patch1", "app.module.patch2", "app.module.patch3"])
+ self.assertEqual(pre, ["app.module.patch1", "app.module.patch2", "app.module.patch3"])
+ self.assertEqual(post, [])
+
+
+ @patch("builtins.open", new_callable=mock_open, read_data=EDGE_CASES)
+ def test_new_style_edge_cases(self, _file):
+ all, pre, post = self.get_patches()
+ self.assertEqual(pre, [
+ "App.module.patch1",
+ "app.module.patch2 # rerun",
+ 'execute:frappe.db.updatedb("Item")',
+ 'execute:frappe.function(arg="1")',
+ ])
+
+ @patch("builtins.open", new_callable=mock_open, read_data=COMMENTED_OUT)
+ def test_ignore_comments(self, _file):
+ all, pre, post = self.get_patches()
+ self.assertEqual(pre, ["app.module.patch1", "app.module.patch3"])
diff --git a/frappe/tests/test_permissions.py b/frappe/tests/test_permissions.py
index fdff4d103e..f8ceb5f34c 100644
--- a/frappe/tests/test_permissions.py
+++ b/frappe/tests/test_permissions.py
@@ -4,7 +4,6 @@
import frappe
import frappe.defaults
-import unittest
import frappe.model.meta
from frappe.permissions import (add_user_permission, remove_user_permission,
clear_user_permissions_for_doctype, get_doc_permissions, add_permission, update_permission_property)
@@ -14,9 +13,12 @@ from frappe.core.doctype.user_permission.user_permission import clear_user_permi
from frappe.desk.form.load import getdoc
from frappe.utils.data import now_datetime
+from frappe.tests.utils import FrappeTestCase
+
test_dependencies = ['Blogger', 'Blog Post', "User", "Contact", "Salutation"]
-class TestPermissions(unittest.TestCase):
+
+class TestPermissions(FrappeTestCase):
def setUp(self):
frappe.clear_cache(doctype="Blog Post")
@@ -221,7 +223,7 @@ class TestPermissions(unittest.TestCase):
# check that Document.owner cannot be changed
user.reload()
- user.owner = frappe.db.get_value("User", {"name": ("!=", user.name)})
+ user.owner = "Guest"
self.assertRaises(frappe.CannotChangeConstantError, user.save)
def test_set_only_once(self):
@@ -557,7 +559,6 @@ class TestPermissions(unittest.TestCase):
# Remove delete perm
update('Blog Post', 'Website Manager', 0, 'delete', 0)
-
frappe.clear_cache(doctype="Blog Post")
frappe.set_user("test2@example.com")
diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py
index d2242cc6f7..6b13da067e 100644
--- a/frappe/tests/test_query_builder.py
+++ b/frappe/tests/test_query_builder.py
@@ -3,9 +3,9 @@ from typing import Callable
import frappe
from frappe.query_builder.custom import ConstantColumn
-from frappe.query_builder.functions import Coalesce, GroupConcat, Match
+from frappe.query_builder.functions import Coalesce, GroupConcat, Match, CombineDatetime
from frappe.query_builder.utils import db_type_is
-
+from frappe.query_builder import Case
def run_only_if(dbtype: db_type_is) -> Callable:
return unittest.skipIf(
@@ -25,8 +25,35 @@ class TestCustomFunctionsMariaDB(unittest.TestCase):
)
def test_constant_column(self):
- query = frappe.qb.from_("DocType").select("name", ConstantColumn("John").as_("User"))
- self.assertEqual(query.get_sql(), "SELECT `name`,'John' `User` FROM `tabDocType`")
+ query = frappe.qb.from_("DocType").select(
+ "name", ConstantColumn("John").as_("User")
+ )
+ self.assertEqual(
+ query.get_sql(), "SELECT `name`,'John' `User` FROM `tabDocType`"
+ )
+
+ def test_timestamp(self):
+ note = frappe.qb.DocType("Note")
+ self.assertEqual("TIMESTAMP(posting_date,posting_time)", CombineDatetime(note.posting_date, note.posting_time).get_sql())
+ self.assertEqual("TIMESTAMP('2021-01-01','00:00:21')", CombineDatetime("2021-01-01", "00:00:21").get_sql())
+
+ todo = frappe.qb.DocType("ToDo")
+ select_query = (frappe.qb
+ .from_(note)
+ .join(todo).on(todo.refernce_name == note.name)
+ .select(CombineDatetime(note.posting_date, note.posting_time)))
+ self.assertIn("select timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`)", str(select_query).lower())
+
+ select_query = select_query.orderby(CombineDatetime(note.posting_date, note.posting_time))
+ self.assertIn("order by timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`)", str(select_query).lower())
+
+ select_query = select_query.where(CombineDatetime(note.posting_date, note.posting_time) >= CombineDatetime("2021-01-01", "00:00:01"))
+ self.assertIn("timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`)>=timestamp('2021-01-01','00:00:01')", str(select_query).lower())
+
+ select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp"))
+ self.assertIn("timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`) `timestamp`", str(select_query).lower())
+
+
@run_only_if(db_type_is.POSTGRES)
class TestCustomFunctionsPostgres(unittest.TestCase):
def test_concat(self):
@@ -39,8 +66,37 @@ class TestCustomFunctionsPostgres(unittest.TestCase):
)
def test_constant_column(self):
- query = frappe.qb.from_("DocType").select("name", ConstantColumn("John").as_("User"))
- self.assertEqual(query.get_sql(), 'SELECT "name",\'John\' "User" FROM "tabDocType"')
+ query = frappe.qb.from_("DocType").select(
+ "name", ConstantColumn("John").as_("User")
+ )
+ self.assertEqual(
+ query.get_sql(), 'SELECT "name",\'John\' "User" FROM "tabDocType"'
+ )
+
+ def test_timestamp(self):
+ note = frappe.qb.DocType("Note")
+ self.assertEqual("posting_date+posting_time", CombineDatetime(note.posting_date, note.posting_time).get_sql())
+ self.assertEqual("CAST('2021-01-01' AS DATE)+CAST('00:00:21' AS TIME)", CombineDatetime("2021-01-01", "00:00:21").get_sql())
+
+ todo = frappe.qb.DocType("ToDo")
+ select_query = (frappe.qb
+ .from_(note)
+ .join(todo).on(todo.refernce_name == note.name)
+ .select(CombineDatetime(note.posting_date, note.posting_time)))
+ self.assertIn('select "tabnote"."posting_date"+"tabnote"."posting_time"', str(select_query).lower())
+
+ select_query = select_query.orderby(CombineDatetime(note.posting_date, note.posting_time))
+ self.assertIn('order by "tabnote"."posting_date"+"tabnote"."posting_time"', str(select_query).lower())
+
+ select_query = select_query.where(
+ CombineDatetime(note.posting_date, note.posting_time) >= CombineDatetime('2021-01-01', '00:00:01')
+ )
+ self.assertIn("""where "tabnote"."posting_date"+"tabnote"."posting_time">=cast('2021-01-01' as date)+cast('00:00:01' as time)""",
+ str(select_query).lower())
+
+ select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp"))
+ self.assertIn('"tabnote"."posting_date"+"tabnote"."posting_time" "timestamp"', str(select_query).lower())
+
class TestBuilderBase(object):
def test_adding_tabs(self):
@@ -55,23 +111,95 @@ class TestBuilderBase(object):
self.assertIsInstance(query.run, Callable)
self.assertIsInstance(data, list)
- def test_walk(self):
- DocType = frappe.qb.DocType('DocType')
+
+class TestParameterization(unittest.TestCase):
+ def test_where_conditions(self):
+ DocType = frappe.qb.DocType("DocType")
query = (
frappe.qb.from_(DocType)
.select(DocType.name)
- .where((DocType.owner == "Administrator' --")
- & (Coalesce(DocType.search_fields == "subject"))
- )
+ .where((DocType.owner == "Administrator' --"))
)
self.assertTrue("walk" in dir(query))
query, params = query.walk()
self.assertIn("%(param1)s", query)
- self.assertIn("%(param2)s", query)
- self.assertIn("param1",params)
- self.assertEqual(params["param1"],"Administrator' --")
- self.assertEqual(params["param2"],"subject")
+ self.assertIn("param1", params)
+ self.assertEqual(params["param1"], "Administrator' --")
+
+ def test_set_cnoditions(self):
+ DocType = frappe.qb.DocType("DocType")
+ query = frappe.qb.update(DocType).set(DocType.value, "some_value")
+
+ self.assertTrue("walk" in dir(query))
+ query, params = query.walk()
+
+ self.assertIn("%(param1)s", query)
+ self.assertIn("param1", params)
+ self.assertEqual(params["param1"], "some_value")
+
+ def test_where_conditions_functions(self):
+ DocType = frappe.qb.DocType("DocType")
+ query = (
+ frappe.qb.from_(DocType)
+ .select(DocType.name)
+ .where(Coalesce(DocType.search_fields == "subject"))
+ )
+
+ self.assertTrue("walk" in dir(query))
+ query, params = query.walk()
+
+ self.assertIn("%(param1)s", query)
+ self.assertIn("param1", params)
+ self.assertEqual(params["param1"], "subject")
+
+ def test_case(self):
+ DocType = frappe.qb.DocType("DocType")
+ query = (
+ frappe.qb.from_(DocType)
+ .select(
+ Case()
+ .when(DocType.search_fields == "value", "other_value")
+ .when(Coalesce(DocType.search_fields == "subject_in_function"), "true_value")
+ .else_("Overdue")
+ )
+ )
+
+ self.assertTrue("walk" in dir(query))
+ query, params = query.walk()
+
+ self.assertIn("%(param1)s", query)
+ self.assertIn("param1", params)
+ self.assertEqual(params["param1"], "value")
+ self.assertEqual(params["param2"], "other_value")
+ self.assertEqual(params["param3"], "subject_in_function")
+ self.assertEqual(params["param4"], "true_value")
+ self.assertEqual(params["param5"], "Overdue")
+
+ def test_case_in_update(self):
+ DocType = frappe.qb.DocType("DocType")
+ query = (
+ frappe.qb.update(DocType)
+ .set(
+ "parent",
+ Case()
+ .when(DocType.search_fields == "value", "other_value")
+ .when(Coalesce(DocType.search_fields == "subject_in_function"), "true_value")
+ .else_("Overdue")
+ )
+ )
+
+ self.assertTrue("walk" in dir(query))
+ query, params = query.walk()
+
+ self.assertIn("%(param1)s", query)
+ self.assertIn("param1", params)
+ self.assertEqual(params["param1"], "value")
+ self.assertEqual(params["param2"], "other_value")
+ self.assertEqual(params["param3"], "subject_in_function")
+ self.assertEqual(params["param4"], "true_value")
+ self.assertEqual(params["param5"], "Overdue")
+
@run_only_if(db_type_is.MARIADB)
@@ -84,6 +212,7 @@ class TestBuilderMaria(unittest.TestCase, TestBuilderBase):
"SELECT * FROM `__Auth`", frappe.qb.from_("__Auth").select("*").get_sql()
)
+
@run_only_if(db_type_is.POSTGRES)
class TestBuilderPostgres(unittest.TestCase, TestBuilderBase):
def test_adding_tabs_in_from(self):
diff --git a/frappe/tests/test_query_report.py b/frappe/tests/test_query_report.py
index 656894fc9b..2117bc830e 100644
--- a/frappe/tests/test_query_report.py
+++ b/frappe/tests/test_query_report.py
@@ -12,37 +12,30 @@ class TestQueryReport(unittest.TestCase):
def test_xlsx_data_with_multiple_datatypes(self):
"""Test exporting report using rows with multiple datatypes (list, dict)"""
- # Describe the columns
- columns = {
- 0: {"label": "Column A", "fieldname": "column_a"},
- 1: {"label": "Column B", "fieldname": "column_b"},
- 2: {"label": "Column C", "fieldname": "column_c"}
- }
-
# Create mock data
data = frappe._dict()
data.columns = [
- {"label": "Column A", "fieldname": "column_a"},
- {"label": "Column B", "fieldname": "column_b", "width": 150},
- {"label": "Column C", "fieldname": "column_c", "width": 100}
+ {"label": "Column A", "fieldname": "column_a", "fieldtype": "Float"},
+ {"label": "Column B", "fieldname": "column_b", "width": 100, "fieldtype": "Float"},
+ {"label": "Column C", "fieldname": "column_c", "width": 150, "fieldtype": "Duration"},
]
data.result = [
- [1.0, 3.0, 5.5],
- {"column_a": 22.1, "column_b": 21.8, "column_c": 30.2},
- {"column_b": 5.1, "column_c": 9.5, "column_a": 11.1},
- [3.0, 1.5, 7.5],
+ [1.0, 3.0, 600],
+ {"column_a": 22.1, "column_b": 21.8, "column_c": 86412},
+ {"column_b": 5.1, "column_c": 53234, "column_a": 11.1},
+ [3.0, 1.5, 333],
]
# Define the visible rows
visible_idx = [0, 2, 3]
# Build the result
- xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation=0)
+ xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation=0)
self.assertEqual(type(xlsx_data), list)
self.assertEqual(len(xlsx_data), 4) # columns + data
# column widths are divided by 10 to match the scale that is supported by openpyxl
- self.assertListEqual(column_widths, [0, 15, 10])
+ self.assertListEqual(column_widths, [0, 10, 15])
for row in xlsx_data:
self.assertEqual(type(row), list)
diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py
index 58cc5bb125..0c1cba31fb 100644
--- a/frappe/tests/test_rename_doc.py
+++ b/frappe/tests/test_rename_doc.py
@@ -1,13 +1,40 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
+
import os
import unittest
+from contextlib import contextmanager, redirect_stdout
+from io import StringIO
+from random import choice, sample
+from typing import List
+from unittest.mock import patch
import frappe
-from frappe.utils import add_to_date, now
-from frappe.exceptions import DoesNotExistError
-
-from random import choice, sample
+from frappe.exceptions import DoesNotExistError, ValidationError
from frappe.model.base_document import get_controller
+from frappe.model.rename_doc import bulk_rename, get_fetch_fields, update_document_title, update_linked_doctypes
from frappe.modules.utils import get_doc_path
+from frappe.utils import add_to_date, now
+
+
+@contextmanager
+def patch_db(endpoints: List[str] = None):
+ patched_endpoints = []
+
+ for point in endpoints:
+ x = patch(f"frappe.db.{point}", new=lambda: True)
+ patched_endpoints.append(x)
+
+ savepoint = "SAVEPOINT_for_test_bulk_rename"
+ frappe.db.savepoint(save_point=savepoint)
+ try:
+ for x in patched_endpoints:
+ x.start()
+ yield
+ finally:
+ for x in patched_endpoints:
+ x.stop()
+ frappe.db.rollback(save_point=savepoint)
class TestRenameDoc(unittest.TestCase):
@@ -50,6 +77,11 @@ class TestRenameDoc(unittest.TestCase):
@classmethod
def tearDownClass(self):
"""Deleting data generated for the tests defined under TestRenameDoc"""
+ # delete_doc doesnt drop tables
+ # this is done to bypass inconsistencies in the db
+ frappe.delete_doc_if_exists("DocType", "Renamed Doc")
+ frappe.db.sql_ddl("drop table if exists `tabRenamed Doc`")
+
# delete the documents created
for docname in self.available_documents:
frappe.delete_doc(self.test_doctype, docname)
@@ -153,7 +185,55 @@ class TestRenameDoc(unittest.TestCase):
new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True)
)
- # delete_doc doesnt drop tables
- # this is done to bypass inconsistencies in the db
- frappe.delete_doc_if_exists("DocType", "Renamed Doc")
- frappe.db.sql_ddl("drop table if exists `tabRenamed Doc`")
+ def test_update_document_title_api(self):
+ test_doctype = "Module Def"
+ test_doc = frappe.get_doc({
+ "doctype": test_doctype,
+ "module_name": f"Test-test_update_document_title_api-{frappe.generate_hash()}",
+ "custom": True,
+ })
+ test_doc.insert(ignore_mandatory=True)
+
+ dt = test_doc.doctype
+ dn = test_doc.name
+ new_name = f"{dn}-new"
+
+ # pass invalid types to API
+ with self.assertRaises(ValidationError):
+ update_document_title(doctype=dt, docname=dn, title={}, name={"hack": "this"})
+
+ doc_before = frappe.get_doc(test_doctype, dn)
+ return_value = update_document_title(doctype=dt, docname=dn, new_name=new_name)
+ doc_after = frappe.get_doc(test_doctype, return_value)
+
+ doc_before_dict = doc_before.as_dict(no_nulls=True, no_default_fields=True)
+ doc_after_dict = doc_after.as_dict(no_nulls=True, no_default_fields=True)
+ doc_before_dict.pop("module_name")
+ doc_after_dict.pop("module_name")
+
+ self.assertEqual(new_name, return_value)
+ self.assertDictEqual(doc_before_dict, doc_after_dict)
+ self.assertEqual(doc_after.module_name, return_value)
+
+ test_doc.delete()
+
+ def test_bulk_rename(self):
+ input_data = [[x, f"{x}-new"] for x in self.available_documents]
+
+ with patch_db(["commit", "rollback"]), patch("frappe.enqueue") as enqueue:
+ message_log = bulk_rename(self.test_doctype, input_data, via_console=False)
+ self.assertEqual(len(message_log), len(self.available_documents))
+ self.assertIsInstance(message_log, list)
+ enqueue.assert_called_with(
+ 'frappe.utils.global_search.rebuild_for_doctype', doctype=self.test_doctype,
+ )
+
+ def test_deprecated_utils(self):
+ stdout = StringIO()
+
+ with redirect_stdout(stdout), patch_db(["set_value"]):
+ get_fetch_fields("User", "ToDo", ["Activity Log"])
+ self.assertTrue("Function frappe.model.rename_doc.get_fetch_fields" in stdout.getvalue())
+
+ update_linked_doctypes("User", "ToDo", "str", "str")
+ self.assertTrue("Function frappe.model.rename_doc.update_linked_doctypes" in stdout.getvalue())
diff --git a/frappe/tests/test_search.py b/frappe/tests/test_search.py
index f644f2dfcc..38a00f689a 100644
--- a/frappe/tests/test_search.py
+++ b/frappe/tests/test_search.py
@@ -70,10 +70,10 @@ class TestSearch(unittest.TestCase):
result = frappe.response['results']
# Check whether the result is sorted or not
- self.assertEquals(self.parent_doctype_name, result[0]['value'])
+ self.assertEqual(self.parent_doctype_name, result[0]['value'])
# Check whether searching for parent also list out children
- self.assertEquals(len(result), len(self.child_doctypes_names) + 1)
+ self.assertEqual(len(result), len(self.child_doctypes_names) + 1)
#Search for the word "pay", part of the word "pays" (country) in french.
def test_link_search_in_foreign_language(self):
diff --git a/frappe/tests/test_twofactor.py b/frappe/tests/test_twofactor.py
index 72c29cd47b..fadc61a551 100644
--- a/frappe/tests/test_twofactor.py
+++ b/frappe/tests/test_twofactor.py
@@ -222,9 +222,10 @@ def disable_2fa():
def toggle_2fa_all_role(state=None):
'''Enable or disable 2fa for 'all' role on the system.'''
all_role = frappe.get_doc('Role','All')
- if state == None:
- state = False if all_role.two_factor_auth == True else False
- if state not in [True, False]: return
+ state = state if state is not None else False
+ if type(state) != bool:
+ return
+
all_role.two_factor_auth = cint(state)
all_role.save(ignore_permissions=True)
frappe.db.commit()
diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py
index 5c1541e0de..18fca9de8c 100644
--- a/frappe/tests/test_utils.py
+++ b/frappe/tests/test_utils.py
@@ -1,22 +1,28 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
-import unittest
-import frappe
-from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url
-from frappe.utils import validate_url, validate_email_address
-from frappe.utils import ceil, floor
-from frappe.utils.data import cast, validate_python_code
-from frappe.utils.diff import get_version_diff, version_query, _get_value_from_version
-
-from PIL import Image
-from frappe.utils.image import strip_exif_data, optimize_image
import io
+import json
+import unittest
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
+from enum import Enum
from mimetypes import guess_type
-from datetime import datetime, timedelta, date
-
from unittest.mock import patch
+import pytz
+from PIL import Image
+
+import frappe
+from frappe.utils import ceil, evaluate_filters, floor, format_timedelta
+from frappe.utils import get_url, money_in_words, parse_timedelta, scrub_urls
+from frappe.utils import validate_email_address, validate_url
+from frappe.utils.data import cast, get_time, get_timedelta, nowtime, now_datetime, validate_python_code
+from frappe.utils.diff import _get_value_from_version, get_version_diff, version_query
+from frappe.utils.image import optimize_image, strip_exif_data
+from frappe.utils.response import json_handler
+
+
class TestFilters(unittest.TestCase):
def test_simple_dict(self):
self.assertTrue(evaluate_filters({'doctype': 'User', 'status': 'Open'}, {'status': 'Open'}))
@@ -273,9 +279,7 @@ class TestPythonExpressions(unittest.TestCase):
for expr in invalid_expressions:
self.assertRaises(frappe.ValidationError, validate_python_code, expr)
-
class TestDiffUtils(unittest.TestCase):
-
@classmethod
def setUpClass(cls):
cls.doc = frappe.get_doc(doctype="Client Script", dt="Client Script")
@@ -328,4 +332,181 @@ class TestDateUtils(unittest.TestCase):
self.assertEqual(frappe.utils.get_last_day_of_week("2020-12-24"),
frappe.utils.getdate("2020-12-26"))
self.assertEqual(frappe.utils.get_last_day_of_week("2020-12-28"),
- frappe.utils.getdate("2021-01-02"))
\ No newline at end of file
+ frappe.utils.getdate("2021-01-02"))
+
+ def test_get_time(self):
+ datetime_input = now_datetime()
+ timedelta_input = get_timedelta()
+ time_input = nowtime()
+
+ self.assertIsInstance(get_time(datetime_input), time)
+ self.assertIsInstance(get_time(timedelta_input), time)
+ self.assertIsInstance(get_time(time_input), time)
+ self.assertIsInstance(get_time("100:2:12"), time)
+ self.assertIsInstance(get_time(str(datetime_input)), time)
+ self.assertIsInstance(get_time(str(timedelta_input)), time)
+ self.assertIsInstance(get_time(str(time_input)), time)
+
+ def test_get_timedelta(self):
+ datetime_input = now_datetime()
+ timedelta_input = get_timedelta()
+ time_input = nowtime()
+
+ self.assertIsInstance(get_timedelta(), timedelta)
+ self.assertIsInstance(get_timedelta("100:2:12"), timedelta)
+ self.assertIsInstance(get_timedelta("17:21:00"), timedelta)
+ self.assertIsInstance(get_timedelta("2012-01-19 17:21:00"), timedelta)
+ self.assertIsInstance(get_timedelta(str(datetime_input)), timedelta)
+ self.assertIsInstance(get_timedelta(str(timedelta_input)), timedelta)
+ self.assertIsInstance(get_timedelta(str(time_input)), timedelta)
+
+class TestResponse(unittest.TestCase):
+ def test_json_handler(self):
+ class TEST(Enum):
+ ABC = "!@)@)!"
+ BCE = "ENJD"
+
+ GOOD_OBJECT = {
+ "time_types": [
+ date(year=2020, month=12, day=2),
+ datetime(year=2020, month=12, day=2, hour=23, minute=23, second=23, microsecond=23, tzinfo=pytz.utc),
+ time(hour=23, minute=23, second=23, microsecond=23, tzinfo=pytz.utc),
+ timedelta(days=10, hours=12, minutes=120, seconds=10),
+ ],
+ "float": [
+ Decimal(29.21),
+ ],
+ "doc": [
+ frappe.get_doc("System Settings"),
+ ],
+ "iter": [
+ {1, 2, 3},
+ (1, 2, 3),
+ "abcdef",
+ ],
+ "string": "abcdef"
+ }
+
+ BAD_OBJECT = {"Enum": TEST}
+
+ processed_object = json.loads(json.dumps(GOOD_OBJECT, default=json_handler))
+
+ self.assertTrue(all([isinstance(x, str) for x in processed_object["time_types"]]))
+ self.assertTrue(all([isinstance(x, float) for x in processed_object["float"]]))
+ self.assertTrue(all([isinstance(x, (list, str)) for x in processed_object["iter"]]))
+ self.assertIsInstance(processed_object["string"], str)
+ with self.assertRaises(TypeError):
+ json.dumps(BAD_OBJECT, default=json_handler)
+
+class TestTimeDeltaUtils(unittest.TestCase):
+ def test_format_timedelta(self):
+ self.assertEqual(format_timedelta(timedelta(seconds=0)), "0:00:00")
+ self.assertEqual(format_timedelta(timedelta(hours=10)), "10:00:00")
+ self.assertEqual(format_timedelta(timedelta(hours=100)), "100:00:00")
+ self.assertEqual(format_timedelta(timedelta(seconds=100, microseconds=129)), "0:01:40.000129")
+ self.assertEqual(format_timedelta(timedelta(seconds=100, microseconds=12212199129)), "3:25:12.199129")
+
+ def test_parse_timedelta(self):
+ self.assertEqual(parse_timedelta("0:0:0"), timedelta(seconds=0))
+ self.assertEqual(parse_timedelta("10:0:0"), timedelta(hours=10))
+ self.assertEqual(parse_timedelta("7 days, 0:32:18.192221"), timedelta(days=7, seconds=1938, microseconds=192221))
+ self.assertEqual(parse_timedelta("7 days, 0:32:18"), timedelta(days=7, seconds=1938))
+
+class TestXlsxUtils(unittest.TestCase):
+ def test_unescape(self):
+ from frappe.utils.xlsxutils import handle_html
+
+ val = handle_html(" html data >
")
+ self.assertIn("html data >", val)
+ self.assertEqual("abc", handle_html("abc"))
+
+
+class TestLinkTitle(unittest.TestCase):
+ def test_link_title_doctypes_in_boot_info(self):
+ """
+ Test that doctypes are added to link_title_map in boot_info
+ """
+ custom_doctype = frappe.get_doc(
+ {
+ "doctype": "DocType",
+ "module": "Core",
+ "custom": 1,
+ "fields": [
+ {
+ "label": "Test Field",
+ "fieldname": "test_title_field",
+ "fieldtype": "Data",
+ }
+ ],
+ "show_title_field_in_link": 1,
+ "title_field": "test_title_field",
+ "permissions": [{"role": "System Manager", "read": 1}],
+ "name": "Test Custom Doctype for Link Title",
+ }
+ )
+ custom_doctype.insert()
+
+ prop_setter = frappe.get_doc(
+ {
+ "doctype": "Property Setter",
+ "doc_type": "User",
+ "property": "show_title_field_in_link",
+ "property_type": "Check",
+ "doctype_or_field": "DocType",
+ "value": "1",
+ }
+ ).insert()
+
+ from frappe.boot import get_link_title_doctypes
+
+ link_title_doctypes = get_link_title_doctypes()
+ self.assertTrue("User" in link_title_doctypes)
+ self.assertTrue("Test Custom Doctype for Link Title" in link_title_doctypes)
+
+ prop_setter.delete()
+ custom_doctype.delete()
+
+ def test_link_titles_on_getdoc(self):
+ """
+ Test that link titles are added to the doctype on getdoc
+ """
+ prop_setter = frappe.get_doc(
+ {
+ "doctype": "Property Setter",
+ "doc_type": "User",
+ "property": "show_title_field_in_link",
+ "property_type": "Check",
+ "doctype_or_field": "DocType",
+ "value": "1",
+ }
+ ).insert()
+
+ user = frappe.get_doc(
+ {
+ "doctype": "User",
+ "user_type": "Website User",
+ "email": "test_user_for_link_title@example.com",
+ "send_welcome_email": 0,
+ "first_name": "Test User for Link Title",
+ }
+ ).insert(ignore_permissions=True)
+
+ todo = frappe.get_doc(
+ {
+ "doctype": "ToDo",
+ "description": "test-link-title-on-getdoc",
+ "allocated_to": user.name,
+ }
+ ).insert()
+
+ from frappe.desk.form.load import getdoc
+
+ getdoc("ToDo", todo.name)
+ link_titles = frappe.local.response["_link_titles"]
+
+ self.assertTrue(f"{user.doctype}::{user.name}" in link_titles)
+ self.assertEqual(link_titles[f"{user.doctype}::{user.name}"], user.full_name)
+
+ todo.delete()
+ user.delete()
+ prop_setter.delete()
diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py
index e40a07c0ec..97b9fc9b67 100644
--- a/frappe/tests/test_website.py
+++ b/frappe/tests/test_website.py
@@ -1,7 +1,9 @@
import unittest
+from unittest.mock import patch
import frappe
from frappe.utils import set_request
+from frappe.website.page_renderers.static_page import StaticPage
from frappe.website.serve import get_response, get_response_content
from frappe.website.utils import (build_response, clear_website_cache, get_home_page)
@@ -20,6 +22,7 @@ class TestWebsite(unittest.TestCase):
doctype='User',
email='test-user-for-home-page@example.com',
first_name='test')).insert(ignore_if_duplicate=True)
+ user.reload()
role = frappe.get_doc(dict(
doctype = 'Role',
@@ -96,6 +99,19 @@ class TestWebsite(unittest.TestCase):
response = get_response()
self.assertEqual(response.status_code, 200)
+ set_request(method="GET", path="/_test/assets/image.jpg")
+ response = get_response()
+ self.assertEqual(response.status_code, 200)
+
+ set_request(method="GET", path="/_test/assets/image")
+ response = get_response()
+ self.assertEqual(response.status_code, 200)
+
+ with patch.object(StaticPage, "render") as static_render:
+ set_request(method="GET", path="/_test/assets/image")
+ response = get_response()
+ static_render.assert_called()
+
def test_error_page(self):
set_request(method='GET', path='/_test/problematic_page')
response = get_response()
@@ -126,7 +142,6 @@ class TestWebsite(unittest.TestCase):
response = get_response()
self.assertEqual(response.status_code, 404)
-
def test_redirect(self):
import frappe.hooks
frappe.set_user('Administrator')
diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py
index 79868b0b76..311922d0d7 100644
--- a/frappe/tests/ui_test_helpers.py
+++ b/frappe/tests/ui_test_helpers.py
@@ -134,6 +134,16 @@ def create_contact_records():
insert_contact('Test Form Contact 2', '54321')
insert_contact('Test Form Contact 3', '12345')
+@frappe.whitelist()
+def create_multiple_todo_records():
+ values = []
+ if frappe.db.get_all('ToDo', {'description': 'Multiple ToDo 1'}):
+ return
+
+ for index in range(1, 1002):
+ values.append(('100{}'.format(index), 'Multiple ToDo {}'.format(index)))
+
+ frappe.db.bulk_insert('ToDo', fields=['name', 'description'], values=set(values))
def insert_contact(first_name, phone_number):
doc = frappe.get_doc({
@@ -148,9 +158,6 @@ def create_form_tour():
if frappe.db.exists('Form Tour', {'name': 'Test Form Tour'}):
return
- def get_docfield_name(filters):
- return frappe.db.get_value('DocField', filters, "name")
-
tour = frappe.get_doc({
'doctype': 'Form Tour',
'title': 'Test Form Tour',
@@ -161,7 +168,6 @@ def create_form_tour():
"description": "Test Description 1",
"has_next_condition": 1,
"next_step_condition": "eval: doc.first_name",
- "field": get_docfield_name({'parent': 'Contact', 'fieldname': 'first_name'}),
"fieldname": "first_name",
"fieldtype": "Data"
},{
@@ -169,21 +175,18 @@ def create_form_tour():
"description": "Test Description 2",
"has_next_condition": 1,
"next_step_condition": "eval: doc.last_name",
- "field": get_docfield_name({'parent': 'Contact', 'fieldname': 'last_name'}),
"fieldname": "last_name",
"fieldtype": "Data"
},{
"title": "Test Title 3",
"description": "Test Description 3",
- "field": get_docfield_name({'parent': 'Contact', 'fieldname': 'phone_nos'}),
"fieldname": "phone_nos",
"fieldtype": "Table"
},{
"title": "Test Title 4",
"description": "Test Description 4",
"is_table_field": 1,
- "parent_field": get_docfield_name({'parent': 'Contact', 'fieldname': 'phone_nos'}),
- "field": get_docfield_name({'parent': 'Contact Phone', 'fieldname': 'phone'}),
+ "parent_fieldname": "phone_nos",
"next_step_condition": "eval: doc.phone",
"has_next_condition": 1,
"fieldname": "phone",
@@ -248,10 +251,68 @@ def create_topic_and_reply(web_page):
@frappe.whitelist()
def update_webform_to_multistep():
- doc = frappe.get_doc("Web Form", "edit-profile")
- _doc = frappe.copy_doc(doc)
- _doc.is_multi_step_form = 1
- _doc.title = "update-profile-duplicate"
- _doc.route = "update-profile-duplicate"
- _doc.is_standard = False
- _doc.save()
+ if not frappe.db.exists("Web Form", "update-profile-duplicate"):
+ doc = frappe.get_doc("Web Form", "edit-profile")
+ _doc = frappe.copy_doc(doc)
+ _doc.is_multi_step_form = 1
+ _doc.title = "update-profile-duplicate"
+ _doc.route = "update-profile-duplicate"
+ _doc.is_standard = False
+ _doc.save()
+
+@frappe.whitelist()
+def update_child_table(name):
+ doc = frappe.get_doc('DocType', name)
+ if len(doc.fields) == 1:
+ doc.append('fields', {
+ 'fieldname': 'doctype_to_link',
+ 'fieldtype': 'Link',
+ 'in_list_view': 1,
+ 'label': 'Doctype to Link',
+ 'options': 'Doctype to Link'
+ })
+
+ doc.save()
+
+
+@frappe.whitelist()
+def insert_doctype_with_child_table_record(name):
+ if frappe.db.get_all(name, {'title': 'Test Grid Search'}):
+ return
+
+ def insert_child(doc, data, barcode, check, rating, duration, date):
+ doc.append('child_table_1', {
+ 'data': data,
+ 'barcode': barcode,
+ 'check': check,
+ 'rating': rating,
+ 'duration': duration,
+ 'date': date,
+ })
+
+ doc = frappe.new_doc(name)
+ doc.title = 'Test Grid Search'
+ doc.append('child_table', {'title': 'Test Grid Search'})
+
+ insert_child(doc, 'Data', '09709KJKKH2432', 1, 0.5, 266851, "2022-02-21")
+ insert_child(doc, 'Test', '09209KJHKH2432', 1, 0.8, 547877, "2021-05-27")
+ insert_child(doc, 'New', '09709KJHYH1132', 0, 0.1, 3, "2019-03-02")
+ insert_child(doc, 'Old', '09701KJHKH8750', 0, 0, 127455, "2022-01-11")
+ insert_child(doc, 'Alpha', '09204KJHKH2432', 0, 0.6, 364, "2019-12-31")
+ insert_child(doc, 'Delta', '09709KSPIO2432', 1, 0.9, 1242000, "2020-04-21")
+ insert_child(doc, 'Update', '76989KJLVA2432', 0, 1, 183845, "2022-02-10")
+ insert_child(doc, 'Delete', '29189KLHVA1432', 0, 0, 365647, "2021-05-07")
+ insert_child(doc, 'Make', '09689KJHAA2431', 0, 0.3, 24, "2020-11-11")
+ insert_child(doc, 'Create', '09709KLKKH2432', 1, 0.3, 264851, "2021-02-21")
+ insert_child(doc, 'Group', '09209KJLKH2432', 1, 0.8, 537877, "2020-03-15")
+ insert_child(doc, 'Slide', '01909KJHYH1132', 0, 0.5, 9, "2018-03-02")
+ insert_child(doc, 'Drop', '09701KJHKH8750', 1, 0, 127255, "2018-01-01")
+ insert_child(doc, 'Beta', '09204QJHKN2432', 0, 0.6, 354, "2017-12-30")
+ insert_child(doc, 'Flag', '09709KXPIP2432', 1, 0, 1241000, "2021-04-21")
+ insert_child(doc, 'Upgrade', '75989ZJLVA2432', 0.8, 1, 183645, "2020-08-13")
+ insert_child(doc, 'Down', '28189KLHRA1432', 1, 0, 362647, "2020-06-17")
+ insert_child(doc, 'Note', '09689DJHAA2431', 0, 0.1, 29, "2021-09-11")
+ insert_child(doc, 'Click', '08189DJHAA2431', 1, 0.3, 209, "2020-07-04")
+ insert_child(doc, 'Drag', '08189DIHAA2981', 0, 0.7, 342628, "2022-05-04")
+
+ doc.insert()
diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py
new file mode 100644
index 0000000000..8699a51bf8
--- /dev/null
+++ b/frappe/tests/utils.py
@@ -0,0 +1,76 @@
+import copy
+import signal
+import unittest
+from contextlib import contextmanager
+
+import frappe
+
+
+class FrappeTestCase(unittest.TestCase):
+ """Base test class for Frappe tests."""
+ @classmethod
+ def setUpClass(cls) -> None:
+ frappe.db.commit()
+ return super().setUpClass()
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ frappe.db.rollback()
+ return super().tearDownClass()
+
+
+@contextmanager
+def change_settings(doctype, settings_dict):
+ """ A context manager to ensure that settings are changed before running
+ function and restored after running it regardless of exceptions occured.
+ This is useful in tests where you want to make changes in a function but
+ don't retain those changes.
+ import and use as decorator to cover full function or using `with` statement.
+
+ example:
+ @change_settings("Print Settings", {"send_print_as_pdf": 1})
+ def test_case(self):
+ ...
+ """
+
+ try:
+ settings = frappe.get_doc(doctype)
+ # remember setting
+ previous_settings = copy.deepcopy(settings_dict)
+ for key in previous_settings:
+ previous_settings[key] = getattr(settings, key)
+
+ # change setting
+ for key, value in settings_dict.items():
+ setattr(settings, key, value)
+ settings.save()
+ # singles are cached by default, clear to avoid flake
+ frappe.db.value_cache[settings] = {}
+ yield # yield control to calling function
+
+ finally:
+ # restore settings
+ settings = frappe.get_doc(doctype)
+ for key, value in previous_settings.items():
+ setattr(settings, key, value)
+ settings.save()
+
+
+def timeout(seconds=30, error_message="Test timed out."):
+ """ Timeout decorator to ensure a test doesn't run for too long.
+
+ adapted from https://stackoverflow.com/a/2282656"""
+ def decorator(func):
+ def _handle_timeout(signum, frame):
+ raise Exception(error_message)
+
+ def wrapper(*args, **kwargs):
+ signal.signal(signal.SIGALRM, _handle_timeout)
+ signal.alarm(seconds)
+ try:
+ result = func(*args, **kwargs)
+ finally:
+ signal.alarm(0)
+ return result
+ return wrapper
+ return decorator
diff --git a/frappe/translate.py b/frappe/translate.py
index c5ef24bc2a..0367d33d3b 100644
--- a/frappe/translate.py
+++ b/frappe/translate.py
@@ -135,7 +135,7 @@ def get_dict(fortype, name=None):
asset_key = fortype + ":" + (name or "-")
translation_assets = cache.hget("translation_assets", frappe.local.lang, shared=True) or {}
- if not asset_key in translation_assets:
+ if asset_key not in translation_assets:
messages = []
if fortype=="doctype":
messages = get_messages_from_doctype(name)
@@ -206,7 +206,7 @@ def make_dict_from_messages(messages, full_dict=None, load_user_translation=True
:param messages: List of untranslated messages
"""
out = {}
- if full_dict==None:
+ if full_dict is None:
if load_user_translation:
full_dict = get_full_dict(frappe.local.lang)
else:
@@ -576,13 +576,15 @@ def get_server_messages(app):
def get_messages_from_include_files(app_name=None):
"""Returns messages from js files included at time of boot like desk.min.js for desk and web"""
+ from frappe.utils.jinja_globals import bundled_asset
messages = []
app_include_js = frappe.get_hooks("app_include_js", app_name=app_name) or []
web_include_js = frappe.get_hooks("web_include_js", app_name=app_name) or []
include_js = app_include_js + web_include_js
for js_path in include_js:
- relative_path = os.path.join(frappe.local.sites_path, js_path.lstrip('/'))
+ file_path = bundled_asset(js_path)
+ relative_path = os.path.join(frappe.local.sites_path, file_path.lstrip('/'))
messages_from_file = get_messages_from_file(relative_path)
messages.extend(messages_from_file)
@@ -648,8 +650,6 @@ def extract_messages_from_code(code):
if isinstance(e, InvalidIncludePath):
frappe.clear_last_message()
- pass
-
messages = []
pattern = r"_\(([\"']{,3})(?P
((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P((?!\5).)*)\5)*(\s*,\s*(.)*?\s*(,\s*([\"'])(?P((?!\11).)*)\11)*)*\)"
diff --git a/frappe/translations/de.csv b/frappe/translations/de.csv
index 1dc542f55d..1131a8b5d7 100644
--- a/frappe/translations/de.csv
+++ b/frappe/translations/de.csv
@@ -91,6 +91,7 @@ Field Name,Feldname,
Fieldname,Feldname,
Fields,Felder,
First Name,Vorname,
+First Day of the Week,Erster Wochentag,
Frequency,Häufigkeit,
Friday,Freitag,
From,Von,
@@ -145,11 +146,14 @@ Monthly,Monatlich,
More,Weiter,
More Information,Mehr Informationen,
More...,Mehr...,
-Move,Bewegen,
+Move,Verschieben,
My Account,Mein Konto,
+My Profile,Mein Profil,
+My Settings,Meine Einstellungen,
New Address,Neue Adresse,
New Contact,Neuer Kontakt,
Next,Weiter,
+No,Nein,
No Data,Keine Daten,
No address added yet.,Noch keine Adresse hinzugefügt.,
No contacts added yet.,Noch keine Kontakte hinzugefügt.,
@@ -171,7 +175,7 @@ Payment Gateway,Zahlungs-Gateways,
Payment Gateway Name,Name des Zahlungsgateways,
Payments,Zahlungen,
Period,Periode,
-Pincode,Postleitzahl (PLZ),
+Pincode,Postleitzahl,
Plan Name,Planname,
Please enable pop-ups,Bitte Pop-ups aktivieren,
Please select Company,Bitte Unternehmen auswählen,
@@ -348,7 +352,7 @@ Add a New Role,Neue Rolle hinzufügen,
Add a column,Spalte einfügen,
Add a comment,Einen Kommentar hinzufügen,
Add a new section,Fügen Sie einen neuen Abschnitt hinzu,
-Add a tag ...,Füge einen Tag hinzu ...,
+Add a tag ...,Füge ein Schlagwort hinzu ...,
Add all roles,Alle Rollen hinzufügen,
Add custom forms.,Benutzerdefinierte Formulare hinzufügen,
Add custom javascript to forms.,Benutzerdefiniertes Javascript zum Formular hinzufügen,
@@ -404,7 +408,7 @@ Allow Self Approval,Erlaube Selbstgenehmigung,
Allow approval for creator of the document,Genehmigung für den Ersteller des Dokuments zulassen,
Allow events in timeline,Ereignisse in der Zeitleiste zulassen,
Allow in Quick Entry,In Schnelleingabe zulassen,
-Allow on Submit,Beim Übertragen zulassen,
+Allow on Submit,Änderungen zulassen wenn gebucht,
Allow only one session per user,Nur eine Sitzung pro Benutzer zulassen,
Allow page break inside tables,Seitenumbruch innerhalb von Tabellen erlauben,
Allow saving if mandatory fields are not filled,Speichern trotz leerer Pflichtfelder zulassen,
@@ -732,6 +736,7 @@ Content Hash,Inhalts-Hash,
Content web page.,Inhalt der Webseite.,
Conversation Tones,Konversationstöne,
Copyright,Copyright,
+Copy to Clipboard,In die Zwischenablage,
Core,Kern,
Core DocTypes cannot be customized.,Core DocTypes können nicht angepasst werden.,
Could not connect to outgoing email server,Konnte keine Verbindung zum Postausgangsserver herstellen,
@@ -945,6 +950,7 @@ Edit Auto Email Report Settings,Bearbeiten Sie die Einstellungen für automatisc
Edit Custom HTML,Benutzerdefiniertes HTML bearbeiten,
Edit DocType,DocType bearbeiten,
Edit Filter,Filter bearbeiten,
+Edit Filters,Filter bearbeiten,
Edit Format,Format bearbeiten,
Edit HTML,HTML bearbeiten,
Edit Heading,Kopf bearbeiten,
@@ -954,6 +960,7 @@ Edit {0},Bearbeiten {0},
Editable Grid,Editierbares Raster,
Editing Row,Zeile bearbeiten,
Eg. smsgateway.com/api/send_sms.cgi,z. B. smsgateway.com/api/send_sms.cgi,
+Email,E-Mail,
Email Account Name,E-Mail-Konten-Name,
Email Account added multiple times,E-Mail-Konto wurde mehrmals hinzugefügt,
Email Addresses,E-Mail-Adressen,
@@ -1219,8 +1226,8 @@ Headers,Headers,
Heading,Überschrift,
Hello {0},Hallo {0},
Hello!,Hallo!,
-Help Articles,Artikel-Hilfe,
-Help Category,Kategorie-Hilfe,
+Help Articles,Hilfeartikel,
+Help Category,Hilfekategorie,
Help on Search,Hilfe zur Suche,
"Help: To link to another record in the system, use ""#Form/Note/[Note Name]"" as the Link URL. (don't use ""http://"")","Hilfe: Um eine Verknüpfung mit einem anderen Datensatz im System zu erstellen, bitte ""#Formular/Anmerkung/[Anmerkungsname]"" als Verknüpfungs-URL verwenden (kein ""http://""!).",
Helvetica,Helvetica,
@@ -1229,6 +1236,7 @@ Hide Copy,Kopie ausblenden,
Hide Footer Signup,Fußzeilen-Anmeldung ausblenden,
Hide Sidebar and Menu,Seitenleiste und Menü ausblenden,
Hide Standard Menu,Standardmenü ausblenden,
+Hide Tags,Schlagworte ausblenden,
Hide Weekends,Wochenenden ausblenden,
Hide details,Details ausblenden,
Hide footer in auto email reports,Fußzeile in automatischen E-Mail-Berichten ausblenden,
@@ -1393,6 +1401,7 @@ Is Spam,ist Spam,
Is Standard,Ist Standard,
Is Submittable,Ist übertragbar,
Is Table,ist eine Tabelle,
+Is Template, Ist Vorlage,
Is Your Company Address,Ist Ihre Unternehmensadresse,
It is risky to delete this file: {0}. Please contact your System Manager.,"Es ist riskant, diese Datei zu löschen: {0}. Bitte kontaktieren Sie Ihren System-Manager.",
Item cannot be added to its own descendents,Artikel kann nicht zu seinen eigenen Abkömmlingen hinzugefügt werden,
@@ -1446,6 +1455,7 @@ Last User,Letzter Benutzer,
Last Week,Letzte Woche,
Last Year,Vergangenes Jahr,
Last synced {0},Zuletzt synchronisiert {0},
+Learn more,Mehr erfahren,
Leave a Comment,Hinterlasse einen Kommentar,
Leave blank to repeat always,"Freilassen, um immer zu wiederholen",
Leave this conversation,Benachrichtigungen abbestellen,
@@ -1476,9 +1486,10 @@ Linked,Verknüpft,
Linked With,Verknüpft mit,
Linked with {0},Verknüpft mit {0},
Links,Verknüpfungen,
-List,Listenansicht,
+List,Liste,
List Filter,Listenfilter,
-List View Setting,List View Setting,
+List View,Listenansicht,
+List View Setting,Einstellungen zu Listenansicht,
List a document type,Einen Dokumenttyp auflisten,
"List as [{""label"": _(""Jobs""), ""route"":""jobs""}]","Liste als [{ ""label"": _ ( ""Jobs""), ""route"": ""jobs""}]",
List of backups available for download,Datensicherungen herunterladen,
@@ -1519,7 +1530,7 @@ Main Section,Hauptbereich,
Make use of longer keyboard patterns,Nutzen Sie mehr Tastaturmuster,
Manage Third Party Apps,Verwalten von Apps von Drittanbietern,
Mandatory Information missing:,Pflichtangaben fehlen:,
-Mandatory field: set role for,Pflichtfeld: set Rolle für,
+Mandatory field: set role for,Pflichtfeld: Rolle anwenden auf,
Mandatory field: {0},Pflichtfeld: {0},
"Mandatory fields required in table {0}, Row {1}","Pflichtfelder in der Tabelle erforderlich {0}, Reihe {1}",
Mandatory fields required in {0},Für {0} benötigte Pflichtfelder:,
@@ -1571,6 +1582,7 @@ Module Def,Modul-Def,
Module Name,Modulname,
Module Not Found,Modul nicht gefunden,
Module Path,Modulpfad,
+Module Profile, Modulprofil,
Module to Export,Module für den Export,
Modules HTML,Modul-HTML,
Monospace,Monospace,
@@ -1647,7 +1659,7 @@ No Preview,Keine Vorschau,
No Preview Available,Keine Vorschau vorhanden,
No Printer is Available.,Es ist kein Drucker verfügbar.,
No Results,Keine Ergebnisse,
-No Tags,No Tags,
+No Tags,Keine Schlagworte,
No alerts for today,Keine Warnungen für heute,
No comments yet,Noch keine Kommentare,
No comments yet. Start a new discussion.,Noch keine Kommentare. Starten Sie eine neue Diskussion.,
@@ -2037,7 +2049,7 @@ Remove,Entfernen,
Remove Field,Feld entfernen,
Remove Filter,Filter entfernen,
Remove Section,Abschnitt entfernen,
-Remove Tag,Markierung entfernen,
+Remove Tag,Schlagwort entfernen,
Remove all customizations?,Alle Anpassungen entfernen?,
Removed {0},{0} entfernt,
Rename many items by uploading a .csv file.,Viele Elemente auf einmal umbenennen durch Hochladen einer .CSV-Datei,
@@ -2256,7 +2268,7 @@ Set Permissions,Festlegen von Berechtigungen,
Set Permissions on Document Types and Roles,Berechtigungen für Dokumenttypen und Rollen setzen,
Set Property After Alert,Setzen Sie die Eigenschaft nach Alert,
Set Quantity,Anzahl festlegen,
-Set Role For,Set Rolle für,
+Set Role For,Rolle anwenden auf,
Set User Permissions,Nutzer-Berechtigungen setzen,
Set Value,Wert festlegen,
Set custom roles for page and report,Legen Sie benutzerdefinierte Rollen für Seite und Bericht,
@@ -2415,7 +2427,7 @@ Sum,Summe,
Sum of {0},Summe von {0},
Support Email Address Not Specified,Support-E-Mail-Adresse nicht angegeben,
Suspend Sending,Senden unterbrechen,
-Switch To Desk,Switch To Desk,
+Switch To Desk,Zum Desk wechseln,
Symbol,Symbol,
Sync,Synchronisieren,
Sync on Migrate,Sync auf Migrate,
@@ -2858,8 +2870,8 @@ bullhorn,Megafon,
ca-central-1,ca-central-1,
camera,Kamera,
cancelled this document,brach die Arbeit an diesem Dokument ab,
-changed value of {0},Wert von {0} geändert,
-changed values for {0},Werte von {0} geändert,
+changed value of {0},hat den Wert von {0} geändert,
+changed values for {0},hat die Werte von {0} geändert,
chevron-down,Winkel nach unten,
chevron-left,Winkel nach links,
chevron-right,Winkel nach rechts,
@@ -3247,7 +3259,7 @@ DocType Action,DocType-Aktion,
DocType Event,DocType-Ereignis,
DocType Link,DocType Link,
Document Share,Dokumentenfreigabe,
-Document Tag,Dokument-Tag,
+Document Tag,Dokument-Schlagwort,
Document Title,Dokumenttitel,
Document Type Field Mapping,Dokumenttyp-Feldzuordnung,
Document Type Mapping,Dokumenttypzuordnung,
@@ -3419,7 +3431,7 @@ Mandatory Depends On,Obligatorisch Hängt von ab,
Map Columns,Spalten zuordnen,
Map columns from {0} to fields in {1},Ordnen Sie Spalten von {0} Feldern in {1} zu.,
Mapping column {0} to field {1},Spalte {0} dem Feld {1} zuordnen,
-Mark all as Read,Markiere alle als gelesen,
+Mark all as Read,Alle als gelesen markieren,
Maximum Points,Maximale Punkte,
Maximum points allowed after multiplying points with the multiplier value\n(Note: For no limit leave this field empty or set 0),Maximal zulässige Punkte nach Multiplikation der Punkte mit dem Multiplikatorwert (Hinweis: Für unbegrenzte Anzahl lassen Sie dieses Feld leer oder setzen Sie 0),
Me,Mir,
@@ -3473,7 +3485,7 @@ Page Shortcuts,Seitenkürzel,
Parent Field (Tree),Elternfeld (Baum),
Parent Field must be a valid fieldname,Das übergeordnete Feld muss ein gültiger Feldname sein,
Pin Globally,Global anheften,
-Places,Setzt,
+Places,Orte,
Please check the filter values set for Dashboard Chart: {},Bitte überprüfen Sie die für das Dashboard-Diagramm festgelegten Filterwerte: {},
Please enable pop-ups in your browser,Bitte aktivieren Sie Popups in Ihrem Browser,
Please find attached {0}: {1},Im Anhang finden Sie {0}: {1},
@@ -3529,7 +3541,7 @@ Select Filters,Wählen Sie Filter,
Select Google Calendar to which event should be synced.,"Wählen Sie Google Kalender aus, mit dem das Ereignis synchronisiert werden soll.",
Select Google Contacts to which contact should be synced.,"Wählen Sie Google-Kontakte aus, mit denen der Kontakt synchronisiert werden soll.",
Select Group By...,Wählen Sie Gruppieren nach ...,
-Select Mandatory,Wählen Pflicht,
+Select Mandatory,Verpflichtende auswählen,
Select atleast 2 actions,Wählen Sie mindestens 2 Aktionen aus,
Select list item,Listenelement auswählen,
Select multiple list items,Wählen Sie mehrere Listenelemente aus,
@@ -3555,9 +3567,9 @@ Skipping column {0},Spalte {0} wird übersprungen,
Social Home,Soziales Zuhause,
Some columns might get cut off when printing to PDF. Try to keep number of columns under 10.,"Einige Spalten werden beim Drucken in PDF möglicherweise abgeschnitten. Versuchen Sie, die Anzahl der Spalten unter 10 zu halten.",
Something went wrong during the token generation. Click on {0} to generate a new one.,"Während der Token-Generierung ist ein Fehler aufgetreten. Klicken Sie auf {0}, um eine neue zu erstellen.",
-Submit After Import,Nach dem Import einreichen,
-Submitting...,Einreichen ...,
-Success! You are good to go 👍,Erfolg! Du bist gut zu gehen 👍,
+Submit After Import,Nach dem Import buchen,
+Submitting...,wird verbucht...,
+Success! You are good to go 👍,Erfolg! Du kannst nun durchstarten 👍,
Successful Transactions,Erfolgreiche Transaktionen,
Successfully Submitted!,Erfolgreich eingereicht!,
Successfully imported {0} record.,{0} Datensatz erfolgreich importiert.,
@@ -3569,9 +3581,9 @@ Sync Contacts,Kontakte synchronisieren,
Sync with Google Calendar,Mit Google Kalender synchronisieren,
Sync with Google Contacts,Mit Google-Kontakten synchronisieren,
Synced,Synchronisiert,
-Syncing,Synchronisierung,
+Syncing,Synchronisiert,
Syncing {0} of {1},{0} von {1} synchronisieren,
-Tag Link,Tag-Link,
+Tag Link,Schlagwortverknüpfung,
Take Backup,Backup erstellen,
Template Error,Vorlagenfehler,
Template Options,Vorlagenoptionen,
@@ -3652,8 +3664,8 @@ You need to install pycups to use this feature!,"Sie müssen Pycups installieren
Your Target,Dein Ziel,
"browse,","Durchsuche,",
cancelled this document {0},stornierte dieses Dokument {0},
-changed value of {0} {1},geänderter Wert von {0} {1},
-changed values for {0} {1},geänderte Werte für {0} {1},
+changed value of {0} {1},hat den Wert von {0} {1} geändert,
+changed values for {0} {1},hat die Werte von {0} {1} geändert,
choose an,wähle ein,
empty,leeren,
of,von,
@@ -3720,7 +3732,6 @@ Dr,Soll,
Due Date,Fälligkeitsdatum,
Duplicate,Duplizieren,
Edit Profile,Profil bearbeiten,
-Email,Email,
End Time,Endzeit,
Enter Value,Wert eingeben,
Entity Type,Entitätstyp,
@@ -3778,14 +3789,14 @@ Reset,Zurücksetzen,
Review,Rezension,
Room,Zimmer,
Room Type,Zimmertyp,
-Save,speichern,
+Save,Speichern,
Search results for,Suchergebnisse für,
Select All,Alles auswählen,
Send,Absenden,
Sending,Versand,
Server Error,Serverfehler,
Set,Menge,
-Setup,Einstellungen,
+Setup,Einrichtung,
Setup Wizard,Setup-Assistent,
Size,Größe,
Sr,Pos,
@@ -3793,7 +3804,7 @@ Start,Start,
Start Time,Startzeit,
Status,Status,
Submitted,Gebucht,
-Tag,Etikett,
+Tag,Schlagwort,
Template,Vorlage,
Thursday,Donnerstag,
Title,Bezeichnung,
@@ -3808,7 +3819,7 @@ Warehouse,Lager,
Welcome to {0},Willkommen auf {0},
Year,Jahr,
Yearly,Jährlich,
-You,Benutzer,
+You,Sie,
You can also copy-paste this link in your browser,Sie können diese Verknüpfung in Ihren Browser kopieren,
and,und,
{0} Name,{0} Name,
@@ -3942,7 +3953,7 @@ lock,sperren,
logged in,Angemeldet,
message,Mitteilung,
module,Modul,
-move,Bewegung,
+move,verschieben,
music,Musik,
new,Neu,
now,jetzt,
@@ -4025,7 +4036,7 @@ Please select target language for translation,Bitte wählen Sie die Zielsprache
Select Language,Sprache auswählen,
Confirm Translations,Übersetzungen bestätigen,
Contributed Translations,Beigetragene Übersetzungen,
-Show Tags,Tags anzeigen,
+Show Tags,Schlagworte anzeigen,
Do not have permission to access {0} bucket.,Sie haben keine Berechtigung zum Zugriff auf den Bucket {0}.,
Allow document creation via Email,Dokumenterstellung per E-Mail zulassen,
Sender Field,Absenderfeld,
@@ -4124,9 +4135,9 @@ Using this console may allow attackers to impersonate you and steal your informa
yesterday,gestern,
{0} years ago,Vor {0} Jahren,
New Chart,Neues Diagramm,
-New Shortcut,Neue Verknüpfung,
+New Shortcut,Neuer Schnellzugriff,
Edit Chart,Diagramm bearbeiten,
-Edit Shortcut,Verknüpfung bearbeiten,
+Edit Shortcut,Schnellzugriff bearbeiten,
Couldn't Load Desk,Schreibtisch konnte nicht geladen werden,
"Something went wrong while loading Desk. Please relaod the page . If the problem persists, contact the Administrator","Beim Laden von Desk ist ein Fehler aufgetreten. Bitte überarbeiten Sie die Seite . Wenn das Problem weiterhin besteht, wenden Sie sich an den Administrator",
Customize Workspace,Arbeitsbereich anpassen,
@@ -4172,7 +4183,7 @@ Phone Number,Telefonnummer,
Linked Documents,Verknüpfte Dokumente,
Account SID,Konto-SID,
Steps,Schritte,
-email,Email,
+email,E-Mail,
Component,Komponente,
Subtitle,Untertitel,
Global Defaults,Allgemeine Voreinstellungen,
@@ -4217,7 +4228,7 @@ since last month,seit letztem Monat,
since last year,seit letztem Jahr,
Show,Show,
New Number Card,Neue Zahlenkarte,
-Your Shortcuts,Ihre Verknüpfungen,
+Your Shortcuts,Ihre Schnellzugriffe,
You haven't added any Dashboard Charts or Number Cards yet.,Sie haben noch keine Dashboard-Diagramme oder Zahlenkarten hinzugefügt.,
Click On Customize to add your first widget,"Klicken Sie auf Anpassen, um Ihr erstes Widget hinzuzufügen",
Are you sure you want to reset all customizations?,Möchten Sie wirklich alle Anpassungen zurücksetzen?,
@@ -4639,7 +4650,7 @@ Not permitted to view {0},{0} darf nicht angezeigt werden,
Camera,Kamera,
Invalid filter: {0},Ungültiger Filter: {0},
Let's Get Started,Lass uns anfangen,
-Reports & Masters,Berichte & Meister,
+Reports & Masters,Berichte & Stammdaten,
New {0} {1} added to Dashboard {2},Neues {0} {1} zum Dashboard hinzugefügt {2},
New {0} {1} created,Neue {0} {1} erstellt,
New {0} Created,Neu {0} erstellt,
@@ -4704,3 +4715,67 @@ Reset sorting,Sortierung zurücksetzen,
Sort Ascending,Aufsteigend sortieren,
Sort Descending,Absteigend sortieren,
Remove column,Spalte entfernen,
+Set all public,Alle als öffentlich setzen,
+Set all private,Alle als privat setzen,
+Library,Bibliothek,
+My Device,Mein Gerät,
+Drag and drop files here or upload from,Ziehen Sie Dateien hierher oder laden Sie sie von,
+days,Tage,
+seconds,Sekunden,
+minutes,Minuten,
+Copy,Kopieren,
+{} Assigned,{} Zugewiesen,
+Hide Saved,Gespeicherte ausblenden,
+Show Saved,Gespeicherte anzeigen,
+{0} created this {1},{0} erstellte dies {1},
+{0} edited this {1},{0} bearbeitete dies {1},
+Toggle Full Width,Toggle Volle Breite,
+Documentation,Dokumentation,
+About,Über,
+Search or type a command (Ctrl + G),Suchen oder Befehl eingeben (Strg + G),
+{} Pending,{} Ausstehend,
+{} Available,{} Verfügbar,
+{} Open,{} Offen,
+Password set,Passwort gesetzt,
+Your new password has been set successfully.,Ihr Passwort wurde erfolgreich aktualisiert.,
+You hit the rate limit because of too many requests. Please try after sometime.,Sie haben die maximale Anzahl an Anfragen erreicht. Bitte versuchen Sie es später noch einmal.,
+"You need {0} permission to fetch values from {1} {2}","Sie benötigen eine {0}-Berechtigung, um die Werte von {1} {2} abzurufen",
+Cannot Fetch Values,Werte können nicht abgerufen werden,
+You do not have Read or Select Permissions for {},Sie haben keine Lese- oder Auswahlberechtigung für {},
+Or,Oder,
+{0} changed values for {1},{0} hat die Werte von {1} geändert,
+{0} changed values for {1} {2},{0} hat die Werte von {1} {2} geändert,
+{0} cancelled this document,{0} dieses Dokument storniert,
+{0} cancelled this document {1},{0} dieses Dokument storniert {1},
+{0} submitted this document,{0} hat dieses Dokument eingereicht,
+{0} submitted this document {1},{0} hat das Dokument {1} eingereicht,
+Customizations Discarded,Anpassungen verworfen,
+No filters selected,Keine Filter ausgewählt,
+You haven't created a {0} yet,Sie haben noch kein(en) {0} erstellt,
+No Data...,Keine Daten...,
+Don't have an account?,Sie haben noch kein Benutzerkonto?,
+{0} changed value of {1},{0} hat den Wert von {1} geändert,
+Basic Info,Grundlegende Informationen,
+No.,Nr.,number
+No.,Nein.,opposite of yes
+There are no upcoming events for you.,Es sind keine Termine für Sie geplant.,
+No Upcoming Events,Keine bevorstehenden Termine,
+"Looks like you haven’t received any notifications.","Sieht aus, als hätten Sie keine Benachrichtigungen erhalten.",
+No New notifications,Keine neuen Benachrichtigungen,
+Overview,Übersicht,
+Connections,Verknüpfungen,
+Save Customizations,Anpassungen speichern,
+Apply Filters,Filter anwenden,
+Add a Filter,Filter hinzufügen,
+Reset Customizations,Anpassungen zurücksetzen,
+{} wants to access the following details from your account,{} möchte Zugriff auf die folgenden Angaben von Ihrem Account,
+{0} is not a field of doctype {1},{0} ist kein Feld in Doctype {1},
+{0} from {1} to {2} in row #{3},{0} von {1} zu/bis {2} in Zeile #{3},
+{0} from {1} to {2},{0} von {1} zu/bis {2},
+{0} changed {1} to {2},{0} wurde von {1} zu {2} geändert,
+{0} Map,{0} Karte,
+Use HTML,HTML verwenden,
+Submit on Creation,Nach Erstellung buchen,
+Show Absolute Values,Absolutwerte anzeigen,
+Row #{0}: Could not find field {1} in {2} DocType,Zeile #{0}: Feld {1} existiert nicht in DocType {2},
+Repeat on Days,An Tagen wiederholen,
diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py
index 87f0ded68a..03877518d3 100644
--- a/frappe/utils/__init__.py
+++ b/frappe/utils/__init__.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 functools
@@ -25,9 +25,6 @@ import frappe
from frappe.utils.data import *
from frappe.utils.html_utils import sanitize_html
-default_fields = ['doctype', 'name', 'owner', 'creation', 'modified', 'modified_by',
- 'parent', 'parentfield', 'parenttype', 'idx', 'docstatus']
-
def get_fullname(user=None):
"""get the full name (first name + last name) of the user from User"""
@@ -57,8 +54,8 @@ def get_email_address(user=None):
def get_formatted_email(user, mail=None):
"""get Email Address of user formatted as: `John Doe `"""
fullname = get_fullname(user)
-
method = get_hook_method('get_sender_details')
+
if method:
sender_name, mail = method()
# if method exists but sender_name is ""
@@ -306,7 +303,7 @@ def remove_blanks(d):
"""
empty_keys = []
for key in d:
- if d[key]=='' or d[key]==None:
+ if d[key] == "" or d[key] is None:
# del d[key] raises runtime exception, using a workaround
empty_keys.append(key)
for key in empty_keys:
@@ -457,7 +454,8 @@ def touch_file(path):
os.utime(path, None)
return path
-def get_test_client():
+def get_test_client() -> Client:
+ """Returns an test instance of the Frappe WSGI"""
from frappe.app import application
return Client(application)
@@ -814,22 +812,33 @@ def get_assets_json():
# using .get instead of .get_value to avoid pickle.loads
try:
- assets_json = cache.get("assets_json")
- except ConnectionError:
+ if not frappe.conf.developer_mode:
+ assets_json = cache.get("assets_json").decode('utf-8')
+ else:
+ assets_json = None
+ except (UnicodeDecodeError, AttributeError, ConnectionError):
assets_json = None
- # if value found, decode it
- if assets_json is not None:
- try:
- assets_json = assets_json.decode('utf-8')
- except (UnicodeDecodeError, AttributeError):
- assets_json = None
-
if not assets_json:
- assets_json = frappe.read_file("assets/assets.json")
- cache.set_value("assets_json", assets_json, shared=True)
+ # get merged assets.json and assets-rtl.json
+ assets_dict = frappe.parse_json(
+ frappe.read_file("assets/assets.json")
+ )
- frappe.local.assets_json = frappe.safe_decode(assets_json)
+ assets_rtl = frappe.read_file("assets/assets-rtl.json")
+ if assets_rtl:
+ assets_dict.update(
+ frappe.parse_json(assets_rtl)
+ )
+ frappe.local.assets_json = frappe.as_json(assets_dict)
+ # save in cache
+ cache.set_value("assets_json", frappe.local.assets_json,
+ shared=True)
+
+ return assets_dict
+ else:
+ # from cache, decode and send
+ frappe.local.assets_json = frappe.safe_decode(assets_json)
return frappe.parse_json(frappe.local.assets_json)
@@ -917,10 +926,11 @@ def dictify(arg):
def add_user_info(user, user_info):
if user not in user_info:
info = frappe.db.get_value("User",
- user, ["full_name", "user_image", "name", 'email'], as_dict=True) or frappe._dict()
+ user, ["full_name", "user_image", "name", 'email', 'time_zone'], as_dict=True) or frappe._dict()
user_info[user] = frappe._dict(
fullname = info.full_name or user,
image = info.user_image,
name = user,
- email = info.email
+ email = info.email,
+ time_zone = info.time_zone
)
diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py
index b2592e9e8f..0a68cf22c4 100755
--- a/frappe/utils/background_jobs.py
+++ b/frappe/utils/background_jobs.py
@@ -1,6 +1,7 @@
import os
import socket
import time
+from functools import lru_cache
from uuid import uuid4
from collections import defaultdict
from typing import List
@@ -20,12 +21,22 @@ from frappe.utils.redis_queue import RedisQueue
from frappe.utils.commands import log
-default_timeout = 300
-queue_timeout = {
- 'long': 1500,
- 'default': 300,
- 'short': 300
-}
+
+@lru_cache()
+def get_queues_timeout():
+ common_site_config = frappe.get_conf()
+ custom_workers_config = common_site_config.get("workers", {})
+ default_timeout = 300
+
+ return {
+ "default": default_timeout,
+ "short": default_timeout,
+ "long": 1500,
+ **{
+ worker: config.get("timeout", default_timeout)
+ for worker, config in custom_workers_config.items()
+ }
+ }
redis_connection = None
@@ -46,12 +57,16 @@ def enqueue(method, queue='default', timeout=None, event=None,
# To handle older implementations
is_async = kwargs.pop('async', is_async)
- if now or frappe.flags.in_migrate:
+ if not is_async and not frappe.flags.in_test:
+ print(_("Using enqueue with is_async=False outside of tests is not recommended, use now=True instead."))
+
+ call_directly = now or frappe.flags.in_migrate or (not is_async and not frappe.flags.in_test)
+ if call_directly:
return frappe.call(method, **kwargs)
q = get_queue(queue, is_async=is_async)
if not timeout:
- timeout = queue_timeout.get(queue) or 300
+ timeout = get_queues_timeout().get(queue) or 300
queue_args = {
"site": frappe.local.site,
"user": frappe.session.user,
@@ -198,7 +213,7 @@ def get_jobs(site=None, queue=None, key='method'):
def get_queue_list(queue_list=None, build_queue_name=False):
'''Defines possible queues. Also wraps a given queue in a list after validating.'''
- default_queue_list = list(queue_timeout)
+ default_queue_list = list(get_queues_timeout())
if queue_list:
if isinstance(queue_list, str):
queue_list = [queue_list]
@@ -230,7 +245,7 @@ def get_queue(qtype, is_async=True):
def validate_queue(queue, default_queue_list=None):
if not default_queue_list:
- default_queue_list = list(queue_timeout)
+ default_queue_list = list(get_queues_timeout())
if queue not in default_queue_list:
frappe.throw(_("Queue should be one of {0}").format(', '.join(default_queue_list)))
@@ -290,7 +305,7 @@ def generate_qname(qtype: str) -> str:
def is_queue_accessible(qobj: Queue) -> bool:
"""Checks whether queue is relate to current bench or not.
"""
- accessible_queues = [generate_qname(q) for q in list(queue_timeout)]
+ accessible_queues = [generate_qname(q) for q in list(get_queues_timeout())]
return qobj.name in accessible_queues
def enqueue_test_job():
diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py
index 2ef53a3343..5197b20bd3 100644
--- a/frappe/utils/backups.py
+++ b/frappe/utils/backups.py
@@ -15,7 +15,7 @@ import click
# imports - module imports
import frappe
-from frappe import _, conf
+from frappe import conf
from frappe.utils import get_file_size, get_url, now, now_datetime, cint
from frappe.utils.password import get_encryption_key
@@ -183,8 +183,6 @@ class BackupGenerator:
False,
)
- self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S")
-
if not (
self.backup_path_conf
and self.backup_path_db
@@ -212,7 +210,7 @@ class BackupGenerator:
partial = "-partial" if self.partial else ""
ext = "tgz" if self.compress_files else "tar"
enc = "-enc" if frappe.get_system_settings("encrypt_backup") else ""
-
+ self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S")
for_conf = f"{self.todays_date}-{self.site_slug}-site_config_backup{enc}.json"
for_db = f"{self.todays_date}-{self.site_slug}{partial}-database{enc}.sql.gz"
@@ -507,7 +505,7 @@ download only after 24 hours.""" % {
datetime_str.strftime("%d/%m/%Y %H:%M:%S") + """ - Backup ready to be downloaded"""
)
- frappe.sendmail(recipients=recipient_list, msg=msg, subject=subject)
+ frappe.sendmail(recipients=recipient_list, message=msg, subject=subject)
return recipient_list
@@ -653,7 +651,8 @@ def get_backup_path():
@frappe.whitelist()
def get_backup_encryption_key():
- return frappe.local.conf.encryption_key
+ frappe.only_for("System Manager")
+ return frappe.conf.encryption_key
class Backup:
def __init__(self, file_path):
@@ -780,7 +779,7 @@ if __name__ == "__main__":
db_type=db_type,
db_port=db_port,
)
- odb.send_email("abc.sql.gz")
+ odb.send_email()
if cmd == "delete_temp_backups":
delete_temp_backups()
diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py
index 6c405ce467..634c99de13 100755
--- a/frappe/utils/boilerplate.py
+++ b/frappe/utils/boilerplate.py
@@ -1,6 +1,11 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
-import frappe, os, re, git
+
+import git
+import os
+import re
+
+import frappe
from frappe.utils import touch_file, cstr
def make_boilerplate(dest, app_name, no_git=False):
@@ -57,6 +62,11 @@ def make_boilerplate(dest, app_name, no_git=False):
frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "public",
"js"))
+ # add .gitkeep file so that public folder is committed to git
+ # this is needed because if public doesn't exist, bench build doesn't symlink the apps assets
+ with open(os.path.join(dest, hooks.app_name, hooks.app_name, "public", ".gitkeep"), "w") as f:
+ f.write('')
+
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "__init__.py"), "w") as f:
f.write(frappe.as_unicode(init_template))
diff --git a/frappe/utils/csvutils.py b/frappe/utils/csvutils.py
index e26da3da1c..2fb212ea3c 100644
--- a/frappe/utils/csvutils.py
+++ b/frappe/utils/csvutils.py
@@ -112,7 +112,7 @@ def check_record(d):
docfield = doc.meta.get_field(key)
val = d[key]
if docfield:
- if docfield.reqd and (val=='' or val==None):
+ if docfield.reqd and (val=='' or val is None):
frappe.msgprint(_("{0} is required").format(docfield.label), raise_exception=1)
if docfield.fieldtype=='Select' and val and docfield.options:
diff --git a/frappe/utils/data.py b/frappe/utils/data.py
index b4ac5e7fef..212ae8eba6 100644
--- a/frappe/utils/data.py
+++ b/frappe/utils/data.py
@@ -1,17 +1,22 @@
-# 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 Optional
-import frappe
-import operator
-import json
import base64
-import re, datetime, math, time
+import datetime
+import json
+import math
+import operator
+import re
+import time
from code import compile_command
-from urllib.parse import quote, urljoin
-from frappe.desk.utils import slug
-from click import secho
from enum import Enum
+from typing import Any, Dict, List, Optional, Tuple, Union
+from urllib.parse import quote, urljoin
+
+from click import secho
+
+import frappe
+from frappe.desk.utils import slug
DATE_FORMAT = "%Y-%m-%d"
TIME_FORMAT = "%H:%M:%S.%f"
@@ -99,11 +104,17 @@ def get_timedelta(time: Optional[str] = None) -> Optional[datetime.timedelta]:
datetime.timedelta: Timedelta object equivalent of the passed `time` string
"""
from dateutil import parser
+ from dateutil.parser import ParserError
time = time or "0:0:0"
try:
- t = parser.parse(time)
+ try:
+ t = parser.parse(time)
+ except ParserError as e:
+ if "day" in e.args[1] or "hour must be in" in e.args[0]:
+ return parse_timedelta(time)
+ raise e
return datetime.timedelta(
hours=t.hour, minutes=t.minute, seconds=t.second, microseconds=t.microsecond
)
@@ -129,7 +140,7 @@ def add_to_date(date, years=0, months=0, weeks=0, days=0, hours=0, minutes=0, se
from dateutil.parser._parser import ParserError
from dateutil.relativedelta import relativedelta
- if date==None:
+ if date is None:
date = now_datetime()
if hours:
@@ -201,7 +212,7 @@ def get_time_zone():
return frappe.cache().get_value("time_zone", _get_time_zone)
def convert_utc_to_timezone(utc_timestamp, time_zone):
- from pytz import timezone, UnknownTimeZoneError
+ from pytz import UnknownTimeZoneError, timezone
utcnow = timezone('UTC').localize(utc_timestamp)
try:
return utcnow.astimezone(timezone(time_zone))
@@ -318,17 +329,24 @@ def get_year_ending(date):
# last day of this month
return add_to_date(date, days=-1)
-def get_time(time_str):
+def get_time(time_str: str) -> datetime.time:
from dateutil import parser
+ from dateutil.parser import ParserError
if isinstance(time_str, datetime.datetime):
return time_str.time()
elif isinstance(time_str, datetime.time):
return time_str
- else:
- if isinstance(time_str, datetime.timedelta):
- time_str = str(time_str)
+ elif isinstance(time_str, datetime.timedelta):
+ return (datetime.datetime.min + time_str).time()
+ try:
return parser.parse(time_str).time()
+ except ParserError as e:
+ if "day" in e.args[1] or "hour must be in" in e.args[0]:
+ return (
+ datetime.datetime.min + parse_timedelta(time_str)
+ ).time()
+ raise e
def get_datetime_str(datetime_obj):
if isinstance(datetime_obj, str):
@@ -610,7 +628,7 @@ def cast(fieldtype, value=None):
value = flt(value)
elif fieldtype in ("Int", "Check"):
- value = cint(value)
+ value = cint(sbool(value))
elif fieldtype in ("Data", "Text", "Small Text", "Long Text",
"Text Editor", "Select", "Link", "Dynamic Link"):
@@ -726,7 +744,7 @@ def ceil(s):
def cstr(s, encoding='utf-8'):
return frappe.as_unicode(s, encoding)
-def sbool(x):
+def sbool(x: str) -> Union[bool, Any]:
"""Converts str object to Boolean if possible.
Example:
"true" becomes True
@@ -737,12 +755,15 @@ def sbool(x):
x (str): String to be converted to Bool
Returns:
- object: Returns Boolean or type(x)
+ object: Returns Boolean or x
"""
- from distutils.util import strtobool
-
try:
- return bool(strtobool(x))
+ val = x.lower()
+ if val in ('true', '1'):
+ return True
+ elif val in ('false', '0'):
+ return False
+ return x
except Exception:
return x
@@ -917,13 +938,13 @@ number_format_info = {
"#.########": (".", "", 8)
}
-def get_number_format_info(format):
+def get_number_format_info(format: str) -> Tuple[str, str, int]:
return number_format_info.get(format) or (".", ",", 2)
#
# convert currency to words
#
-def money_in_words(number, main_currency = None, fraction_currency=None):
+def money_in_words(number: str, main_currency: Optional[str] = None, fraction_currency: Optional[str] = None):
"""
Returns string in words with currency and fraction currency.
"""
@@ -1009,9 +1030,11 @@ def is_image(filepath):
def get_thumbnail_base64_for_image(src):
from os.path import exists as file_exists
+
from PIL import Image
+
+ from frappe import cache, safe_decode
from frappe.core.doctype.file.file import get_local_image
- from frappe import safe_decode, cache
if not src:
frappe.throw('Invalid source for image: {0}'.format(src))
@@ -1302,7 +1325,7 @@ operator_map = {
"None": lambda a, b: (not a) and True or False
}
-def evaluate_filters(doc, filters):
+def evaluate_filters(doc, filters: Union[Dict, List, Tuple]):
'''Returns true if doc matches filters'''
if isinstance(filters, dict):
for key, value in filters.items():
@@ -1319,7 +1342,7 @@ def evaluate_filters(doc, filters):
return True
-def compare(val1, condition, val2, fieldtype=None):
+def compare(val1: Any, condition: str, val2: Any, fieldtype: Optional[str] = None):
ret = False
if fieldtype:
val2 = cast(fieldtype, val2)
@@ -1328,7 +1351,7 @@ def compare(val1, condition, val2, fieldtype=None):
return ret
-def get_filter(doctype, f, filters_config=None):
+def get_filter(doctype: str, f: Union[Dict, List, Tuple], filters_config=None) -> "frappe._dict":
"""Returns a _dict like
{
@@ -1339,7 +1362,7 @@ def get_filter(doctype, f, filters_config=None):
"fieldtype":
}
"""
- from frappe.model import default_fields, optional_fields
+ from frappe.model import default_fields, optional_fields, child_table_fields
if isinstance(f, dict):
key, value = next(iter(f.items()))
@@ -1377,7 +1400,7 @@ def get_filter(doctype, f, filters_config=None):
frappe.throw(frappe._("Operator must be one of {0}").format(", ".join(valid_operators)))
- if f.doctype and (f.fieldname not in default_fields + optional_fields):
+ if f.doctype and (f.fieldname not in default_fields + optional_fields + child_table_fields):
# verify fieldname belongs to the doctype
meta = frappe.get_meta(f.doctype)
if not meta.has_field(f.fieldname):
@@ -1415,8 +1438,10 @@ def make_filter_dict(filters):
return _filter
def sanitize_column(column_name):
- from frappe import _
import sqlparse
+
+ from frappe import _
+
regex = re.compile("^.*[,'();].*")
column_name = sqlparse.format(column_name, strip_comments=True, keyword_case="lower")
blacklisted_keywords = ['select', 'create', 'insert', 'delete', 'drop', 'update', 'case', 'and', 'or']
@@ -1469,7 +1494,7 @@ def expand_relative_urls(html):
return html
def quoted(url):
- return cstr(quote(encode(url), safe=b"~@#$&()*!+=:;,.?/'"))
+ return cstr(quote(encode(cstr(url)), safe=b"~@#$&()*!+=:;,.?/'"))
def quote_urls(html):
def _quote_url(match):
@@ -1492,9 +1517,10 @@ def strip(val, chars=None):
return (val or "").replace("\ufeff", "").replace("\u200b", "").strip(chars)
def to_markdown(html):
- from html2text import html2text
from html.parser import HTMLParser
+ from html2text import html2text
+
text = None
try:
text = html2text(html or '')
@@ -1504,7 +1530,8 @@ def to_markdown(html):
return text
def md_to_html(markdown_text):
- from markdown2 import markdown as _markdown, MarkdownError
+ from markdown2 import MarkdownError
+ from markdown2 import markdown as _markdown
extras = {
'fenced-code-blocks': None,
@@ -1529,14 +1556,14 @@ def md_to_html(markdown_text):
def markdown(markdown_text):
return md_to_html(markdown_text)
-def is_subset(list_a, list_b):
+def is_subset(list_a: List, list_b: List) -> bool:
'''Returns whether list_a is a subset of list_b'''
return len(list(set(list_a) & set(list_b))) == len(list_a)
-def generate_hash(*args, **kwargs):
+def generate_hash(*args, **kwargs) -> str:
return frappe.generate_hash(*args, **kwargs)
-def guess_date_format(date_string):
+def guess_date_format(date_string: str) -> str:
DATE_FORMATS = [
r"%d/%b/%y",
r"%d-%m-%Y",
@@ -1611,13 +1638,13 @@ def guess_date_format(date_string):
if date_format and time_format:
return (date_format + ' ' + time_format).strip()
-def validate_json_string(string):
+def validate_json_string(string: str) -> None:
try:
json.loads(string)
except (TypeError, ValueError):
raise frappe.ValidationError
-def get_user_info_for_avatar(user_id):
+def get_user_info_for_avatar(user_id: str) -> Dict:
user_info = {
"email": user_id,
"image": "",
@@ -1664,3 +1691,30 @@ class UnicodeWithAttrs(str):
def __init__(self, text):
self.toc_html = text.toc_html
self.metadata = text.metadata
+
+
+def format_timedelta(o: datetime.timedelta) -> str:
+ # mariadb allows a wide diff range - https://mariadb.com/kb/en/time/
+ # but frappe doesnt - i think via babel : only allows 0..23 range for hour
+ total_seconds = o.total_seconds()
+ hours, remainder = divmod(total_seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ rounded_seconds = round(seconds, 6)
+ int_seconds = int(seconds)
+
+ if rounded_seconds == int_seconds:
+ seconds = int_seconds
+ else:
+ seconds = rounded_seconds
+
+ return "{:01}:{:02}:{:02}".format(int(hours), int(minutes), seconds)
+
+
+def parse_timedelta(s: str) -> datetime.timedelta:
+ # ref: https://stackoverflow.com/a/21074460/10309266
+ if 'day' in s:
+ m = re.match(r"(?P[-\d]+) day[s]*, (?P\d+):(?P\d+):(?P\d[\.\d+]*)", s)
+ else:
+ m = re.match(r"(?P\d+):(?P\d+):(?P\d[\.\d+]*)", s)
+
+ return datetime.timedelta(**{key: float(val) for key, val in m.groupdict().items()})
diff --git a/frappe/utils/diff.py b/frappe/utils/diff.py
index ac0e1b7439..2574f47fbd 100644
--- a/frappe/utils/diff.py
+++ b/frappe/utils/diff.py
@@ -1,14 +1,15 @@
import json
from difflib import unified_diff
-from typing import List
+from typing import List, Union
import frappe
from frappe.utils import pretty_date
+from frappe.utils.data import cstr
@frappe.whitelist()
def get_version_diff(
- from_version: str, to_version: str, fieldname: str = "script"
+ from_version: Union[int, str], to_version: Union[int, str], fieldname: str = "script"
) -> List[str]:
before, before_timestamp = _get_value_from_version(from_version, fieldname)
@@ -23,15 +24,15 @@ def get_version_diff(
diff = unified_diff(
before,
after,
- fromfile=from_version,
- tofile=to_version,
+ fromfile=cstr(from_version),
+ tofile=cstr(to_version),
fromfiledate=before_timestamp,
tofiledate=after_timestamp,
)
return list(diff)
-def _get_value_from_version(version_name: str, fieldname: str):
+def _get_value_from_version(version_name: Union[int, str], fieldname: str):
version = frappe.get_list(
"Version", fields=["data", "modified"], filters={"name": version_name}
)
diff --git a/frappe/utils/formatters.py b/frappe/utils/formatters.py
index 9436dea2c2..ae925a0ab2 100644
--- a/frappe/utils/formatters.py
+++ b/frappe/utils/formatters.py
@@ -3,9 +3,11 @@
import frappe
import datetime
-from frappe.utils import formatdate, fmt_money, flt, cstr, cint, format_datetime, format_time, format_duration
+from frappe.utils import formatdate, fmt_money, flt, cstr, cint, format_datetime, format_time, format_duration, format_timedelta
from frappe.model.meta import get_field_currency, get_field_precision
import re
+from dateutil.parser import ParserError
+
def format_value(value, df=None, doc=None, currency=None, translated=False, format=None):
'''Format value based on given fieldtype, document reference, currency reference.
@@ -47,7 +49,10 @@ def format_value(value, df=None, doc=None, currency=None, translated=False, form
return format_datetime(value)
elif df.get("fieldtype")=="Time":
- return format_time(value)
+ try:
+ return format_time(value)
+ except ParserError:
+ return format_timedelta(value)
elif value==0 and df.get("fieldtype") in ("Int", "Float", "Currency", "Percent") and df.get("print_hide_if_no_value"):
# this is required to show 0 as blank in table columns
@@ -83,9 +88,14 @@ def format_value(value, df=None, doc=None, currency=None, translated=False, form
return frappe.utils.markdown(value)
elif df.get("fieldtype") == "Table MultiSelect":
+ values = []
meta = frappe.get_meta(df.options)
link_field = [df for df in meta.fields if df.fieldtype == 'Link'][0]
- values = [v.get(link_field.fieldname, 'asdf') for v in value]
+ for v in value:
+ v.update({'__link_titles': doc.get('__link_titles')})
+ formatted_value = frappe.format_value(v.get(link_field.fieldname, ''), link_field, v)
+ values.append(formatted_value)
+
return ', '.join(values)
elif df.get("fieldtype") == "Duration":
@@ -95,4 +105,19 @@ def format_value(value, df=None, doc=None, currency=None, translated=False, form
elif df.get("fieldtype") == "Text Editor":
return "{}
".format(value)
+ elif df.get("fieldtype") in ["Link", "Dynamic Link"]:
+ if not doc or not doc.get("__link_titles") or not df.options:
+ return value
+
+ doctype = df.options
+ if df.get("fieldtype") == "Dynamic Link":
+ if not df.parent:
+ return value
+
+ meta = frappe.get_meta(df.parent)
+ _field = meta.get_field(df.options)
+ doctype = _field.options
+
+ return doc.__link_titles.get("{0}::{1}".format(doctype, value), value)
+
return value
diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py
index 7b591dff45..22938671a6 100644
--- a/frappe/utils/global_search.py
+++ b/frappe/utils/global_search.py
@@ -9,6 +9,8 @@ import os
from frappe.utils import cint, strip_html_tags
from frappe.utils.html_utils import unescape_html
from frappe.model.base_document import get_controller
+from frappe.utils.data import cstr
+
def setup_global_search_table():
"""
@@ -251,7 +253,7 @@ def update_global_search(doc):
if hasattr(doc, 'is_website_published') and doc.meta.allow_guest_to_view:
published = 1 if doc.is_website_published() else 0
- title = (doc.get_title() or '')[:int(frappe.db.VARCHAR_LEN)]
+ title = (cstr(doc.get_title()) or '')[:int(frappe.db.VARCHAR_LEN)]
route = doc.get('route') if doc else ''
value = dict(
diff --git a/frappe/utils/install.py b/frappe/utils/install.py
index cf76c9fffc..3af77b885f 100644
--- a/frappe/utils/install.py
+++ b/frappe/utils/install.py
@@ -90,7 +90,7 @@ def install_basic_docs():
for d in install_docs:
try:
- frappe.get_doc(d).insert()
+ frappe.get_doc(d).insert(ignore_if_duplicate=True)
except frappe.NameError:
pass
@@ -255,6 +255,12 @@ def add_standard_navbar_items():
'item_type': 'Action',
'action': 'frappe.ui.toolbar.show_shortcuts(event)',
'is_standard': 1
+ },
+ {
+ 'item_label': 'Frappe Support',
+ 'item_type': 'Route',
+ 'route': 'https://frappe.io/support',
+ 'is_standard': 1
}
]
diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py
index 12d00a78d8..3702a009fb 100644
--- a/frappe/utils/jinja.py
+++ b/frappe/utils/jinja.py
@@ -97,13 +97,10 @@ def get_jloader():
if not getattr(frappe.local, 'jloader', None):
from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader
- if frappe.local.flags.in_setup_help:
- apps = ['frappe']
- else:
- apps = frappe.get_hooks('template_apps')
- if not apps:
- apps = frappe.local.flags.web_pages_apps or frappe.get_installed_apps(sort=True)
- apps.reverse()
+ apps = frappe.get_hooks('template_apps')
+ if not apps:
+ apps = frappe.local.flags.web_pages_apps or frappe.get_installed_apps(sort=True)
+ apps.reverse()
if "frappe" not in apps:
apps.append('frappe')
@@ -124,15 +121,13 @@ def set_filters(jenv):
import frappe
from frappe.utils import cint, cstr, flt
- jenv.filters["json"] = frappe.as_json
- jenv.filters["len"] = len
- jenv.filters["int"] = cint
- jenv.filters["str"] = cstr
- jenv.filters["flt"] = flt
-
- if frappe.flags.in_setup_help:
- return
-
+ jenv.filters.update({
+ "json": frappe.as_json,
+ "len": len,
+ "int": cint,
+ "str": cstr,
+ "flt": flt,
+ })
def get_jinja_hooks():
"""Returns a tuple of (methods, filters) each containing a dict of method name and method definition pair."""
diff --git a/frappe/utils/make_random.py b/frappe/utils/make_random.py
index bb4395b626..0db26dfcd2 100644
--- a/frappe/utils/make_random.py
+++ b/frappe/utils/make_random.py
@@ -35,7 +35,7 @@ def get_random(doctype, filters=None, doc=False):
condition = " where " + " and ".join(condition)
else:
condition = ""
-
+
out = frappe.db.multisql({
'mariadb': """select name from `tab%s` %s
order by RAND() limit 1 offset 0""" % (doctype, condition),
diff --git a/frappe/utils/minify.py b/frappe/utils/minify.py
deleted file mode 100644
index b2cc93e554..0000000000
--- a/frappe/utils/minify.py
+++ /dev/null
@@ -1,212 +0,0 @@
-
-# This code is original from jsmin by Douglas Crockford, it was translated to
-# Python by Baruch Even. The original code had the following copyright and
-# license.
-#
-# /* jsmin.c
-# 2007-05-22
-#
-# Copyright (c) 2002 Douglas Crockford (www.crockford.com)
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy of
-# this software and associated documentation files (the "Software"), to deal in
-# the Software without restriction, including without limitation the rights to
-# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-# of the Software, and to permit persons to whom the Software is furnished to do
-# so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# The Software shall be used for Good, not Evil.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-# */
-
-from io import StringIO
-
-def jsmin(js):
- ins = StringIO(js)
- outs = StringIO()
- JavascriptMinify().minify(ins, outs)
- str = outs.getvalue()
- if len(str) > 0 and str[0] == '\n':
- str = str[1:]
- return str
-
-def isAlphanum(c):
- """return true if the character is a letter, digit, underscore,
- dollar sign, or non-ASCII character.
- """
- return ((c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or
- (c >= 'A' and c <= 'Z') or c == '_' or c == '$' or c == '\\' or (c is not None and ord(c) > 126));
-
-class UnterminatedComment(Exception):
- pass
-
-class UnterminatedStringLiteral(Exception):
- pass
-
-class UnterminatedRegularExpression(Exception):
- pass
-
-class JavascriptMinify(object):
-
- def _outA(self):
- self.outstream.write(self.theA)
- def _outB(self):
- self.outstream.write(self.theB)
-
- def _get(self):
- """return the next character from stdin. Watch out for lookahead. If
- the character is a control character, translate it to a space or
- linefeed.
- """
- c = self.theLookahead
- self.theLookahead = None
- if c == None:
- c = self.instream.read(1)
- if c >= ' ' or c == '\n':
- return c
- if c == '': # EOF
- return '\000'
- if c == '\r':
- return '\n'
- return ' '
-
- def _peek(self):
- self.theLookahead = self._get()
- return self.theLookahead
-
- def _next(self):
- """get the next character, excluding comments. peek() is used to see
- if an unescaped '/' is followed by a '/' or '*'.
- """
- c = self._get()
- if c == '/' and self.theA != '\\':
- p = self._peek()
- if p == '/':
- c = self._get()
- while c > '\n':
- c = self._get()
- return c
- if p == '*':
- c = self._get()
- while 1:
- c = self._get()
- if c == '*':
- if self._peek() == '/':
- self._get()
- return ' '
- if c == '\000':
- raise UnterminatedComment()
-
- return c
-
- def _action(self, action):
- """do something! What you do is determined by the argument:
- 1 Output A. Copy B to A. Get the next B.
- 2 Copy B to A. Get the next B. (Delete A).
- 3 Get the next B. (Delete B).
- action treats a string as a single character. Wow!
- action recognizes a regular expression if it is preceded by ( or , or =.
- """
- if action <= 1:
- self._outA()
-
- if action <= 2:
- self.theA = self.theB
- if self.theA == "'" or self.theA == '"':
- while 1:
- self._outA()
- self.theA = self._get()
- if self.theA == self.theB:
- break
- if self.theA <= '\n':
- raise UnterminatedStringLiteral()
- if self.theA == '\\':
- self._outA()
- self.theA = self._get()
-
-
- if action <= 3:
- self.theB = self._next()
- if self.theB == '/' and (self.theA == '(' or self.theA == ',' or
- self.theA == '=' or self.theA == ':' or
- self.theA == '[' or self.theA == '?' or
- self.theA == '!' or self.theA == '&' or
- self.theA == '|' or self.theA == ';' or
- self.theA == '{' or self.theA == '}' or
- self.theA == '\n'):
- self._outA()
- self._outB()
- while 1:
- self.theA = self._get()
- if self.theA == '/':
- break
- elif self.theA == '\\':
- self._outA()
- self.theA = self._get()
- elif self.theA <= '\n':
- raise UnterminatedRegularExpression()
- self._outA()
- self.theB = self._next()
-
-
- def _jsmin(self):
- """Copy the input to the output, deleting the characters which are
- insignificant to JavaScript. Comments will be removed. Tabs will be
- replaced with spaces. Carriage returns will be replaced with linefeeds.
- Most spaces and linefeeds will be removed.
- """
- self.theA = '\n'
- self._action(3)
-
- while self.theA != '\000':
- if self.theA == ' ':
- if isAlphanum(self.theB):
- self._action(1)
- else:
- self._action(2)
- elif self.theA == '\n':
- if self.theB in ['{', '[', '(', '+', '-']:
- self._action(1)
- elif self.theB == ' ':
- self._action(3)
- else:
- if isAlphanum(self.theB):
- self._action(1)
- else:
- self._action(2)
- else:
- if self.theB == ' ':
- if isAlphanum(self.theA):
- self._action(1)
- else:
- self._action(3)
- elif self.theB == '\n':
- if self.theA in ['}', ']', ')', '+', '-', '"', '\'']:
- self._action(1)
- else:
- if isAlphanum(self.theA):
- self._action(1)
- else:
- self._action(3)
- else:
- self._action(1)
-
- def minify(self, instream, outstream):
- self.instream = instream
- self.outstream = outstream
- self.theA = '\n'
- self.theB = None
- self.theLookahead = None
-
- self._jsmin()
- self.instream.close()
diff --git a/frappe/utils/nestedset.py b/frappe/utils/nestedset.py
index 98ad337043..2517761c45 100644
--- a/frappe/utils/nestedset.py
+++ b/frappe/utils/nestedset.py
@@ -227,7 +227,6 @@ class NestedSet(Document):
update_nsm(self)
except frappe.DoesNotExistError:
if self.flags.on_rollback:
- pass
frappe.message_log.pop()
else:
raise
diff --git a/frappe/utils/pdf.py b/frappe/utils/pdf.py
index 9a7c0889b5..b8e684869e 100644
--- a/frappe/utils/pdf.py
+++ b/frappe/utils/pdf.py
@@ -155,7 +155,7 @@ def read_options_from_html(html):
toggle_visible_pdf(soup)
# use regex instead of soup-parser
- for attr in ("margin-top", "margin-bottom", "margin-left", "margin-right", "page-size", "header-spacing", "orientation"):
+ for attr in ("margin-top", "margin-bottom", "margin-left", "margin-right", "page-size", "header-spacing", "orientation", "page-width", "page-height"):
try:
pattern = re.compile(r"(\.print-format)([\S|\s][^}]*?)(" + str(attr) + r":)(.+)(mm;)")
match = pattern.findall(html)
diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py
index dac9ab7a6d..c40180b538 100644
--- a/frappe/utils/redis_wrapper.py
+++ b/frappe/utils/redis_wrapper.py
@@ -22,7 +22,7 @@ class RedisWrapper(redis.Redis):
if shared:
return key
if user:
- if user == True:
+ if user is True:
user = frappe.session.user
key = "user:{0}:{1}".format(user, key)
@@ -154,7 +154,7 @@ class RedisWrapper(redis.Redis):
_name = self.make_key(name, shared=shared)
# set in local
- if not _name in frappe.local.cache:
+ if _name not in frappe.local.cache:
frappe.local.cache[_name] = {}
frappe.local.cache[_name][key] = value
@@ -173,7 +173,7 @@ class RedisWrapper(redis.Redis):
def hget(self, name, key, generator=None, shared=False):
_name = self.make_key(name, shared=shared)
- if not _name in frappe.local.cache:
+ if _name not in frappe.local.cache:
frappe.local.cache[_name] = {}
if not key: return None
diff --git a/frappe/utils/response.py b/frappe/utils/response.py
index f6ad91dbd2..a852c584c6 100644
--- a/frappe/utils/response.py
+++ b/frappe/utils/response.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 json
@@ -16,7 +16,7 @@ from werkzeug.local import LocalProxy
from werkzeug.wsgi import wrap_file
from werkzeug.wrappers import Response
from werkzeug.exceptions import NotFound, Forbidden
-from frappe.utils import cint
+from frappe.utils import cint, format_timedelta
from urllib.parse import quote
from frappe.core.doctype.access_log.access_log import make_access_log
@@ -122,12 +122,14 @@ def make_logs(response = None):
def json_handler(obj):
"""serialize non-serializable data for json"""
- # serialize date
- import collections.abc
+ from collections.abc import Iterable
- if isinstance(obj, (datetime.date, datetime.timedelta, datetime.datetime, datetime.time)):
+ if isinstance(obj, (datetime.date, datetime.datetime, datetime.time)):
return str(obj)
+ elif isinstance(obj, datetime.timedelta):
+ return format_timedelta(obj)
+
elif isinstance(obj, decimal.Decimal):
return float(obj)
@@ -138,7 +140,7 @@ def json_handler(obj):
doc = obj.as_dict(no_nulls=True)
return doc
- elif isinstance(obj, collections.abc.Iterable):
+ elif isinstance(obj, Iterable):
return list(obj)
elif type(obj)==type or isinstance(obj, Exception):
diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py
index 2042c1f2ce..5013963d1f 100644
--- a/frappe/utils/safe_exec.py
+++ b/frappe/utils/safe_exec.py
@@ -139,7 +139,21 @@ def get_safe_globals():
get_hooks=get_hooks,
enqueue=safe_enqueue,
sanitize_html=frappe.utils.sanitize_html,
- log_error=frappe.log_error
+ log_error=frappe.log_error,
+ db = NamespaceDict(
+ get_list=frappe.get_list,
+ get_all=frappe.get_all,
+ get_value=frappe.db.get_value,
+ set_value=frappe.db.set_value,
+ get_single_value=frappe.db.get_single_value,
+ get_default=frappe.db.get_default,
+ exists=frappe.db.exists,
+ count=frappe.db.count,
+ escape=frappe.db.escape,
+ sql=read_sql,
+ commit=frappe.db.commit,
+ rollback=frappe.db.rollback,
+ ),
),
FrappeClient=FrappeClient,
style=frappe._dict(
@@ -155,29 +169,11 @@ def get_safe_globals():
dev_server=1 if frappe._dev_server else 0,
run_script=run_script,
is_job_queued=is_job_queued,
+ get_visible_columns=get_visible_columns,
)
add_module_properties(frappe.exceptions, out.frappe, lambda obj: inspect.isclass(obj) and issubclass(obj, Exception))
- if not frappe.flags.in_setup_help:
- out.get_visible_columns = get_visible_columns
- out.frappe.date_format = date_format
- out.frappe.time_format = time_format
- out.frappe.db = NamespaceDict(
- get_list=frappe.get_list,
- get_all=frappe.get_all,
- get_value=frappe.db.get_value,
- set_value=frappe.db.set_value,
- get_single_value=frappe.db.get_single_value,
- get_default=frappe.db.get_default,
- exists=frappe.db.exists,
- count=frappe.db.count,
- escape=frappe.db.escape,
- sql=read_sql,
- commit=frappe.db.commit,
- rollback=frappe.db.rollback,
- )
-
if frappe.response:
out.frappe.response = frappe.response
diff --git a/frappe/utils/user.py b/frappe/utils/user.py
old mode 100755
new mode 100644
index cbf38f6acb..ca7a555c72
--- a/frappe/utils/user.py
+++ b/frappe/utils/user.py
@@ -79,7 +79,7 @@ class UserPermissions:
for r in get_valid_perms():
dt = r['parent']
- if not dt in self.perm_map:
+ if dt not in self.perm_map:
self.perm_map[dt] = {}
for k in frappe.permissions.rights:
diff --git a/frappe/utils/xlsxutils.py b/frappe/utils/xlsxutils.py
index 3f3ba94b19..38a076212a 100644
--- a/frappe/utils/xlsxutils.py
+++ b/frappe/utils/xlsxutils.py
@@ -10,6 +10,7 @@ from openpyxl.styles import Font
from openpyxl.utils import get_column_letter
import frappe
+from frappe.utils.html_utils import unescape_html
ILLEGAL_CHARACTERS_RE = re.compile(r'[\000-\010]|[\013-\014]|[\016-\037]')
@@ -51,19 +52,15 @@ def make_xlsx(data, sheet_name, wb=None, column_widths=None):
def handle_html(data):
+ from html2text import HTML2Text
+
# return if no html tags found
data = frappe.as_unicode(data)
- if '<' not in data:
- return data
- if '>' not in data:
+ if '<' not in data or '>' not in data:
return data
- from html2text import HTML2Text
-
- h = HTML2Text()
- h.unicode_snob = True
- h = h.unescape(data or "")
+ h = unescape_html(data or "")
obj = HTML2Text()
obj.ignore_links = True
diff --git a/frappe/website/doctype/blog_post/blog_post.json b/frappe/website/doctype/blog_post/blog_post.json
index b05293f28b..5e3cc78d70 100644
--- a/frappe/website/doctype/blog_post/blog_post.json
+++ b/frappe/website/doctype/blog_post/blog_post.json
@@ -213,8 +213,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
- "max_attachments": 5,
- "modified": "2021-11-23 10:42:01.759723",
+ "modified": "2022-03-09 01:48:25.227295",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",
diff --git a/frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py b/frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py
index 3699cdfbbd..e2f583fd48 100644
--- a/frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py
+++ b/frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py
@@ -7,7 +7,7 @@ import re
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import get_fullname, date_diff, get_datetime
+from frappe.utils import get_fullname, time_diff_in_hours, get_datetime
from frappe.utils.user import get_system_managers
from frappe.utils.verified_command import get_signed_params, verify_request
import json
@@ -353,8 +353,8 @@ def process_data_deletion_request():
for request in requests:
doc = frappe.get_doc("Personal Data Deletion Request", request)
- if date_diff(get_datetime(), doc.creation) >= auto_account_deletion:
- doc.add_comment("Comment", _("The User record for this request has been auto-deleted due to inactivity."))
+ if time_diff_in_hours(get_datetime(), doc.creation) >= auto_account_deletion:
+ doc.add_comment("Comment", _("The User record for this request has been auto-deleted due to inactivity by system admins."))
doc.trigger_data_deletion()
def remove_unverified_record():
diff --git a/frappe/website/doctype/personal_data_deletion_request/test_personal_data_deletion_request.py b/frappe/website/doctype/personal_data_deletion_request/test_personal_data_deletion_request.py
index 27dcfe5858..675a891130 100644
--- a/frappe/website/doctype/personal_data_deletion_request/test_personal_data_deletion_request.py
+++ b/frappe/website/doctype/personal_data_deletion_request/test_personal_data_deletion_request.py
@@ -4,10 +4,10 @@
import frappe
import unittest
from frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request import (
- remove_unverified_record,
+ remove_unverified_record, process_data_deletion_request
)
from frappe.website.doctype.personal_data_download_request.test_personal_data_download_request import (
- create_user_if_not_exists,
+ create_user_if_not_exists
)
from datetime import datetime, timedelta
@@ -58,3 +58,15 @@ class TestPersonalDataDeletionRequest(unittest.TestCase):
self.assertFalse(
frappe.db.exists("Personal Data Deletion Request", self.delete_request.name)
)
+
+ def test_process_auto_request(self):
+ frappe.db.set_value("Website Settings", None, "auto_account_deletion", "1")
+ date_time_obj = datetime.strptime(
+ self.delete_request.creation, "%Y-%m-%d %H:%M:%S.%f"
+ ) + timedelta(hours=-2)
+ self.delete_request.db_set("creation", date_time_obj)
+ self.delete_request.db_set("status", "Pending Approval")
+
+ process_data_deletion_request()
+ self.delete_request.reload()
+ self.assertEqual(self.delete_request.status, "Deleted")
diff --git a/frappe/website/doctype/web_form/templates/web_form.html b/frappe/website/doctype/web_form/templates/web_form.html
index a8666b55e9..9bcb036ec6 100644
--- a/frappe/website/doctype/web_form/templates/web_form.html
+++ b/frappe/website/doctype/web_form/templates/web_form.html
@@ -82,7 +82,8 @@ frappe.boot = {
time_zone: {
system: "{{ frappe.utils.get_time_zone() }}",
user: "{{ frappe.db.get_value('User', frappe.session.user, 'time_zone') or frappe.utils.get_time_zone() }}"
- }
+ },
+ link_title_doctypes: `{{ frappe.call('frappe.boot.get_link_title_doctypes') }}`
};
// for backward compatibility of some libs
frappe.sys_defaults = frappe.boot.sysdefaults;
@@ -92,18 +93,12 @@ $(".file-size").each(function() {
});
{{ include_script("controls.bundle.js") }}
-{% if is_list %}
-{# web form list #}
-
-
+{% if is_list %}
{{ include_script("dialog.bundle.js") }}
{{ include_script("web_form.bundle.js") }}
{{ include_script("bootstrap-4-web.bundle.js") }}
-{% else %}
-{# web form #}
+{% else %}
{{ include_script("dialog.bundle.js") }}
-
-