Sfoglia il codice sorgente

Merge branch 'develop' into frm_call

version-14
Ankush Menat 3 anni fa
committed by GitHub
parent
commit
87fb4d4459
Non sono state trovate chiavi note per questa firma nel database ID Chiave GPG: 4AEE18F83AFDEB23
90 ha cambiato i file con 2522 aggiunte e 1305 eliminazioni
  1. +4
    -2
      .github/helper/install.sh
  2. +10
    -4
      .github/helper/roulette.py
  3. +16
    -0
      .github/workflows/ui-tests.yml
  4. +4
    -0
      .mergify.yml
  5. +30
    -0
      cypress/fixtures/child_table_doctype.js
  6. +45
    -0
      cypress/fixtures/doctype_to_link.js
  7. +46
    -0
      cypress/fixtures/doctype_with_child_table.js
  8. +45
    -0
      cypress/integration/control_link.js
  9. +24
    -0
      cypress/integration/dashboard_links.js
  10. +21
    -0
      cypress/integration/depends_on.js
  11. +92
    -0
      cypress/integration/grid.js
  12. +46
    -13
      cypress/integration/report_view.js
  13. +4
    -2
      frappe/api.py
  14. +10
    -0
      frappe/boot.py
  15. +14
    -133
      frappe/build.py
  16. +4
    -8
      frappe/commands/site.py
  17. +8
    -3
      frappe/commands/utils.py
  18. +20
    -11
      frappe/core/doctype/communication/communication.py
  19. +14
    -0
      frappe/core/doctype/communication/test_communication.py
  20. +8
    -1
      frappe/core/doctype/docfield/docfield.json
  21. +8
    -1
      frappe/core/doctype/doctype/doctype.json
  22. +25
    -20
      frappe/core/doctype/doctype/doctype.py
  23. +1
    -1
      frappe/core/doctype/file/file.py
  24. +109
    -1
      frappe/core/doctype/report/test_report.py
  25. +2
    -1
      frappe/core/page/permission_manager/permission_manager.js
  26. +1
    -0
      frappe/coverage.py
  27. +466
    -456
      frappe/custom/doctype/custom_field/custom_field.json
  28. +2
    -2
      frappe/custom/doctype/custom_field/custom_field.py
  29. +8
    -1
      frappe/custom/doctype/customize_form/customize_form.json
  30. +9
    -3
      frappe/custom/doctype/customize_form/customize_form.py
  31. +8
    -1
      frappe/custom/doctype/customize_form_field/customize_form_field.json
  32. +11
    -44
      frappe/custom/doctype/property_setter/property_setter.py
  33. +2
    -0
      frappe/database/database.py
  34. +1
    -0
      frappe/database/mariadb/framework_mariadb.sql
  35. +1
    -0
      frappe/database/postgres/framework_postgres.sql
  36. +4
    -1
      frappe/database/schema.py
  37. +16
    -0
      frappe/desk/doctype/dashboard/dashboard_list.js
  38. +56
    -1
      frappe/desk/form/load.py
  39. +17
    -7
      frappe/desk/query_report.py
  40. +59
    -15
      frappe/desk/reportview.py
  41. +50
    -6
      frappe/desk/search.py
  42. +1
    -2
      frappe/installer.py
  43. +130
    -59
      frappe/migrate.py
  44. +84
    -37
      frappe/model/base_document.py
  45. +1
    -5
      frappe/model/document.py
  46. +28
    -9
      frappe/model/meta.py
  47. +1
    -0
      frappe/patches.txt
  48. +7
    -0
      frappe/public/icons/timeless/symbol-defs.svg
  49. +4
    -1
      frappe/public/js/frappe/form/controls/base_control.js
  50. +71
    -10
      frappe/public/js/frappe/form/controls/link.js
  51. +7
    -1
      frappe/public/js/frappe/form/controls/multiselect_pills.js
  52. +5
    -2
      frappe/public/js/frappe/form/controls/table_multiselect.js
  53. +21
    -10
      frappe/public/js/frappe/form/form.js
  54. +8
    -5
      frappe/public/js/frappe/form/formatters.js
  55. +6
    -7
      frappe/public/js/frappe/form/grid.js
  56. +22
    -5
      frappe/public/js/frappe/form/grid_row.js
  57. +11
    -7
      frappe/public/js/frappe/form/layout.js
  58. +12
    -13
      frappe/public/js/frappe/form/multi_select_dialog.js
  59. +1
    -1
      frappe/public/js/frappe/form/quick_entry.js
  60. +30
    -21
      frappe/public/js/frappe/form/save.js
  61. +12
    -3
      frappe/public/js/frappe/form/script_manager.js
  62. +1
    -1
      frappe/public/js/frappe/form/tab.js
  63. +2
    -2
      frappe/public/js/frappe/list/base_list.js
  64. +3
    -2
      frappe/public/js/frappe/list/list_view.js
  65. +8
    -0
      frappe/public/js/frappe/request.js
  66. +11
    -1
      frappe/public/js/frappe/ui/filters/filter.js
  67. +8
    -5
      frappe/public/js/frappe/utils/common.js
  68. +37
    -0
      frappe/public/js/frappe/utils/utils.js
  69. +3
    -2
      frappe/public/js/frappe/views/reports/query_report.js
  70. +55
    -9
      frappe/public/js/frappe/views/reports/report_view.js
  71. +1
    -1
      frappe/public/js/frappe/views/treeview.js
  72. +2
    -0
      frappe/public/scss/common/css_variables.scss
  73. +25
    -1
      frappe/public/scss/desk/list.scss
  74. +1
    -1
      frappe/public/scss/desk/sidebar.scss
  75. +1
    -1
      frappe/templates/print_formats/standard.html
  76. +3
    -3
      frappe/templates/print_formats/standard_macros.html
  77. +166
    -78
      frappe/tests/test_api.py
  78. +133
    -25
      frappe/tests/test_commands.py
  79. +10
    -0
      frappe/tests/test_db.py
  80. +68
    -6
      frappe/tests/test_document.py
  81. +2
    -2
      frappe/tests/test_frappe_client.py
  82. +93
    -0
      frappe/tests/test_utils.py
  83. +20
    -0
      frappe/tests/ui_test_helpers.py
  84. +10
    -4
      frappe/translations/de.csv
  85. +2
    -1
      frappe/utils/__init__.py
  86. +2
    -1
      frappe/utils/backups.py
  87. +21
    -1
      frappe/utils/formatters.py
  88. +0
    -212
      frappe/utils/minify.py
  89. +1
    -1
      frappe/website/doctype/web_form/web_form.js
  90. +50
    -9
      frappe/www/printview.py

+ 4
- 2
.github/helper/install.sh Vedi File

@@ -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

+ 10
- 4
.github/helper/roulette.py Vedi File

@@ -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"')

+ 16
- 0
.github/workflows/ui-tests.yml Vedi File

@@ -141,6 +141,12 @@ jobs:
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

+ 4
- 0
.mergify.yml Vedi File

@@ -48,3 +48,7 @@ pull_request_rules:
actions:
merge:
method: squash
commit_message_template: |
{{ title }} (#{{ number }})

{{ body }}

+ 30
- 0
cypress/fixtures/child_table_doctype.js Vedi File

@@ -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
};

+ 45
- 0
cypress/fixtures/doctype_to_link.js Vedi File

@@ -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
};

+ 46
- 0
cypress/fixtures/doctype_with_child_table.js Vedi File

@@ -0,0 +1,46 @@
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
}
],
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
};

+ 45
- 0
cypress/integration/control_link.js Vedi File

@@ -95,6 +95,51 @@ context('Control Link', () => {
});
});

it('show title field in link', () => {
get_dialog_with_link().as('dialog');

cy.insert_doc("Property Setter", {
"doctype": "Property Setter",
"doc_type": "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]}`);


+ 24
- 0
cypress/integration/dashboard_links.js Vedi File

@@ -1,7 +1,21 @@
import doctype_with_child_table from '../fixtures/doctype_with_child_table';
import child_table_doctype from '../fixtures/child_table_doctype';
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', 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 +76,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');
});
});

+ 21
- 0
cypress/integration/depends_on.js Vedi File

@@ -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');


+ 92
- 0
cypress/integration/grid.js Vedi File

@@ -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();

});
});
});


+ 46
- 13
cypress/integration/report_view.js Vedi File

@@ -11,30 +11,63 @@ context('Report View', () => {
'title': 'Doc 1',
'description': 'Random Text',
'enabled': 0,
// submit document
'docstatus': 1
}, true).as('doc');
'docstatus': 1 // submit document
}, true);
return cy.window().its('frappe').then(frappe => {
return frappe.call("frappe.tests.ui_test_helpers.create_multiple_contact_records");
});
});

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.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);
});
});

it('test load more with count selection buttons', () => {
cy.visit('/app/contact/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');
});
});

+ 4
- 2
frappe/api.py Vedi File

@@ -159,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():
@@ -208,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


+ 10
- 0
frappe/boot.py Vedi File

@@ -89,6 +89,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

@@ -324,6 +325,15 @@ def get_desk_settings():
def get_notification_settings():
return frappe.get_cached_doc('Notification Settings', frappe.session.user)

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(),


+ 14
- 133
frappe/build.py Vedi File

@@ -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 distutils.spawn import find_executable
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 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))

+ 4
- 8
frappe/commands/site.py Vedi File

@@ -447,21 +447,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



+ 8
- 3
frappe/commands/utils.py Vedi File

@@ -742,8 +742,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

@@ -751,8 +752,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')


+ 20
- 11
frappe/core/doctype/communication/communication.py Vedi File

@@ -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
@@ -367,15 +368,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 +398,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 +454,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)



+ 14
- 0
frappe/core/doctype/communication/test_communication.py Vedi File

@@ -5,6 +5,7 @@ from urllib.parse import quote

import frappe
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.core.doctype.communication.communication import get_emails

test_records = frappe.get_test_records('Communication')

@@ -201,6 +202,19 @@ class TestCommunication(unittest.TestCase):

self.assertIn(("Note", note.name), doc_links)

def parse_emails(self):
emails = get_emails(
[
'comm_recipient+DocType+DocName@example.com',
'"First, LastName" <first.lastname@email.com>',
'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 [])


+ 8
- 1
frappe/core/doctype/docfield/docfield.json Vedi File

@@ -17,6 +17,7 @@
"hide_days",
"hide_seconds",
"reqd",
"is_virtual",
"search_index",
"column_break_18",
"options",
@@ -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-01-27 21:22:20.529072",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",


+ 8
- 1
frappe/core/doctype/doctype/doctype.json Vedi File

@@ -46,6 +46,7 @@
"allow_auto_repeat",
"view_settings",
"title_field",
"show_title_field_in_link",
"search_fields",
"default_print_format",
"sort_field",
@@ -582,6 +583,12 @@
"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",
@@ -663,7 +670,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2021-12-09 14:53:10.717788",
"modified": "2022-01-07 16:07:06.196534",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",


+ 25
- 20
frappe/core/doctype/doctype/doctype.py Vedi File

@@ -781,28 +781,30 @@ 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):
fieldnames = tuple(field.fieldname for field in meta.fields)
for index, link in enumerate(meta.links, 1):
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))
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))
frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname"))
if not link.is_child_table:
continue

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.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+1)
frappe.throw(message, frappe.ValidationError, _("Table Fieldname 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 link.table_fieldname not in fieldnames:
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"))

def validate_fields_for_doctype(doctype):
meta = frappe.get_meta(doctype, cached=False)
@@ -1076,6 +1078,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))
@@ -1321,10 +1326,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)
@@ -1332,9 +1336,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')


+ 1
- 1
frappe/core/doctype/file/file.py Vedi File

@@ -745,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":


+ 109
- 1
frappe/core/doctype/report/test_report.py Vedi File

@@ -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,55 @@ 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, report_settings=report_settings)
self.assertEqual(result[-1][0], "Total")
self.assertEqual(result[-1][1], 200)
self.assertEqual(result[-1][2], 150.50)

+ 2
- 1
frappe/core/page/permission_manager/permission_manager.js Vedi File

@@ -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;
}
}
});


+ 1
- 0
frappe/coverage.py Vedi File

@@ -29,6 +29,7 @@ FRAPPE_EXCLUSIONS = [
"*/commands/*",
"*/frappe/change_log/*",
"*/frappe/exceptions*",
"*/frappe/coverage.py",
"*frappe/setup.py",
"*/doctype/*/*_dashboard.py",
"*/patches/*",


+ 466
- 456
frappe/custom/doctype/custom_field/custom_field.json Vedi File

@@ -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\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 &lt;script&gt; or just characters like &lt; or &gt;, 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": "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\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 &lt;script&gt; or just characters like &lt; or &gt;, 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-01-27 21:47:01.065556",
"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
}

+ 2
- 2
frappe/custom/doctype/custom_field/custom_field.py Vedi File

@@ -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:


+ 8
- 1
frappe/custom/doctype/customize_form/customize_form.json Vedi File

@@ -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",


+ 9
- 3
frappe/custom/doctype/customize_form/customize_form.py Vedi File

@@ -418,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])
@@ -430,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):
@@ -512,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 = {
@@ -558,7 +563,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 = {


+ 8
- 1
frappe/custom/doctype/customize_form_field/customize_form_field.json Vedi File

@@ -14,6 +14,7 @@
"non_negative",
"reqd",
"unique",
"is_virtual",
"in_list_view",
"in_standard_filter",
"in_global_search",
@@ -115,6 +116,12 @@
"fieldtype": "Check",
"label": "Unique"
},
{
"default": "0",
"fieldname": "is_virtual",
"fieldtype": "Check",
"label": "Is Virtual"
},
{
"default": "0",
"fieldname": "in_list_view",
@@ -436,7 +443,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-01-03 14:50:32.035768",
"modified": "2022-01-27 21:45:22.349776",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",


+ 11
- 44
frappe/custom/doctype/property_setter/property_setter.py Vedi File

@@ -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, self.row_name)

# clear cache
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,6 +58,7 @@ 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, row_name=None):
"""delete other property setters on this, if this is new"""
filters = dict(doc_type=doc_type, property=property)
@@ -100,4 +68,3 @@ def delete_property_setter(doc_type, property, field_name=None, row_name=None):
filters["row_name"] = row_name

frappe.db.delete('Property Setter', filters)


+ 2
- 0
frappe/database/database.py Vedi File

@@ -177,6 +177,8 @@ 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)):


+ 1
- 0
frappe/database/mariadb/framework_mariadb.sql Vedi File

@@ -224,6 +224,7 @@ 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`)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;


+ 1
- 0
frappe/database/postgres/framework_postgres.sql Vedi File

@@ -229,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")
) ;


+ 4
- 1
frappe/database/schema.py Vedi File

@@ -67,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'):
@@ -85,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'),


+ 16
- 0
frappe/desk/doctype/dashboard/dashboard_list.js Vedi File

@@ -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);
}
},
};

+ 56
- 1
frappe/desk/form/load.py Vedi File

@@ -49,7 +49,7 @@ def getdoc(doctype, name, user=None):
raise

doc.add_seen()
set_link_titles(doc)
frappe.response.docs.append(doc)

@frappe.whitelist()
@@ -367,6 +367,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)
@@ -387,3 +441,4 @@ def get_user_info_for_viewers(users):
frappe.utils.add_user_info(user, user_info)

return user_info


+ 17
- 7
frappe/desk/query_report.py Vedi File

@@ -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, report_settings=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, report_settings=report_settings)

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, report_settings=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, report_settings)

result["add_total_row"] = report.add_total_row and not result.get(
"skip_total_row", False
@@ -435,9 +435,19 @@ 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, report_settings=None):
total_row = [""] * len(columns)
has_percent = []
is_tree = False
parent_field = ''

if report_settings:
if isinstance(report_settings, (str,)):
report_settings = json.loads(report_settings)

is_tree = report_settings.get('tree')
parent_field = report_settings.get('parent_field')

for i, col in enumerate(columns):
fieldtype, options, fieldname = None, None, None
if isinstance(col, str):
@@ -464,12 +474,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)


+ 59
- 15
frappe/desk/reportview.py Vedi File

@@ -262,22 +262,66 @@ def compress(data, args=None):
}

@frappe.whitelist()
def save_report():
"""save report"""

data = frappe.local.form_dict
if frappe.db.exists('Report', data['name']):
d = frappe.get_doc('Report', data['name'])
def save_report(name, doctype, report_settings):
"""Save reports of type Report Builder from Report View"""

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']

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 = frappe.new_doc('Report')
report.report_name = name
report.ref_doctype = doctype

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()


+ 50
- 6
frappe/desk/search.py Vedi File

@@ -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
@@ -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):
@@ -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

+ 1
- 2
frappe/installer.py Vedi File

@@ -529,10 +529,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


+ 130
- 59
frappe/migrate.py Vedi File

@@ -1,30 +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
from frappe.utils.connections import check_connection
from frappe.utils.dashboard import sync_dashboards
import frappe.modules.patch_handler
import frappe.translate
from frappe.cache_manager import clear_global_cache
from frappe.desk.notifications import clear_notifications
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
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.utils.fixtures import sync_fixtures
from frappe.website.utils import clear_website_cache

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)
@@ -35,70 +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:
"""

$ bench start
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

Otherwise, check the server logs and ensure that all the required services are running.""")
sys.exit(1)
def setUp(self):
"""Complete setup required for site migration
"""
frappe.flags.touched_tables = set()
self.touched_tables_file = frappe.get_site_path("touched_tables.json")
add_column(doctype="DocType", column_name="migration_hash", fieldtype="Data")
clear_global_cache()

touched_tables_file = frappe.get_site_path('touched_tables.json')
if os.path.exists(touched_tables_file):
os.remove(touched_tables_file)
if os.path.exists(self.touched_tables_file):
os.remove(self.touched_tables_file)

try:
add_column(doctype="DocType", column_name="migration_hash", fieldtype="Data")
frappe.flags.touched_tables = set()
frappe.flags.in_migrate = True

clear_global_cache()
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)()

frappe.modules.patch_handler.run_all(skip_failing=skip_failing, patch_type=PatchType.pre_model_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.modules.patch_handler.run_all(skip_failing=skip_failing, patch_type=PatchType.post_model_sync)
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()

# syncs static files
clear_website_cache()

# updating installed applications data
frappe.get_single('Installed Applications').update_versions()
frappe.get_single("Portal Settings").sync_menu()
frappe.get_single("Installed Applications").update_versions()

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)()

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()

frappe.db.commit()

clear_notifications()

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 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())

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)

return are_services_running

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()

+ 84
- 37
frappe/model/base_document.py Vedi File

@@ -1,16 +1,14 @@
# 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 frappe
import datetime
from frappe import _
from frappe.model import default_fields, table_fields, child_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

@@ -35,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')
@@ -75,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 = []

@@ -105,10 +105,9 @@ class BaseDocument(object):
})
"""

# 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)
@@ -143,10 +142,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:
@@ -156,6 +159,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)
@@ -181,6 +187,7 @@ class BaseDocument(object):
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)

@@ -217,11 +224,11 @@ class BaseDocument(object):
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()
@@ -241,7 +248,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)
@@ -251,7 +258,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

@@ -325,6 +351,7 @@ class BaseDocument(object):
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] = [
@@ -372,26 +399,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):
@@ -404,8 +448,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
@@ -733,7 +780,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':
@@ -811,7 +858,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



+ 1
- 5
frappe/model/document.py Vedi File

@@ -249,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():


+ 28
- 9
frappe/model/meta.py Vedi File

@@ -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, child_table_fields
from frappe.model.document import Document

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.modules import load_doctype_module
from frappe.model.document import Document
from frappe.model.workflow import get_workflow_name
from frappe import _
from frappe.modules import load_doctype_module
from frappe.utils import cast, cint, cstr


def get_meta(doctype, cached=True):
if cached:
@@ -444,9 +456,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", {
@@ -546,7 +565,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


+ 1
- 0
frappe/patches.txt Vedi File

@@ -184,6 +184,7 @@ frappe.patches.v13_0.queryreport_columns
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
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


+ 7
- 0
frappe/public/icons/timeless/symbol-defs.svg Vedi File

@@ -814,6 +814,13 @@
<path d="M16.814 13.3304L17.9274 12.6875" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>

<symbol viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-dashboard-list">
<path d="M7.5 2.5H4.5C3.94772 2.5 3.5 2.94772 3.5 3.5V9.5C3.5 10.0523 3.94772 10.5 4.5 10.5H7.5C8.05228 10.5 8.5 10.0523 8.5 9.5V3.5C8.5 2.94772 8.05228 2.5 7.5 2.5Z" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 13.5H4.5C3.94772 13.5 3.5 13.9477 3.5 14.5V16.5C3.5 17.0523 3.94772 17.5 4.5 17.5H7.5C8.05228 17.5 8.5 17.0523 8.5 16.5V14.5C8.5 13.9477 8.05228 13.5 7.5 13.5Z" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 2.5H12.5C11.9477 2.5 11.5 2.94772 11.5 3.5V6.5C11.5 7.05228 11.9477 7.5 12.5 7.5H15.5C16.0523 7.5 16.5 7.05228 16.5 6.5V3.5C16.5 2.94772 16.0523 2.5 15.5 2.5Z" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 10.5H12.5C11.9477 10.5 11.5 10.9477 11.5 11.5V16.5C11.5 17.0523 11.9477 17.5 12.5 17.5H15.5C16.0523 17.5 16.5 17.0523 16.5 16.5V11.5C16.5 10.9477 16.0523 10.5 15.5 10.5Z" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>

<symbol viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-text">
<path d="M5 4V6.4H9V16H11.4V6.4H15.4V4H5Z" fill="var(--icon-stroke)" stroke="none"/>
</symbol>


+ 4
- 1
frappe/public/js/frappe/form/controls/base_control.js Vedi File

@@ -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";


+ 71
- 10
frappe/public/js/frappe/form/controls/link.js Vedi File

@@ -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("");
}
});


+ 7
- 1
frappe/public/js/frappe/form/controls/multiselect_pills.js Vedi File

@@ -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 `
<button class="data-pill btn tb-selected-value" data-value="${encoded_value}">
<span class="btn-link-to-form">${__(value)}</span>
<span class="btn-link-to-form">${__(label || value)}</span>
<span class="btn-remove">${frappe.utils.icon('close')}</span>
</button>
`;
}

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();



+ 5
- 2
frappe/public/js/frappe/form/controls/table_multiselect.js Vedi File

@@ -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 `
<button class="data-pill btn tb-selected-value" data-value="${encoded_value}">
<span class="btn-link-to-form">${__(value)}</span>
<span class="btn-link-to-form">${__(pill_name)}</span>
<span class="btn-remove">${frappe.utils.icon('close')}</span>
</button>
`;


+ 21
- 10
frappe/public/js/frappe/form/form.js Vedi File

@@ -334,7 +334,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;
}
@@ -1363,6 +1363,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 {
@@ -1372,8 +1373,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);
@@ -1661,23 +1664,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 +1682,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;


+ 8
- 5
frappe/public/js/frappe/form/formatters.js Vedi File

@@ -110,12 +110,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 +141,14 @@ frappe.form.formatters = {
return `<a
href="/app/${encodeURIComponent(frappe.router.slug(doctype))}/${encodeURIComponent(original_value)}"
data-doctype="${doctype}"
data-name="${original_value}">
${__(options && options.label || value)}</a>`;
data-name="${original_value}"
data-value="${original_value}">
${__(options && options.label || link_title || value)}</a>`;
} else {
return value;
return link_title || value;
}
} else {
return value;
return link_title || value;
}
},
Date: function(value) {


+ 6
- 7
frappe/public/js/frappe/form/grid.js Vedi File

@@ -502,10 +502,9 @@ 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);
for (let field of fieldname) {
this.update_docfield_property(field, "hidden", show);
this.set_editable_grid_column_disp(field, show);
}
} else {
this.get_docfield(fieldname).hidden = show ? 0 : 1;
@@ -555,17 +554,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();
}



+ 22
- 5
frappe/public/js/frappe/form/grid_row.js Vedi File

@@ -5,11 +5,7 @@ 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 = '<input type="checkbox" class="grid-row-check pull-left">';
@@ -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];
}


+ 11
- 7
frappe/public/js/frappe/form/layout.js Vedi File

@@ -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


+ 12
- 13
frappe/public/js/frappe/form/multi_select_dialog.js Vedi File

@@ -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) {
@@ -400,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 += `<div class="list-item__content ellipsis">
${
head ? `<span class="ellipsis text-muted" title="${__(frappe.model.unscrub(column))}">${__(frappe.model.unscrub(column))}</span>`
@@ -486,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)) {


+ 1
- 1
frappe/public/js/frappe/form/quick_entry.js Vedi File

@@ -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);
});
}



+ 30
- 21
frappe/public/js/frappe/form/save.js Vedi File

@@ -249,30 +249,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);
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]);
}
});
} else {
frappe._from_link.set_value(newdoc.name);
}
frappe._from_link.set_value(newdoc.name);
}

// refresh field
frappe._from_link.refresh();
// 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);
});
}
// 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;
frappe._from_link = null;
});
}
}

+ 12
- 3
frappe/public/js/frappe/form/script_manager.js Vedi File

@@ -192,9 +192,18 @@ frappe.ui.form.ScriptManager = class ScriptManager {
}

function setup_add_fetch(df) {
if ((['Data', 'Read Only', 'Text', 'Small Text', 'Currency', 'Check', 'Attach Image',
'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);
}


+ 1
- 1
frappe/public/js/frappe/form/tab.js Vedi File

@@ -40,7 +40,7 @@ export default class Tab {
hide = true;
}

hide && this.toggle(false);
this.toggle(!hide);
}

toggle(show) {


+ 2
- 2
frappe/public/js/frappe/list/base_list.js Vedi File

@@ -391,10 +391,10 @@ frappe.views.BaseList = class BaseList {
$this.addClass("btn-info");

this.start = 0;
this.page_length = $this.data().value;
this.page_length = this.selected_page_count = $this.data().value;
} else if ($this.is(".btn-more")) {
this.start = this.start + this.page_length;
this.page_length = 20;
this.page_length = this.selected_page_count || 20;
}
this.refresh();
});


+ 3
- 2
frappe/public/js/frappe/list/list_view.js Vedi File

@@ -1483,7 +1483,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
return [
filter[1],
"=",
JSON.stringify([filter[2], filter[3]]),
encodeURIComponent(JSON.stringify([filter[2], filter[3]])),
].join("");
})
.join("&");
@@ -1672,7 +1672,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
frappe.model.is_value_type(field_doc) &&
field_doc.fieldtype !== "Read Only" &&
!field_doc.hidden &&
!field_doc.read_only
!field_doc.read_only &&
!field_doc.is_virtual
);
};



+ 8
- 0
frappe/public/js/frappe/request.js Vedi File

@@ -260,6 +260,14 @@ frappe.request.call = function(opts) {
$.extend(frappe._messages, data.__messages);
}

// sync link titles
if (data._link_titles) {
if (!frappe._link_titles) {
frappe._link_titles = {};
}
$.extend(frappe._link_titles, data._link_titles);
}

// callbacks
var status_code_handler = statusCode[xhr.statusCode().status];
if (status_code_handler) {


+ 11
- 1
frappe/public/js/frappe/ui/filters/filter.js Vedi File

@@ -314,6 +314,10 @@ frappe.ui.Filter = class {
return this.utils.get_selected_value(this.field, this.get_condition());
}

get_selected_label() {
return this.utils.get_selected_label(this.field);
}

get_condition() {
return this.filter_edit_area.find('.condition').val();
}
@@ -361,7 +365,7 @@ frappe.ui.Filter = class {
get_filter_button_text() {
let value = this.utils.get_formatted_value(
this.field,
this.get_selected_value()
this.get_selected_label() || this.get_selected_value()
);
return `${__(this.field.df.label)} ${__(this.get_condition())} ${__(
value
@@ -449,6 +453,12 @@ frappe.ui.filter_utils = {
return val;
},

get_selected_label(field) {
if (in_list(["Link", "Dynamic Link"], field.df.fieldtype)) {
return field.get_label_value();
}
},

get_default_condition(df) {
if (df.fieldtype == 'Data') {
return 'like';


+ 8
- 5
frappe/public/js/frappe/utils/common.js Vedi File

@@ -259,8 +259,16 @@ frappe.utils.xss_sanitise = function (string, options) {
'/': '&#x2F;'
};
const REGEX_SCRIPT = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi; // used in jQuery 1.7.2 src/ajax.js Line 14
const REGEX_ALERT = /confirm\(.*\)|alert\(.*\)|prompt\(.*\)/gi; // captures alert, confirm, prompt
options = Object.assign({}, DEFAULT_OPTIONS, options); // don't deep copy, immutable beauty.

// Rule 3 - TODO: Check event handlers?
// script and alert should be checked first or else it will be escaped
if (options.strategies.includes('js')) {
sanitised = sanitised.replace(REGEX_SCRIPT, "");
sanitised = sanitised.replace(REGEX_ALERT, "");
}

// Rule 1
if (options.strategies.includes('html')) {
for (let char in HTML_ESCAPE_MAP) {
@@ -270,11 +278,6 @@ frappe.utils.xss_sanitise = function (string, options) {
}
}

// Rule 3 - TODO: Check event handlers?
if (options.strategies.includes('js')) {
sanitised = sanitised.replace(REGEX_SCRIPT, "");
}

return sanitised;
}



+ 37
- 0
frappe/public/js/frappe/utils/utils.js Vedi File

@@ -1416,5 +1416,42 @@ Object.assign(frappe.utils, {
arr.push(i);
}
return arr;
},

get_link_title(doctype, name) {
if (!doctype || !name || !frappe._link_titles) {
return;
}

return frappe._link_titles[doctype + "::" + name];
},

add_link_title(doctype, name, value) {
if (!doctype || !name) {
return;
}

if (!frappe._link_titles) {
// for link titles
frappe._link_titles = {};
}
frappe._link_titles[doctype + "::" + name] = value;
},

fetch_link_title(doctype, name) {
try {
return frappe.xcall("frappe.desk.search.get_link_title", {
"doctype": doctype,
"docname": name
}).then(title => {
frappe.utils.add_link_title(doctype, name, title);
return title;
});
} catch (error) {
console.log('Error while fetching link title.'); // eslint-disable-line
console.log(error); // eslint-disable-line
return Promise.resolve(name);
}
}
});

+ 3
- 2
frappe/public/js/frappe/views/reports/query_report.js Vedi File

@@ -578,6 +578,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
args: {
report_name: this.report_name,
filters: filters,
report_settings: this.report_settings
},
callback: resolve,
always: () => this.page.btn_secondary.prop('disabled', false)
@@ -834,7 +835,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
let data = this.data;
let columns = this.columns.filter((col) => !col.hidden);

if (this.raw_data.add_total_row) {
if (this.raw_data.add_total_row && !this.report_settings.tree) {
data = data.slice();
data.splice(-1, 1);
}
@@ -854,7 +855,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
treeView: this.tree_report,
layout: 'fixed',
cellHeight: 33,
showTotalRow: this.raw_data.add_total_row,
showTotalRow: this.raw_data.add_total_row && !this.report_settings.tree,
direction: frappe.utils.is_rtl() ? 'rtl' : 'ltr',
hooks: {
columnTotal: frappe.utils.report_column_total


+ 55
- 9
frappe/public/js/frappe/views/reports/report_view.js Vedi File

@@ -18,7 +18,6 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
setup_defaults() {
super.setup_defaults();
this.page_title = __('Report:') + ' ' + this.page_title;
this.menu_items = this.report_menu_items();
this.view = 'Report';

const route = frappe.get_route();
@@ -52,6 +51,11 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
this.page.main.addClass('report-view');
}

setup_page() {
this.menu_items = this.report_menu_items();
super.setup_page();
}

toggle_side_bar() {
super.toggle_side_bar();
// refresh datatable when sidebar is toggled to accomodate extra space
@@ -644,6 +648,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
// not a cancelled doc
&& data.docstatus !== 2
&& !df.read_only
&& !df.is_virtual
&& !df.hidden
// not a standard field i.e., owner, modified_by, etc.
&& frappe.model.is_non_std_field(df.fieldname))
@@ -1025,7 +1030,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
title += ` (${__(doctype)})`;
}

const editable = frappe.model.is_non_std_field(fieldname) && !docfield.read_only;
const editable = frappe.model.is_non_std_field(fieldname) && !docfield.read_only && !docfield.is_virtual;

const align = (() => {
const is_numeric = frappe.model.is_numeric_field(docfield);
@@ -1207,7 +1212,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
args: {
name: name,
doctype: this.doctype,
json: JSON.stringify(report_settings)
report_settings: JSON.stringify(report_settings)
},
callback:(r) => {
if(r.exc) {
@@ -1244,6 +1249,17 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
}
}

delete_report() {
return frappe.call({
method: 'frappe.desk.reportview.delete_report',
args: { name: this.report_name },
callback(response) {
if (response.exc) return;
window.history.back();
}
});
}

get_column_widths() {
if (this.datatable) {
return this.datatable
@@ -1465,12 +1481,42 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
}
});

// save buttons
if(frappe.user.is_report_manager()) {
items = items.concat([
{ label: __('Save'), action: () => this.save_report('save') },
{ label: __('Save As'), action: () => this.save_report('save_as') }
]);
const can_edit_or_delete = (action) => {
const method = action == "delete" ? "can_delete" : "can_write";
return (
this.report_doc
&& this.report_doc.is_standard !== "Yes"
&& (
frappe.model[method]("Report")
|| this.report_doc.owner === frappe.session.user
)
);
};

// A user with role Report Manager or Report Owner can save
if (can_edit_or_delete()) {
items.push({
label: __("Save"),
action: () => this.save_report('save')
});
}

// anyone can save as
items.push({
label: __('Save As'),
action: () => this.save_report('save_as')
});

// A user with role Report Manager or Report Owner can delete
if (can_edit_or_delete("delete")) {
items.push({
label: __("Delete"),
action: () => frappe.confirm(
"Are you sure you want to delete this report?",
() => this.delete_report(),
),
shortcut: "Shift+Ctrl+D"
});
}

// user permissions


+ 1
- 1
frappe/public/js/frappe/views/treeview.js Vedi File

@@ -343,7 +343,7 @@ frappe.views.TreeView = class TreeView {
this.ignore_fields = this.opts.ignore_fields || [];

var mandatory_fields = $.map(me.opts.meta.fields, function(d) {
return (d.reqd || d.bold && !d.read_only) ? d : null });
return (d.reqd || d.bold && !d.read_only && !!d.is_virtual) ? d : null });

var opts_field_names = this.fields.map(function(d) {
return d.fieldname


+ 2
- 0
frappe/public/scss/common/css_variables.scss Vedi File

@@ -136,6 +136,8 @@
--shadow-md: 0px 8px 14px rgba(25, 39, 52, 0.08), 0px 2px 6px rgba(25, 39, 52, 0.04);
--shadow-lg: 0px 18px 22px rgba(25, 39, 52, 0.1), 0px 1px 10px rgba(0, 0, 0, 0.06), 0px 0.5px 5px rgba(25, 39, 52, 0.04);

--drop-shadow: 0px 0.5px 0px rgba(0, 0, 0, 0.05), 0px 0px 0px rgba(0, 0, 0, 0), 0px 2px 4px rgba(0, 0, 0, 0.05);

--modal-shadow: var(--shadow-md);
--card-shadow: var(--shadow-sm);
--btn-shadow: var(--shadow-xs);


+ 25
- 1
frappe/public/scss/desk/list.scss Vedi File

@@ -187,7 +187,31 @@ $level-margin-right: 8px;
}

.list-paging-area, .footnote-area {
border-top: 1px sol var(--border-color);
border-top: 1px solid var(--border-color);

.btn-group {
box-shadow: var(--drop-shadow);
border-radius: var(--border-radius-md);

&> .btn:nth-child(2) {
border-left: none;
border-right: none;
}

.btn-paging {
box-shadow: none;
margin-left: 0px !important;
border: 1px solid var(--dark-border-color);
&.btn-info {
background-color: var(--gray-400);
border-color: var(--gray-400);
color: var(--white);
font-weight: var(--text-bold);
}
}
}

}

.frappe-card {


+ 1
- 1
frappe/public/scss/desk/sidebar.scss Vedi File

@@ -104,10 +104,10 @@ body[data-route^="Module"] .main-menu {
}

.sidebar-image-section {
width: min(100%, 170px);
cursor: pointer;

.sidebar-image {
width: min(100%, 170px);
height: auto;
max-height: 170px;
object-fit: cover;


+ 1
- 1
frappe/templates/print_formats/standard.html Vedi File

@@ -3,7 +3,7 @@
{% for page in layout %}
<div class="page-break">
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}
{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings, print_heading_template) }}
</div>

{% if print_settings.repeat_header_footer %}


+ 3
- 3
frappe/templates/print_formats/standard_macros.html Vedi File

@@ -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 %}
<div class="letter-head">{{ letter_head }}</div>
{% 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 %}
<div class="print-heading">
<h2>


+ 166
- 78
frappe/tests/test_api.py Vedi File

@@ -1,77 +1,126 @@
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 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

def maintain_state(f):
def wrapper(*args, **kwargs):
frappe.db.rollback()
r = f(*args, **kwargs)
frappe.db.commit()
return r

return wrapper
def patch_request_header(key, *args, **kwargs):
if key == "Authorization":
return f"token {authorization_token}"


class TestResourceAPI(unittest.TestCase):
SITE_URL = get_site_url(frappe.local.site)
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):
r = self.post("/api/method/login", {
"usr": "Administrator",
"pwd": frappe.conf.admin_password or "admin",
})
self._sid = r.headers[2][1].split(";")[0].lstrip("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 tearDownClass(cls):
for name in cls.GENERATED_DOCUMENTS:
frappe.delete_doc_if_exists(cls.DOCTYPE, name)
frappe.db.commit()

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}")
if self._testMethodName == "test_auth_cycle":
from frappe.core.doctype.user.user import generate_keys
generate_keys("Administrator")
frappe.db.commit()

def test_unauthorized_call(self):
# test 1: fetch documents without auth
@@ -80,88 +129,107 @@ 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 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 +238,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")

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.assertEqual(response.json()['message'], "pong")
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

+ 133
- 25
frappe/tests/test_commands.py Vedi File

@@ -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.assertEqual(self.returncode, 0)
self.assertIsInstance(json.loads(self.stdout), dict)

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 -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)

+ 10
- 0
frappe/tests/test_db.py Vedi File

@@ -291,6 +291,16 @@ class TestDB(unittest.TestCase):

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


@run_only_if(db_type_is.MARIADB)
class TestDDLCommandsMaria(unittest.TestCase):


+ 68
- 6
frappe/tests/test_document.py Vedi File

@@ -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):
@@ -255,5 +264,58 @@ class TestDocument(unittest.TestCase):

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)
# assuming DocType has more than 3 Data fields
self.assertEquals(len(doc.get("fields", limit=3)), 3)

# limit with filters
self.assertEquals(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)

+ 2
- 2
frappe/tests/test_frappe_client.py Vedi File

@@ -10,7 +10,8 @@ import requests
import base64

class TestFrappeClient(unittest.TestCase):
PASSWORD = "admin"
PASSWORD = frappe.conf.admin_password or "admin"

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 +170,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"


+ 93
- 0
frappe/tests/test_utils.py Vedi File

@@ -419,3 +419,96 @@ class TestXlsxUtils(unittest.TestCase):
val = handle_html("<p>html data &gt;</p>")
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()



+ 20
- 0
frappe/tests/ui_test_helpers.py Vedi File

@@ -134,6 +134,12 @@ def create_contact_records():
insert_contact('Test Form Contact 2', '54321')
insert_contact('Test Form Contact 3', '12345')

@frappe.whitelist()
def create_multiple_contact_records():
if frappe.db.get_all('Contact', {'first_name': 'Multiple Contact 1'}):
return
for index in range(1001):
insert_contact('Multiple Contact {}'.format(index+1), '12345{}'.format(index+1))

def insert_contact(first_name, phone_number):
doc = frappe.get_doc({
@@ -249,3 +255,17 @@ def update_webform_to_multistep():
_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()

+ 10
- 4
frappe/translations/de.csv Vedi File

@@ -148,6 +148,8 @@ More Information,Mehr Informationen,
More...,Mehr...,
Move,Bewegen,
My Account,Mein Konto,
My Profile,Mein Profil,
My Settings,Meine Einstellungen,
New Address,Neue Adresse,
New Contact,Neuer Kontakt,
Next,Weiter,
@@ -406,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,
@@ -734,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,
@@ -957,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,
@@ -1222,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,
@@ -1451,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,
@@ -1483,7 +1488,8 @@ Linked with {0},Verknüpft mit {0},
Links,Verknüpfungen,
List,Listenansicht,
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,


+ 2
- 1
frappe/utils/__init__.py Vedi File

@@ -438,7 +438,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)



+ 2
- 1
frappe/utils/backups.py Vedi File

@@ -653,7 +653,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):


+ 21
- 1
frappe/utils/formatters.py Vedi File

@@ -88,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":
@@ -100,4 +105,19 @@ def format_value(value, df=None, doc=None, currency=None, translated=False, form
elif df.get("fieldtype") == "Text Editor":
return "<div class='ql-snow'>{}</div>".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

+ 0
- 212
frappe/utils/minify.py Vedi File

@@ -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 is 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()

+ 1
- 1
frappe/website/doctype/web_form/web_form.js Vedi File

@@ -60,7 +60,7 @@ frappe.ui.form.on("Web Form", {
options: field.options,
reqd: field.reqd,
default: field.default,
read_only: field.read_only,
read_only: field.read_only || field.is_virtual,
depends_on: field.depends_on,
mandatory_depends_on: field.mandatory_depends_on,
read_only_depends_on: field.read_only_depends_on,


+ 50
- 9
frappe/www/printview.py Vedi File

@@ -151,7 +151,12 @@ def get_rendered_template(doc, name=None, print_format=None, meta=None,

convert_markdown(doc, meta)

args = {
args = {}
# extract `print_heading_template` from the first field and remove it
if format_data and format_data[0].get("fieldname") == "print_heading_template":
args["print_heading_template"] = format_data.pop(0).get("options")

args.update({
"doc": doc,
"meta": frappe.get_meta(doc.doctype),
"layout": make_layout(doc, meta, format_data),
@@ -160,7 +165,7 @@ def get_rendered_template(doc, name=None, print_format=None, meta=None,
"letter_head": letter_head.content,
"footer": letter_head.footer,
"print_settings": print_settings
}
})

html = template.render(args, filters={"len": len})

@@ -169,6 +174,48 @@ def get_rendered_template(doc, name=None, print_format=None, meta=None,

return html

def set_link_titles(doc):
# Adds name with title of link field doctype to __link_titles
if not doc.get("__link_titles"):
setattr(doc, "__link_titles", {})

meta = frappe.get_meta(doc.doctype)
set_title_values_for_link_and_dynamic_link_fields(meta, doc)
set_title_values_for_table_and_multiselect_fields(meta, doc)

def set_title_values_for_link_and_dynamic_link_fields(meta, doc, parent_doc=None):
if parent_doc and not parent_doc.get("__link_titles"):
setattr(parent_doc, "__link_titles", {})
elif doc and not doc.get("__link_titles"):
setattr(doc, "__link_titles", {})

for field in meta.get_link_fields() + meta.get_dynamic_link_fields():
if not doc.get(field.fieldname):
continue

# If link field, then get doctype from options
# If dynamic link field, then get doctype from dependent field
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.get_cached_value(doctype, doc.get(field.fieldname), meta.title_field)
if parent_doc:
parent_doc.__link_titles["{0}::{1}".format(doctype, doc.get(field.fieldname))] = link_title
elif doc:
doc.__link_titles["{0}::{1}".format(doctype, doc.get(field.fieldname))] = link_title

def set_title_values_for_table_and_multiselect_fields(meta, doc):
for field in meta.get_table_fields():
if not doc.get(field.fieldname):
continue

_meta = frappe.get_meta(field.options)
for value in doc.get(field.fieldname):
set_title_values_for_link_and_dynamic_link_fields(_meta, value, doc)

def convert_markdown(doc, meta):
'''Convert text field values to markdown if necessary'''
for field in meta.fields:
@@ -190,6 +237,7 @@ def get_html_and_style(doc, name=None, print_format=None, meta=None,
doc = frappe.get_doc(json.loads(doc))

print_format = get_print_format_doc(print_format, meta=meta or frappe.get_meta(doc.doctype))
set_link_titles(doc)

try:
html = get_rendered_template(doc, name=name, print_format=print_format, meta=meta,
@@ -276,13 +324,6 @@ def make_layout(doc, meta, format_data=None):
layout, page = [], []
layout.append(page)

if format_data:
# extract print_heading_template from the first field
# and remove the field
if format_data[0].get("fieldname") == "print_heading_template":
doc.print_heading_template = format_data[0].get("options")
format_data = format_data[1:]

def get_new_section(): return {'columns': [], 'has_data': False}

def append_empty_field_dict_to_page_column(page):


Caricamento…
Annulla
Salva