Browse Source

Merge branch 'develop' of https://github.com/frappe/frappe into autocomplete-control

version-14
Saqib Ansari 3 years ago
parent
commit
7ce128de6c
100 changed files with 1533 additions and 555 deletions
  1. +3
    -0
      .git-blame-ignore-revs
  2. +4
    -2
      .github/helper/install.sh
  3. +10
    -4
      .github/helper/roulette.py
  4. +16
    -0
      .github/workflows/ui-tests.yml
  5. +4
    -0
      .mergify.yml
  6. +1
    -1
      README.md
  7. +30
    -0
      cypress/fixtures/child_table_doctype.js
  8. +45
    -0
      cypress/fixtures/doctype_to_link.js
  9. +46
    -0
      cypress/fixtures/doctype_with_child_table.js
  10. +45
    -0
      cypress/integration/control_link.js
  11. +24
    -0
      cypress/integration/dashboard_links.js
  12. +21
    -0
      cypress/integration/depends_on.js
  13. +92
    -0
      cypress/integration/grid.js
  14. +1
    -0
      cypress/integration/list_view.js
  15. +48
    -14
      cypress/integration/report_view.js
  16. +4
    -2
      frappe/api.py
  17. +10
    -0
      frappe/boot.py
  18. +14
    -133
      frappe/build.py
  19. +34
    -36
      frappe/commands/site.py
  20. +9
    -3
      frappe/commands/utils.py
  21. +7
    -0
      frappe/core/doctype/doctype/doctype.js
  22. +8
    -1
      frappe/core/doctype/doctype/doctype.json
  23. +13
    -5
      frappe/core/doctype/doctype/doctype.py
  24. +7
    -0
      frappe/core/doctype/doctype/test_doctype.py
  25. +1
    -1
      frappe/core/doctype/file/file.py
  26. +53
    -1
      frappe/core/doctype/report/test_report.py
  27. +1
    -1
      frappe/core/doctype/user/test_user.py
  28. +2
    -1
      frappe/core/page/permission_manager/permission_manager.js
  29. +1
    -0
      frappe/coverage.py
  30. +56
    -0
      frappe/custom/doctype/client_script/client_script.js
  31. +3
    -3
      frappe/custom/doctype/client_script/client_script.json
  32. +8
    -1
      frappe/custom/doctype/customize_form/customize_form.json
  33. +3
    -1
      frappe/custom/doctype/customize_form/customize_form.py
  34. +7
    -2
      frappe/custom/doctype/customize_form/test_customize_form.py
  35. +9
    -2
      frappe/custom/doctype/customize_form_field/customize_form_field.json
  36. +3
    -1
      frappe/database/database.py
  37. +1
    -0
      frappe/database/mariadb/framework_mariadb.sql
  38. +1
    -0
      frappe/database/postgres/framework_postgres.sql
  39. +12
    -10
      frappe/database/postgres/schema.py
  40. +2
    -2
      frappe/database/postgres/setup_db.py
  41. +16
    -0
      frappe/desk/doctype/dashboard/dashboard_list.js
  42. +56
    -1
      frappe/desk/form/load.py
  43. +20
    -9
      frappe/desk/form/meta.py
  44. +1
    -1
      frappe/desk/page/setup_wizard/setup_wizard.py
  45. +17
    -7
      frappe/desk/query_report.py
  46. +3
    -2
      frappe/desk/reportview.py
  47. +50
    -6
      frappe/desk/search.py
  48. +1
    -1
      frappe/desk/treeview.py
  49. +1
    -1
      frappe/desk/utils.py
  50. +5
    -3
      frappe/email/doctype/email_domain/test_email_domain.py
  51. +12
    -8
      frappe/installer.py
  52. +7
    -0
      frappe/integrations/doctype/ldap_settings/ldap_settings.json
  53. +8
    -3
      frappe/integrations/doctype/ldap_settings/ldap_settings.py
  54. +130
    -59
      frappe/migrate.py
  55. +71
    -37
      frappe/model/base_document.py
  56. +2
    -2
      frappe/model/db_query.py
  57. +1
    -1
      frappe/model/delete_doc.py
  58. +1
    -5
      frappe/model/document.py
  59. +19
    -7
      frappe/model/meta.py
  60. +2
    -1
      frappe/model/naming.py
  61. +79
    -47
      frappe/model/rename_doc.py
  62. +1
    -1
      frappe/model/sync.py
  63. +6
    -2
      frappe/model/utils/rename_doc.py
  64. +3
    -2
      frappe/modules/import_file.py
  65. +1
    -0
      frappe/patches.txt
  66. +7
    -0
      frappe/public/icons/timeless/symbol-defs.svg
  67. +3
    -1
      frappe/public/js/frappe/form/controls/date.js
  68. +71
    -10
      frappe/public/js/frappe/form/controls/link.js
  69. +7
    -1
      frappe/public/js/frappe/form/controls/multiselect_pills.js
  70. +5
    -2
      frappe/public/js/frappe/form/controls/table_multiselect.js
  71. +26
    -13
      frappe/public/js/frappe/form/form.js
  72. +8
    -5
      frappe/public/js/frappe/form/formatters.js
  73. +7
    -8
      frappe/public/js/frappe/form/grid.js
  74. +27
    -10
      frappe/public/js/frappe/form/grid_row.js
  75. +12
    -8
      frappe/public/js/frappe/form/layout.js
  76. +12
    -13
      frappe/public/js/frappe/form/multi_select_dialog.js
  77. +30
    -21
      frappe/public/js/frappe/form/save.js
  78. +1
    -1
      frappe/public/js/frappe/form/tab.js
  79. +2
    -4
      frappe/public/js/frappe/form/toolbar.js
  80. +26
    -3
      frappe/public/js/frappe/list/base_list.js
  81. +1
    -1
      frappe/public/js/frappe/list/list_view.js
  82. +2
    -2
      frappe/public/js/frappe/model/meta.js
  83. +3
    -1
      frappe/public/js/frappe/model/model.js
  84. +8
    -0
      frappe/public/js/frappe/request.js
  85. +11
    -1
      frappe/public/js/frappe/ui/filters/filter.js
  86. +2
    -2
      frappe/public/js/frappe/ui/filters/filter_list.js
  87. +1
    -1
      frappe/public/js/frappe/ui/link_preview.js
  88. +37
    -0
      frappe/public/js/frappe/utils/utils.js
  89. +3
    -2
      frappe/public/js/frappe/views/reports/query_report.js
  90. +1
    -1
      frappe/public/js/frappe/views/reports/report_view.js
  91. +6
    -4
      frappe/public/scss/common/color_picker.scss
  92. +2
    -0
      frappe/public/scss/common/css_variables.scss
  93. +1
    -1
      frappe/public/scss/common/grid.scss
  94. +5
    -0
      frappe/public/scss/common/modal.scss
  95. +2
    -2
      frappe/public/scss/desk/frappe_datatable.scss
  96. +25
    -1
      frappe/public/scss/desk/list.scss
  97. +1
    -1
      frappe/public/scss/desk/sidebar.scss
  98. +4
    -0
      frappe/public/scss/website/web_form.scss
  99. +1
    -1
      frappe/realtime.py
  100. +1
    -1
      frappe/templates/print_formats/standard.html

+ 3
- 0
.git-blame-ignore-revs View File

@@ -13,3 +13,6 @@ fe20515c23a3ac41f1092bf0eaf0a0a452ec2e85


# Updating license headers # Updating license headers
34460265554242a8d05fb09f049033b1117e1a2b 34460265554242a8d05fb09f049033b1117e1a2b

# Refactor "not a in b" -> "a not in b"
745297a49d516e5e3c4bb3e1b0c4235e7d31165d

+ 4
- 2
.github/helper/install.sh View 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" == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi


if [ "$TYPE" == "ui" ]; then bench setup requirements --node; 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 # install node-sass which is required for website theme test
cd ./apps/frappe || exit cd ./apps/frappe || exit
@@ -60,4 +62,4 @@ cd ../..
bench start & bench start &
bench --site test_site reinstall --yes bench --site test_site reinstall --yes
if [ "$TYPE" == "server" ]; then bench --site test_site_producer reinstall --yes; fi 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 View File

@@ -41,6 +41,7 @@ if __name__ == "__main__":
# this is a push build, run all builds # this is a push build, run all builds
if not pr_number: if not pr_number:
os.system('echo "::set-output name=build::strawberry"') os.system('echo "::set-output name=build::strawberry"')
os.system('echo "::set-output name=build-server::strawberry"')
sys.exit(0) sys.exit(0)


files_list = files_list or get_files_list(pr_number=pr_number, repo=repo) 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)) 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_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_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: if ci_files_changed:
print("CI related files were updated, running all build processes.") 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.") print("Only Frontend code was updated; Stopping Python build process.")
sys.exit(0) 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"') os.system('echo "::set-output name=build::strawberry"')

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

@@ -141,6 +141,12 @@ jobs:
env: env:
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb 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 - name: Check If Coverage Report Exists
id: check_coverage id: check_coverage
uses: andstor/file-existence-action@v1 uses: andstor/file-existence-action@v1
@@ -156,3 +162,13 @@ jobs:
directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/ directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/
verbose: true verbose: true
flags: ui-tests 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 View File

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

{{ body }}

+ 1
- 1
README.md View File

@@ -27,7 +27,7 @@
<img src='https://www.codetriage.com/frappe/frappe/badges/users.svg'> <img src='https://www.codetriage.com/frappe/frappe/badges/users.svg'>
</a> </a>
<a href="https://codecov.io/gh/frappe/frappe"> <a href="https://codecov.io/gh/frappe/frappe">
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj&flag=server"/>
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/>
</a> </a>
</div> </div>




+ 30
- 0
cypress/fixtures/child_table_doctype.js View 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 View 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 View 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 View 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)', () => { it('should update dependant fields (via fetch_from)', () => {
cy.get('@todos').then(todos => { cy.get('@todos').then(todos => {
cy.visit(`/app/todo/${todos[0]}`); cy.visit(`/app/todo/${todos[0]}`);


+ 24
- 0
cypress/integration/dashboard_links.js View 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', () => { context('Dashboard links', () => {
before(() => { before(() => {
cy.visit('/login'); cy.visit('/login');
cy.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', () => { 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'); 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 View File

@@ -55,10 +55,31 @@ context('Depends On', () => {
'read_only_depends_on': "eval:doc.test_field=='Some Other Value'", 'read_only_depends_on': "eval:doc.test_field=='Some Other Value'",
'options': "Child Test Depends On" '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', () => { it('should set the field as mandatory depending on other fields value', () => {
cy.new_form('Test Depends On'); cy.new_form('Test Depends On');
cy.fill_field('test_field', 'Some Value'); cy.fill_field('test_field', 'Some Value');


+ 92
- 0
cypress/integration/grid.js View 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();

});
});
});


+ 1
- 0
cypress/integration/list_view.js View File

@@ -12,6 +12,7 @@ context('List View', () => {
cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true }); cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true });
cy.get('.actions-btn-group button').contains('Actions').should('be.visible'); cy.get('.actions-btn-group button').contains('Actions').should('be.visible');
cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh'); cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh');
cy.wait(3000); // wait before you hit another refresh
cy.get('button[data-original-title="Refresh"]').click(); cy.get('button[data-original-title="Refresh"]').click();
cy.wait('@list-refresh'); cy.wait('@list-refresh');
cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible'); cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible');


+ 48
- 14
cypress/integration/report_view.js View File

@@ -11,30 +11,64 @@ context('Report View', () => {
'title': 'Doc 1', 'title': 'Doc 1',
'description': 'Random Text', 'description': 'Random Text',
'enabled': 0, '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.', () => { it('Field with enabled allow_on_submit should be editable.', () => {
cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update'); cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update');
cy.visit(`/app/List/${doctype_name}/Report`); cy.visit(`/app/List/${doctype_name}/Report`);

// check status column added from docstatus // check status column added from docstatus
cy.get('.dt-row-0 > .dt-cell--col-3').should('contain', 'Submitted'); cy.get('.dt-row-0 > .dt-cell--col-3').should('contain', 'Submitted');
let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); let cell = cy.get('.dt-row-0 > .dt-cell--col-4');

// select the cell // select the cell
cell.dblclick(); cell.dblclick();
cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true }); cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true });
cy.get('.dt-row-0 > .dt-cell--col-3').click(); // click outside

cy.wait('@value-update'); cy.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 View File

@@ -159,7 +159,10 @@ def get_request_form_data():
else: else:
data = frappe.local.form_dict.data 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(): def validate_auth():
@@ -208,7 +211,6 @@ def validate_oauth(authorization_header):
pass pass





def validate_auth_via_api_keys(authorization_header): def validate_auth_via_api_keys(authorization_header):
""" """
Authenticate request using API keys and set session user Authenticate request using API keys and set session user


+ 10
- 0
frappe/boot.py View File

@@ -89,6 +89,7 @@ def get_bootinfo():
bootinfo.additional_filters_config = get_additional_filters_from_hooks() bootinfo.additional_filters_config = get_additional_filters_from_hooks()
bootinfo.desk_settings = get_desk_settings() bootinfo.desk_settings = get_desk_settings()
bootinfo.app_logo_url = get_app_logo() bootinfo.app_logo_url = get_app_logo()
bootinfo.link_title_doctypes = get_link_title_doctypes()


return bootinfo return bootinfo


@@ -324,6 +325,15 @@ def get_desk_settings():
def get_notification_settings(): def get_notification_settings():
return frappe.get_cached_doc('Notification Settings', frappe.session.user) 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): def set_time_zone(bootinfo):
bootinfo.time_zone = { bootinfo.time_zone = {
"system": get_time_zone(), "system": get_time_zone(),


+ 14
- 133
frappe/build.py View 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 # License: MIT. See LICENSE
import os import os
import re
import json
import shutil import shutil
import re
import subprocess import subprocess
from distutils.spawn import find_executable
from subprocess import getoutput from subprocess import getoutput
from io import StringIO
from tempfile import mkdtemp, mktemp 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 click
import psutil import psutil
from urllib.parse import urlparse
from semantic_version import Version
from requests import head from requests import head
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from semantic_version import Version


import frappe


timestamps = {} timestamps = {}
app_paths = None app_paths = None
@@ -32,6 +28,7 @@ class AssetsNotDownloadedError(Exception):
class AssetsDontExistError(HTTPError): class AssetsDontExistError(HTTPError):
pass pass



def download_file(url, prefix): def download_file(url, prefix):
from requests import get 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(f"{warn} Please install yarn using below command and try again.\nnpm install -g yarn")
click.echo() click.echo()



def get_node_env(): def get_node_env():
node_env = { node_env = {
"NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}" "NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}"
} }
return node_env return node_env



def get_safe_max_old_space_size(): def get_safe_max_old_space_size():
safe_max_old_space_size = 0 safe_max_old_space_size = 0
try: try:
@@ -296,6 +295,7 @@ def get_safe_max_old_space_size():


return safe_max_old_space_size return safe_max_old_space_size



def generate_assets_map(): def generate_assets_map():
symlinks = {} symlinks = {}


@@ -344,7 +344,6 @@ def clear_broken_symlinks():
os.remove(path) os.remove(path)





def unstrip(message: str) -> str: def unstrip(message: str) -> str:
"""Pads input string on the right side until the last available column in the terminal """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) 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): def scrub_html_template(content):
"""Returns HTML content with removed whitespace and comments""" """Returns HTML content with removed whitespace and comments"""
# remove whitespace to a single space # remove whitespace to a single space
@@ -496,37 +407,7 @@ def scrub_html_template(content):
return content.replace("'", "\'") 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))

+ 34
- 36
frappe/commands/site.py View File

@@ -19,36 +19,38 @@ from frappe.exceptions import SiteNotSpecifiedError
@click.option('--db-type', default='mariadb', type=click.Choice(['mariadb', 'postgres']), help='Optional "postgres" or "mariadb". Default is "mariadb"') @click.option('--db-type', default='mariadb', type=click.Choice(['mariadb', 'postgres']), help='Optional "postgres" or "mariadb". Default is "mariadb"')
@click.option('--db-host', help='Database Host') @click.option('--db-host', help='Database Host')
@click.option('--db-port', type=int, help='Database Port') @click.option('--db-port', type=int, help='Database Port')
@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB')
@click.option('--mariadb-root-password', help='Root password for MariaDB')
@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"')
@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--no-mariadb-socket', is_flag=True, default=False, help='Set MariaDB host to % and use TCP/IP Socket instead of using the UNIX Socket') @click.option('--no-mariadb-socket', is_flag=True, default=False, help='Set MariaDB host to % and use TCP/IP Socket instead of using the UNIX Socket')
@click.option('--admin-password', help='Administrator password for new site', default=None) @click.option('--admin-password', help='Administrator password for new site', default=None)
@click.option('--verbose', is_flag=True, default=False, help='Verbose') @click.option('--verbose', is_flag=True, default=False, help='Verbose')
@click.option('--force', help='Force restore if site/database already exists', is_flag=True, default=False) @click.option('--force', help='Force restore if site/database already exists', is_flag=True, default=False)
@click.option('--source_sql', help='Initiate database with a SQL file') @click.option('--source_sql', help='Initiate database with a SQL file')
@click.option('--install-app', multiple=True, help='Install app after installation') @click.option('--install-app', multiple=True, help='Install app after installation')
def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin_password=None,
verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False,
install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None):
@click.option('--set-default', is_flag=True, default=False, help='Set the new site as default site')
def new_site(site, db_root_username=None, db_root_password=None, admin_password=None,
verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False,
install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None,
set_default=False):
"Create a new site" "Create a new site"
from frappe.installer import _new_site from frappe.installer import _new_site


frappe.init(site=site, new_site=True) frappe.init(site=site, new_site=True)


_new_site(db_name, site, mariadb_root_username=mariadb_root_username,
mariadb_root_password=mariadb_root_password, admin_password=admin_password,
verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force,
no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host,
db_port=db_port, new_site=True)
_new_site(db_name, site, db_root_username=db_root_username,
db_root_password=db_root_password, admin_password=admin_password,
verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force,
no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host,
db_port=db_port, new_site=True)


if len(frappe.utils.get_sites()) == 1:
if set_default:
use(site) use(site)




@click.command('restore') @click.command('restore')
@click.argument('sql-file-path') @click.argument('sql-file-path')
@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB')
@click.option('--mariadb-root-password', help='Root password for MariaDB')
@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"')
@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--db-name', help='Database name for site in case it is a new one') @click.option('--db-name', help='Database name for site in case it is a new one')
@click.option('--admin-password', help='Administrator password for new site') @click.option('--admin-password', help='Administrator password for new site')
@click.option('--install-app', multiple=True, help='Install app after installation') @click.option('--install-app', multiple=True, help='Install app after installation')
@@ -57,7 +59,7 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin
@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended') @click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended')
@click.option('--encryption-key', help='Backup encryption key') @click.option('--encryption-key', help='Backup encryption key')
@pass_context @pass_context
def restore(context, sql_file_path, encryption_key=None, mariadb_root_username=None, mariadb_root_password=None,
def restore(context, sql_file_path, encryption_key=None, db_root_username=None, db_root_password=None,
db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None,
with_private_files=None): with_private_files=None):
"Restore site database from an sql file" "Restore site database from an sql file"
@@ -150,8 +152,8 @@ def restore(context, sql_file_path, encryption_key=None, mariadb_root_username=N




try: try:
_new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
mariadb_root_password=mariadb_root_password, admin_password=admin_password,
_new_site(frappe.conf.db_name, site, db_root_username=db_root_username,
db_root_password=db_root_password, admin_password=admin_password,
verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name, verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name,
force=True, db_type=frappe.conf.db_type) force=True, db_type=frappe.conf.db_type)


@@ -290,16 +292,16 @@ def partial_restore(context, sql_file_path, verbose, encryption_key=None):


@click.command('reinstall') @click.command('reinstall')
@click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--admin-password', help='Administrator Password for reinstalled site')
@click.option('--mariadb-root-username', help='Root username for MariaDB')
@click.option('--mariadb-root-password', help='Root password for MariaDB')
@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"')
@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--yes', is_flag=True, default=False, help='Pass --yes to skip confirmation') @click.option('--yes', is_flag=True, default=False, help='Pass --yes to skip confirmation')
@pass_context @pass_context
def reinstall(context, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False):
def reinstall(context, admin_password=None, db_root_username=None, db_root_password=None, yes=False):
"Reinstall site ie. wipe all data and start over" "Reinstall site ie. wipe all data and start over"
site = get_site(context) site = get_site(context)
_reinstall(site, admin_password, mariadb_root_username, mariadb_root_password, yes, verbose=context.verbose)
_reinstall(site, admin_password, db_root_username, db_root_password, yes, verbose=context.verbose)


def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False, verbose=False):
def _reinstall(site, admin_password=None, db_root_username=None, db_root_password=None, yes=False, verbose=False):
from frappe.installer import _new_site from frappe.installer import _new_site


if not yes: if not yes:
@@ -319,7 +321,7 @@ def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_ro


frappe.init(site=site) frappe.init(site=site)
_new_site(frappe.conf.db_name, site, verbose=verbose, force=True, reinstall=True, install_apps=installed, _new_site(frappe.conf.db_name, site, verbose=verbose, force=True, reinstall=True, install_apps=installed,
mariadb_root_username=mariadb_root_username, mariadb_root_password=mariadb_root_password,
db_root_username=db_root_username, db_root_password=db_root_password,
admin_password=admin_password) admin_password=admin_password)


@click.command('install-app') @click.command('install-app')
@@ -447,21 +449,17 @@ def disable_user(context, email):
@pass_context @pass_context
def migrate(context, skip_failing=False, skip_search_index=False): def migrate(context, skip_failing=False, skip_search_index=False):
"Run patches, sync schema and rebuild files/translations" "Run patches, sync schema and rebuild files/translations"
from frappe.migrate import migrate
from frappe.migrate import SiteMigration


for site in context.sites: for site in context.sites:
click.secho(f"Migrating {site}", fg="green") click.secho(f"Migrating {site}", fg="green")
frappe.init(site=site)
frappe.connect()
try: try:
migrate(
context.verbose,
SiteMigration(
skip_failing=skip_failing, skip_failing=skip_failing,
skip_search_index=skip_search_index
)
skip_search_index=skip_search_index,
).run(site=site)
finally: finally:
print() print()
frappe.destroy()
if not context.sites: if not context.sites:
raise SiteNotSpecifiedError raise SiteNotSpecifiedError


@@ -660,16 +658,16 @@ def uninstall(context, app, dry_run, yes, no_backup, force):


@click.command('drop-site') @click.command('drop-site')
@click.argument('site') @click.argument('site')
@click.option('--root-login', default='root')
@click.option('--root-password')
@click.option('--db-root-username', '--mariadb-root-username', '--root-login', help='Root username for MariaDB or PostgreSQL, Default is "root"')
@click.option('--db-root-password', '--mariadb-root-password', '--root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--archived-sites-path') @click.option('--archived-sites-path')
@click.option('--no-backup', is_flag=True, default=False) @click.option('--no-backup', is_flag=True, default=False)
@click.option('--force', help='Force drop-site even if an error is encountered', is_flag=True, default=False) @click.option('--force', help='Force drop-site even if an error is encountered', is_flag=True, default=False)
def drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False):
_drop_site(site, root_login, root_password, archived_sites_path, force, no_backup)
def drop_site(site, db_root_username='root', db_root_password=None, archived_sites_path=None, force=False, no_backup=False):
_drop_site(site, db_root_username, db_root_password, archived_sites_path, force, no_backup)




def _drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False):
def _drop_site(site, db_root_username=None, db_root_password=None, archived_sites_path=None, force=False, no_backup=False):
"Remove site from database and filesystem" "Remove site from database and filesystem"
from frappe.database import drop_user_and_database from frappe.database import drop_user_and_database
from frappe.utils.backups import scheduled_backup from frappe.utils.backups import scheduled_backup
@@ -694,7 +692,7 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
click.echo("\n".join(messages)) click.echo("\n".join(messages))
sys.exit(1) sys.exit(1)


drop_user_and_database(frappe.conf.db_name, root_login, root_password)
drop_user_and_database(frappe.conf.db_name, db_root_username, db_root_password)


archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites') archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites')




+ 9
- 3
frappe/commands/utils.py View File

@@ -640,6 +640,7 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal
skip_test_records=False, skip_before_tests=False, failfast=False, case=None): skip_test_records=False, skip_before_tests=False, failfast=False, case=None):


with CodeCoverage(coverage, app): with CodeCoverage(coverage, app):
import frappe
import frappe.test_runner import frappe.test_runner
tests = test tests = test
site = get_site(context) site = get_site(context)
@@ -742,8 +743,9 @@ def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=Fals
@click.option('--profile', is_flag=True, default=False) @click.option('--profile', is_flag=True, default=False)
@click.option('--noreload', "no_reload", 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('--nothreading', "no_threading", is_flag=True, default=False)
@click.option('--with-coverage', is_flag=True, default=False)
@pass_context @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" "Start development web server"
import frappe.app import frappe.app


@@ -751,8 +753,12 @@ def serve(context, port=None, profile=False, no_reload=False, no_threading=False
site = None site = None
else: else:
site = context.sites[0] 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') @click.command('request')


+ 7
- 0
frappe/core/doctype/doctype/doctype.js View File

@@ -33,9 +33,16 @@ frappe.ui.form.on('DocType', {
} }
} }


const customize_form_link = "<a href='/app/customize-form'>Customize Form</a>";
if(!frappe.boot.developer_mode && !frm.doc.custom) { if(!frappe.boot.developer_mode && !frm.doc.custom) {
// make the document read-only // make the document read-only
frm.set_read_only(); frm.set_read_only();
frm.dashboard.add_comment(__("DocTypes can not be modified, please use {0} instead", [customize_form_link]), "blue", true);
} else if (frappe.boot.developer_mode) {
let msg = __("This site is running in developer mode. Any change made here will be updated in code.");
msg += "<br>";
msg += __("If you just want to customize for your site, use {0} instead.", [customize_form_link]);
frm.dashboard.add_comment(msg, "yellow");
} }


if(frm.is_new()) { if(frm.is_new()) {


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

@@ -46,6 +46,7 @@
"allow_auto_repeat", "allow_auto_repeat",
"view_settings", "view_settings",
"title_field", "title_field",
"show_title_field_in_link",
"search_fields", "search_fields",
"default_print_format", "default_print_format",
"sort_field", "sort_field",
@@ -582,6 +583,12 @@
"fieldname": "document_states_section", "fieldname": "document_states_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Document States" "label": "Document States"
},
{
"default": "0",
"fieldname": "show_title_field_in_link",
"fieldtype": "Check",
"label": "Show Title in Link Fields"
} }
], ],
"icon": "fa fa-bolt", "icon": "fa fa-bolt",
@@ -663,7 +670,7 @@
"link_fieldname": "reference_doctype" "link_fieldname": "reference_doctype"
} }
], ],
"modified": "2021-12-09 14:53:10.717788",
"modified": "2022-01-07 16:07:06.196534",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocType", "name": "DocType",


+ 13
- 5
frappe/core/doctype/doctype/doctype.py View File

@@ -786,9 +786,10 @@ def validate_links_table_fieldnames(meta):


fieldnames = tuple(field.fieldname for field in meta.fields) fieldnames = tuple(field.fieldname for field in meta.fields)
for index, link in enumerate(meta.links, 1): 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, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype))
if not frappe.get_meta(link.link_doctype).has_field(link.link_fieldname):
message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format(
index, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype)
)
frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname")) frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname"))


if not link.is_child_table: if not link.is_child_table:
@@ -802,8 +803,15 @@ def validate_links_table_fieldnames(meta):
message = _("Document Links Row #{0}: Table Fieldname is mandatory for internal links").format(index) message = _("Document Links Row #{0}: Table Fieldname is mandatory for internal links").format(index)
frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing")) 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))
if meta.name == link.parent_doctype:
field_exists = link.table_fieldname in fieldnames
else:
field_exists = frappe.get_meta(link.parent_doctype).has_field(link.table_fieldname)

if not field_exists:
message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format(
index, frappe.bold(link.table_fieldname), frappe.bold(meta.name)
)
frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname")) frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname"))


def validate_fields_for_doctype(doctype): def validate_fields_for_doctype(doctype):


+ 7
- 0
frappe/core/doctype/doctype/test_doctype.py View File

@@ -498,6 +498,13 @@ class TestDocType(unittest.TestCase):
self.assertEqual(doc.is_virtual, 1) self.assertEqual(doc.is_virtual, 1)
self.assertFalse(frappe.db.table_exists('Test Virtual Doctype')) self.assertFalse(frappe.db.table_exists('Test Virtual Doctype'))


def test_default_fieldname(self):
fields = [{"label": "title", "fieldname": "title", "fieldtype": "Data", "default": "{some_fieldname}"}]
dt = new_doctype("DT with default field", fields=fields)
dt.insert()

dt.delete()

def new_doctype(name, unique=0, depends_on='', fields=None): def new_doctype(name, unique=0, depends_on='', fields=None):
doc = frappe.get_doc({ doc = frappe.get_doc({
"doctype": "DocType", "doctype": "DocType",


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

@@ -745,7 +745,7 @@ def delete_file(path):
"""Delete file from `public folder`""" """Delete file from `public folder`"""
if path: if path:
if ".." in path.split("/"): 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("/")) parts = os.path.split(path.strip("/"))
if parts[0]=="files": if parts[0]=="files":


+ 53
- 1
frappe/core/doctype/report/test_report.py View File

@@ -3,7 +3,7 @@


import frappe, json, os import frappe, json, os
import unittest 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.desk.reportview import delete_report, save_report as _save_report
from frappe.custom.doctype.customize_form.customize_form import reset_customization from frappe.custom.doctype.customize_form.customize_form import reset_customization
from frappe.core.doctype.user_permission.test_user_permission import create_user from frappe.core.doctype.user_permission.test_user_permission import create_user
@@ -282,3 +282,55 @@ result = [


# Set user back to administrator # Set user back to administrator
frappe.set_user('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)

+ 1
- 1
frappe/core/doctype/user/test_user.py View File

@@ -356,7 +356,7 @@ class TestUser(unittest.TestCase):
self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/") self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/")
update_password(old_password, old_password=new_password) update_password(old_password, old_password=new_password)
self.assertEqual( self.assertEqual(
json.loads(frappe.message_log[0]).get("message"),
json.loads(frappe.message_log[0]).get("message"),
"Password reset instructions have been sent to your email" "Password reset instructions have been sent to your email"
) )




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

@@ -347,6 +347,7 @@ frappe.PermissionEngine = class PermissionEngine {
} }


add_check_events() { add_check_events() {
let me = this;
this.body.on("click", ".show-user-permissions", () => { this.body.on("click", ".show-user-permissions", () => {
frappe.route_options = { allow: this.get_doctype() || "" }; frappe.route_options = { allow: this.get_doctype() || "" };
frappe.set_route('List', 'User Permission'); frappe.set_route('List', 'User Permission');
@@ -373,7 +374,7 @@ frappe.PermissionEngine = class PermissionEngine {
// exception: reverse // exception: reverse
chk.prop("checked", !chk.prop("checked")); chk.prop("checked", !chk.prop("checked"));
} else { } else {
this.get_perm(args.role)[args.ptype] = args.value;
me.get_perm(args.role)[args.ptype] = args.value;
} }
} }
}); });


+ 1
- 0
frappe/coverage.py View File

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


+ 56
- 0
frappe/custom/doctype/client_script/client_script.js View File

@@ -2,6 +2,9 @@
// For license information, please see license.txt // For license information, please see license.txt


frappe.ui.form.on('Client Script', { frappe.ui.form.on('Client Script', {
setup(frm) {
frm.get_field("sample").html(SAMPLE_HTML);
},
refresh(frm) { refresh(frm) {
if (frm.doc.dt && frm.doc.script) { if (frm.doc.dt && frm.doc.script) {
frm.add_custom_button(__('Go to {0}', [frm.doc.dt]), frm.add_custom_button(__('Go to {0}', [frm.doc.dt]),
@@ -97,3 +100,56 @@ frappe.ui.form.on('${doctype}', {
frm.set_value('script', script + boilerplate); frm.set_value('script', script + boilerplate);
} }
}); });

const SAMPLE_HTML = `<h3>Client Script Help</h3>
<p>Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started</p>
<pre><code>

// fetch local_tax_no on selection of customer
// cur_frm.add_fetch(link_field, source_fieldname, target_fieldname);
cur_frm.add_fetch("customer", "local_tax_no', 'local_tax_no');

// additional validation on dates
frappe.ui.form.on('Task', 'validate', function(frm) {
if (frm.doc.from_date &lt; get_today()) {
msgprint('You can not select past date in From Date');
validated = false;
}
});

// make a field read-only after saving
frappe.ui.form.on('Task', {
refresh: function(frm) {
// use the __islocal value of doc, to check if the doc is saved or not
frm.set_df_property('myfield', 'read_only', frm.doc.__islocal ? 0 : 1);
}
});

// additional permission check
frappe.ui.form.on('Task', {
validate: function(frm) {
if(user=='user1@example.com' &amp;&amp; frm.doc.purpose!='Material Receipt') {
msgprint('You are only allowed Material Receipt');
validated = false;
}
}
});

// calculate sales incentive
frappe.ui.form.on('Sales Invoice', {
validate: function(frm) {
// calculate incentives for each person on the deal
total_incentive = 0
$.each(frm.doc.sales_team, function(i, d) {
// calculate incentive
var incentive_percent = 2;
if(frm.doc.base_grand_total &gt; 400) incentive_percent = 4;
// actual incentive
d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;
total_incentive += flt(d.incentives)
});
frm.doc.total_incentive = total_incentive;
}
})

</code></pre>`;

+ 3
- 3
frappe/custom/doctype/client_script/client_script.json View File

@@ -40,8 +40,7 @@
{ {
"fieldname": "sample", "fieldname": "sample",
"fieldtype": "HTML", "fieldtype": "HTML",
"label": "Sample",
"options": "<h3>Client Script Help</h3>\n<p>Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started</p>\n<pre><code>\n\n// fetch local_tax_no on selection of customer \n// cur_frm.add_fetch(link_field, source_fieldname, target_fieldname); \ncur_frm.add_fetch('customer', 'local_tax_no', 'local_tax_no');\n\n// additional validation on dates \nfrappe.ui.form.on('Task', 'validate', function(frm) {\n if (frm.doc.from_date &lt; get_today()) {\n msgprint('You can not select past date in From Date');\n validated = false;\n } \n});\n\n// make a field read-only after saving \nfrappe.ui.form.on('Task', {\n refresh: function(frm) {\n // use the __islocal value of doc, to check if the doc is saved or not\n frm.set_df_property('myfield', 'read_only', frm.doc.__islocal ? 0 : 1);\n } \n});\n\n// additional permission check\nfrappe.ui.form.on('Task', {\n validate: function(frm) {\n if(user=='user1@example.com' &amp;&amp; frm.doc.purpose!='Material Receipt') {\n msgprint('You are only allowed Material Receipt');\n validated = false;\n }\n } \n});\n\n// calculate sales incentive\nfrappe.ui.form.on('Sales Invoice', {\n validate: function(frm) {\n // calculate incentives for each person on the deal\n total_incentive = 0\n $.each(frm.doc.sales_team, function(i, d) {\n // calculate incentive\n var incentive_percent = 2;\n if(frm.doc.base_grand_total &gt; 400) incentive_percent = 4;\n // actual incentive\n d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;\n total_incentive += flt(d.incentives)\n });\n frm.doc.total_incentive = total_incentive;\n } \n})\n\n</code></pre>"
"label": "Sample"
}, },
{ {
"default": "0", "default": "0",
@@ -76,7 +75,7 @@
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-09-04 12:03:27.029815",
"modified": "2022-02-18 00:43:33.941466",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Client Script", "name": "Client Script",
@@ -107,5 +106,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "ASC", "sort_order": "ASC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

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

@@ -27,6 +27,7 @@
"autoname", "autoname",
"view_settings_section", "view_settings_section",
"title_field", "title_field",
"show_title_field_in_link",
"image_field", "image_field",
"default_print_format", "default_print_format",
"column_break_29", "column_break_29",
@@ -296,6 +297,12 @@
"fieldtype": "Table", "fieldtype": "Table",
"label": "States", "label": "States",
"options": "DocType State" "options": "DocType State"
},
{
"default": "0",
"fieldname": "show_title_field_in_link",
"fieldtype": "Check",
"label": "Show Title in Link Fields"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
@@ -304,7 +311,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2021-12-14 16:45:04.308690",
"modified": "2022-01-07 16:07:06.196534",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customize Form", "name": "Customize Form",


+ 3
- 1
frappe/custom/doctype/customize_form/customize_form.py View File

@@ -516,7 +516,8 @@ doctype_properties = {
'email_append_to': 'Check', 'email_append_to': 'Check',
'subject_field': 'Data', 'subject_field': 'Data',
'sender_field': 'Data', 'sender_field': 'Data',
'autoname': 'Data'
'autoname': 'Data',
'show_title_field_in_link': 'Check'
} }


docfield_properties = { docfield_properties = {
@@ -539,6 +540,7 @@ docfield_properties = {
'in_global_search': 'Check', 'in_global_search': 'Check',
'in_preview': 'Check', 'in_preview': 'Check',
'bold': 'Check', 'bold': 'Check',
'no_copy': 'Check',
'hidden': 'Check', 'hidden': 'Check',
'collapsible': 'Check', 'collapsible': 'Check',
'collapsible_depends_on': 'Data', 'collapsible_depends_on': 'Data',


+ 7
- 2
frappe/custom/doctype/customize_form/test_customize_form.py View File

@@ -97,13 +97,18 @@ class TestCustomizeForm(unittest.TestCase):


custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0] custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0]
custom_field.reqd = 1 custom_field.reqd = 1
custom_field.no_copy = 1
d.run_method("save_customization") d.run_method("save_customization")
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1) self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 1)


custom_field = d.get("fields", {"is_custom_field": True})[0] custom_field = d.get("fields", {"is_custom_field": True})[0]
custom_field.reqd = 0 custom_field.reqd = 0
custom_field.no_copy = 0
d.run_method("save_customization") d.run_method("save_customization")
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0) self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 0)



def test_save_customization_new_field(self): def test_save_customization_new_field(self):
d = self.get_customize_form("Event") d = self.get_customize_form("Event")
@@ -257,7 +262,7 @@ class TestCustomizeForm(unittest.TestCase):
frappe.clear_cache() frappe.clear_cache()
d = self.get_customize_form("User Group") d = self.get_customize_form("User Group")


d.append('links', dict(link_doctype='User Group Member', parent_doctype='User',
d.append('links', dict(link_doctype='User Group Member', parent_doctype='User Group',
link_fieldname='user', table_fieldname='user_group_members', group='Tests', custom=1)) link_fieldname='user', table_fieldname='user_group_members', group='Tests', custom=1))


d.run_method("save_customization") d.run_method("save_customization")
@@ -267,7 +272,7 @@ class TestCustomizeForm(unittest.TestCase):


# check links exist # check links exist
self.assertTrue([d.name for d in user_group.links if d.link_doctype == 'User Group Member']) self.assertTrue([d.name for d in user_group.links if d.link_doctype == 'User Group Member'])
self.assertTrue([d.name for d in user_group.links if d.parent_doctype == 'User'])
self.assertTrue([d.name for d in user_group.links if d.parent_doctype == 'User Group'])


# remove the link # remove the link
d = self.get_customize_form("User Group") d = self.get_customize_form("User Group")


+ 9
- 2
frappe/custom/doctype/customize_form_field/customize_form_field.json View File

@@ -20,6 +20,7 @@
"in_global_search", "in_global_search",
"in_preview", "in_preview",
"bold", "bold",
"no_copy",
"allow_in_quick_entry", "allow_in_quick_entry",
"translatable", "translatable",
"column_break_7", "column_break_7",
@@ -437,13 +438,19 @@
"fieldname": "show_dashboard", "fieldname": "show_dashboard",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Show Dashboard" "label": "Show Dashboard"
},
{
"default": "0",
"fieldname": "no_copy",
"fieldtype": "Check",
"label": "No Copy"
} }
], ],
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-01-27 21:45:22.349776",
"modified": "2022-02-08 19:38:16.111199",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customize Form Field", "name": "Customize Form Field",
@@ -453,4 +460,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "ASC", "sort_order": "ASC",
"states": [] "states": []
}
}

+ 3
- 1
frappe/database/database.py View File

@@ -177,6 +177,8 @@ class Database(object):
raise frappe.QueryTimeoutError(e) raise frappe.QueryTimeoutError(e)


elif frappe.conf.db_type == 'postgres': elif frappe.conf.db_type == 'postgres':
# TODO: added temporarily
print(e)
raise raise


if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)):
@@ -582,7 +584,7 @@ class Database(object):
company = frappe.db.get_single_value('Global Defaults', 'default_company') company = frappe.db.get_single_value('Global Defaults', 'default_company')
""" """


if not doctype in self.value_cache:
if doctype not in self.value_cache:
self.value_cache[doctype] = {} self.value_cache[doctype] = {}


if cache and fieldname in self.value_cache[doctype]: if cache and fieldname in self.value_cache[doctype]:


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

@@ -224,6 +224,7 @@ CREATE TABLE `tabDocType` (
`email_append_to` int(1) NOT NULL DEFAULT 0, `email_append_to` int(1) NOT NULL DEFAULT 0,
`subject_field` varchar(255) DEFAULT NULL, `subject_field` varchar(255) DEFAULT NULL,
`sender_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, `migration_hash` varchar(255) DEFAULT NULL,
PRIMARY KEY (`name`) PRIMARY KEY (`name`)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;


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

@@ -229,6 +229,7 @@ CREATE TABLE "tabDocType" (
"email_append_to" smallint NOT NULL DEFAULT 0, "email_append_to" smallint NOT NULL DEFAULT 0,
"subject_field" varchar(255) DEFAULT NULL, "subject_field" varchar(255) DEFAULT NULL,
"sender_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, "migration_hash" varchar(255) DEFAULT NULL,
PRIMARY KEY ("name") PRIMARY KEY ("name")
) ; ) ;


+ 12
- 10
frappe/database/postgres/schema.py View File

@@ -5,29 +5,29 @@ from frappe.database.schema import DBTable, get_definition


class PostgresTable(DBTable): class PostgresTable(DBTable):
def create(self): def create(self):
add_text = ""
varchar_len = frappe.db.VARCHAR_LEN


additional_definitions = ""
# columns # columns
column_defs = self.get_column_definitions() column_defs = self.get_column_definitions()
if column_defs: if column_defs:
add_text += ",\n".join(column_defs)
additional_definitions += ",\n".join(column_defs)


# child table columns # child table columns
if self.meta.get("istable") or 0: if self.meta.get("istable") or 0:
if column_defs: if column_defs:
add_text += ",\n"
additional_definitions += ",\n"


add_text += ",\n".join(
additional_definitions += ",\n".join(
( (
"parent varchar({varchar_len})",
"parentfield varchar({varchar_len})",
"parenttype varchar({varchar_len})"
f"parent varchar({varchar_len})",
f"parentfield varchar({varchar_len})",
f"parenttype varchar({varchar_len})",
) )
) )


# TODO: set docstatus length
# create table # create table
frappe.db.sql(("""create table `%s` (
frappe.db.sql(f"""create table `{self.table_name}` (
name varchar({varchar_len}) not null primary key, name varchar({varchar_len}) not null primary key,
creation timestamp(6), creation timestamp(6),
modified timestamp(6), modified timestamp(6),
@@ -35,7 +35,9 @@ class PostgresTable(DBTable):
owner varchar({varchar_len}), owner varchar({varchar_len}),
docstatus smallint not null default '0', docstatus smallint not null default '0',
idx bigint not null default '0', idx bigint not null default '0',
%s)""" % (self.table_name, add_text)).format(varchar_len=frappe.db.VARCHAR_LEN))
{additional_definitions}
)"""
)


self.create_indexes() self.create_indexes()
frappe.db.commit() frappe.db.commit()


+ 2
- 2
frappe/database/postgres/setup_db.py View File

@@ -4,7 +4,7 @@ import frappe




def setup_database(force, source_sql=None, verbose=False): def setup_database(force, source_sql=None, verbose=False):
root_conn = get_root_connection()
root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password)
root_conn.commit() root_conn.commit()
root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(frappe.conf.db_name)) root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(frappe.conf.db_name))
root_conn.sql("DROP USER IF EXISTS {0}".format(frappe.conf.db_name)) root_conn.sql("DROP USER IF EXISTS {0}".format(frappe.conf.db_name))
@@ -70,7 +70,7 @@ def import_db_from_sql(source_sql=None, verbose=False):
print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}") print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}")


def setup_help_database(help_db_name): def setup_help_database(help_db_name):
root_conn = get_root_connection()
root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password)
root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(help_db_name)) root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(help_db_name))
root_conn.sql("DROP USER IF EXISTS {0}".format(help_db_name)) root_conn.sql("DROP USER IF EXISTS {0}".format(help_db_name))
root_conn.sql("CREATE DATABASE `{0}`".format(help_db_name)) root_conn.sql("CREATE DATABASE `{0}`".format(help_db_name))


+ 16
- 0
frappe/desk/doctype/dashboard/dashboard_list.js View 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 View File

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


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


@frappe.whitelist() @frappe.whitelist()
@@ -367,6 +367,60 @@ def get_additional_timeline_content(doctype, docname):


return contents 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): def update_user_info(docinfo):
for d in docinfo.communications: for d in docinfo.communications:
frappe.utils.add_user_info(d.sender, docinfo.user_info) 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) frappe.utils.add_user_info(user, user_info)


return user_info return user_info


+ 20
- 9
frappe/desk/form/meta.py View File

@@ -12,6 +12,15 @@ from frappe.translate import extract_messages_from_code, make_dict_from_messages
from frappe.utils import get_html_format from frappe.utils import get_html_format




ASSET_KEYS = (
"__js", "__css", "__list_js", "__calendar_js", "__map_js",
"__linked_with", "__messages", "__print_formats", "__workflow_docs",
"__form_grid_templates", "__listview_template", "__tree_js",
"__dashboard", "__kanban_column_fields", '__templates',
'__custom_js', '__custom_list_js'
)


def get_meta(doctype, cached=True): def get_meta(doctype, cached=True):
# don't cache for developer mode as js files, templates may be edited # don't cache for developer mode as js files, templates may be edited
if cached and not frappe.conf.developer_mode: if cached and not frappe.conf.developer_mode:
@@ -34,6 +43,12 @@ class FormMeta(Meta):
super(FormMeta, self).__init__(doctype) super(FormMeta, self).__init__(doctype)
self.load_assets() self.load_assets()


def set(self, key, value, *args, **kwargs):
if key in ASSET_KEYS:
self.__dict__[key] = value
else:
super(FormMeta, self).set(key, value, *args, **kwargs)

def load_assets(self): def load_assets(self):
if self.get('__assets_loaded', False): if self.get('__assets_loaded', False):
return return
@@ -55,11 +70,7 @@ class FormMeta(Meta):
def as_dict(self, no_nulls=False): def as_dict(self, no_nulls=False):
d = super(FormMeta, self).as_dict(no_nulls=no_nulls) d = super(FormMeta, self).as_dict(no_nulls=no_nulls)


for k in ("__js", "__css", "__list_js", "__calendar_js", "__map_js",
"__linked_with", "__messages", "__print_formats", "__workflow_docs",
"__form_grid_templates", "__listview_template", "__tree_js",
"__dashboard", "__kanban_column_fields", '__templates',
'__custom_js', '__custom_list_js'):
for k in ASSET_KEYS:
d[k] = self.get(k) d[k] = self.get(k)


# d['fields'] = d.get('fields', []) # d['fields'] = d.get('fields', [])
@@ -172,7 +183,7 @@ class FormMeta(Meta):
WHERE doc_type=%s AND docstatus<2 and disabled=0""", (self.name,), as_dict=1, WHERE doc_type=%s AND docstatus<2 and disabled=0""", (self.name,), as_dict=1,
update={"doctype":"Print Format"}) update={"doctype":"Print Format"})


self.set("__print_formats", print_formats, as_value=True)
self.set("__print_formats", print_formats)


def load_workflows(self): def load_workflows(self):
# get active workflow # get active workflow
@@ -186,7 +197,7 @@ class FormMeta(Meta):
for d in workflow.get("states"): for d in workflow.get("states"):
workflow_docs.append(frappe.get_doc("Workflow State", d.state)) workflow_docs.append(frappe.get_doc("Workflow State", d.state))


self.set("__workflow_docs", workflow_docs, as_value=True)
self.set("__workflow_docs", workflow_docs)




def load_templates(self): def load_templates(self):
@@ -208,7 +219,7 @@ class FormMeta(Meta):
for content in self.get("__form_grid_templates").values(): for content in self.get("__form_grid_templates").values():
messages = extract_messages_from_code(content) messages = extract_messages_from_code(content)
messages = make_dict_from_messages(messages) messages = make_dict_from_messages(messages)
self.get("__messages").update(messages, as_value=True)
self.get("__messages").update(messages)


def load_dashboard(self): def load_dashboard(self):
self.set('__dashboard', self.get_dashboard_data()) self.set('__dashboard', self.get_dashboard_data())
@@ -224,7 +235,7 @@ class FormMeta(Meta):


fields = [x['field_name'] for x in values] fields = [x['field_name'] for x in values]
fields = list(set(fields)) fields = list(set(fields))
self.set("__kanban_column_fields", fields, as_value=True)
self.set("__kanban_column_fields", fields)
except frappe.PermissionError: except frappe.PermissionError:
# no access to kanban board # no access to kanban board
pass pass


+ 1
- 1
frappe/desk/page/setup_wizard/setup_wizard.py View File

@@ -392,7 +392,7 @@ def make_records(records, debug=False):
doc.flags.ignore_mandatory = True doc.flags.ignore_mandatory = True


try: try:
doc.insert(ignore_permissions=True)
doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
frappe.db.commit() frappe.db.commit()


except frappe.DuplicateEntryError as e: except frappe.DuplicateEntryError as e:


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

@@ -73,7 +73,7 @@ def get_report_result(report, filters):
return res return res


@frappe.read_only() @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 user = user or frappe.session.user
filters = filters or [] 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) result = get_filtered_data(report.ref_doctype, columns, result, user)


if cint(report.add_total_row) and result and not skip_total_row: 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 { return {
"result": result, "result": result,
@@ -210,7 +210,7 @@ def get_script(report_name):


@frappe.whitelist() @frappe.whitelist()
@frappe.read_only() @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) report = get_report_doc(report_name)
if not user: if not user:
user = frappe.session.user user = frappe.session.user
@@ -238,7 +238,7 @@ def run(report_name, filters=None, user=None, ignore_prepared_report=False, cust
dn = "" dn = ""
result = get_prepared_report_result(report, filters, dn, user) result = get_prepared_report_result(report, filters, dn, user)
else: 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( result["add_total_row"] = report.add_total_row and not result.get(
"skip_total_row", False "skip_total_row", False
@@ -435,9 +435,19 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visi
return result, column_widths 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) total_row = [""] * len(columns)
has_percent = [] 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): for i, col in enumerate(columns):
fieldtype, options, fieldname = None, None, None fieldtype, options, fieldname = None, None, None
if isinstance(col, str): if isinstance(col, str):
@@ -464,12 +474,12 @@ def add_total_row(result, columns, meta=None):
for row in result: for row in result:
if i >= len(row): if i >= len(row):
continue continue

cell = row.get(fieldname) if isinstance(row, dict) else row[i] cell = row.get(fieldname) if isinstance(row, dict) else row[i]
if fieldtype in ["Currency", "Int", "Float", "Percent", "Duration"] and flt( if fieldtype in ["Currency", "Int", "Float", "Percent", "Duration"] and flt(
cell 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: if fieldtype == "Percent" and i not in has_percent:
has_percent.append(i) has_percent.append(i)


+ 3
- 2
frappe/desk/reportview.py View File

@@ -533,7 +533,8 @@ def get_stats(stats, doctype, filters=None):
columns = [] columns = []


for tag in tags: for tag in tags:
if not tag in columns: continue
if tag not in columns:
continue
try: try:
tag_count = frappe.get_list(doctype, tag_count = frappe.get_list(doctype,
fields=[tag, "count(*)"], fields=[tag, "count(*)"],
@@ -612,7 +613,7 @@ def scrub_user_tags(tagcount):
alltags = t.split(',') alltags = t.split(',')
for tag in alltags: for tag in alltags:
if tag: if tag:
if not tag in rdict:
if tag not in rdict:
rdict[tag] = 0 rdict[tag] = 0


rdict[tag] += tagdict[t] rdict[tag] += tagdict[t]


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

@@ -49,8 +49,10 @@ def sanitize_searchfield(searchfield):
# this is called by the Link Field # this is called by the Link Field
@frappe.whitelist() @frappe.whitelist()
def search_link(doctype, txt, query=None, filters=None, page_length=20, searchfield=None, reference_doctype=None, ignore_user_permissions=False): 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"] del frappe.response["values"]


# this is called by the search box # 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))) fields = list(set(fields + json.loads(filter_fields)))
formatted_fields = ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in 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. # 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( formatted_fields.append("""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype)) _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype))
@@ -205,11 +213,38 @@ def get_std_fields_list(meta, key):


return sflist 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 = [] 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 return results


def scrub_custom_query(query, key, txt): 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={ return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={
'is_group': True '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
- 1
frappe/desk/treeview.py View File

@@ -15,7 +15,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters):


tree_method = frappe.get_attr(tree_method) tree_method = frappe.get_attr(tree_method)


if not tree_method in frappe.whitelisted:
if tree_method not in frappe.whitelisted:
frappe.throw(_("Not Permitted"), frappe.PermissionError) frappe.throw(_("Not Permitted"), frappe.PermissionError)


data = tree_method(doctype, parent, **filters) data = tree_method(doctype, parent, **filters)


+ 1
- 1
frappe/desk/utils.py View File

@@ -20,4 +20,4 @@ def validate_route_conflict(doctype, name):
raise frappe.NameError raise frappe.NameError


def slug(name): def slug(name):
return name.lower().replace(' ', '-')
return name.lower().replace(' ', '-')

+ 5
- 3
frappe/email/doctype/email_domain/test_email_domain.py View File

@@ -20,11 +20,13 @@ class TestDomain(unittest.TestCase):
mail_domain = frappe.get_doc("Email Domain", "test.com") mail_domain = frappe.get_doc("Email Domain", "test.com")
mail_account = frappe.get_doc("Email Account", "Test") mail_account = frappe.get_doc("Email Account", "Test")


# Initially, incoming_port is different in domain and account
self.assertNotEqual(mail_account.incoming_port, mail_domain.incoming_port)
# Ensure a different port
mail_account.incoming_port = int(mail_domain.incoming_port) + 5
mail_account.save()
# Trigger update of accounts using this domain # Trigger update of accounts using this domain
mail_domain.on_update() mail_domain.on_update()
mail_account = frappe.get_doc("Email Account", "Test")

mail_account.reload()
# After update, incoming_port in account should match the domain # After update, incoming_port in account should match the domain
self.assertEqual(mail_account.incoming_port, mail_domain.incoming_port) self.assertEqual(mail_account.incoming_port, mail_domain.incoming_port)




+ 12
- 8
frappe/installer.py View File

@@ -14,8 +14,8 @@ from frappe.defaults import _clear_cache
def _new_site( def _new_site(
db_name, db_name,
site, site,
mariadb_root_username=None,
mariadb_root_password=None,
db_root_username=None,
db_root_password=None,
admin_password=None, admin_password=None,
verbose=False, verbose=False,
install_apps=None, install_apps=None,
@@ -60,8 +60,8 @@ def _new_site(
installing = touch_file(get_site_path("locks", "installing.lock")) installing = touch_file(get_site_path("locks", "installing.lock"))


install_db( install_db(
root_login=mariadb_root_username,
root_password=mariadb_root_password,
root_login=db_root_username,
root_password=db_root_password,
db_name=db_name, db_name=db_name,
admin_password=admin_password, admin_password=admin_password,
verbose=verbose, verbose=verbose,
@@ -92,7 +92,7 @@ def _new_site(
print("*** Scheduler is", scheduler_status, "***") print("*** Scheduler is", scheduler_status, "***")




def install_db(root_login="root", root_password=None, db_name=None, source_sql=None,
def install_db(root_login=None, root_password=None, db_name=None, source_sql=None,
admin_password=None, verbose=True, force=0, site_config=None, reinstall=False, admin_password=None, verbose=True, force=0, site_config=None, reinstall=False,
db_password=None, db_type=None, db_host=None, db_port=None, no_mariadb_socket=False): db_password=None, db_type=None, db_host=None, db_port=None, no_mariadb_socket=False):
import frappe.database import frappe.database
@@ -101,6 +101,11 @@ def install_db(root_login="root", root_password=None, db_name=None, source_sql=N
if not db_type: if not db_type:
db_type = frappe.conf.db_type or 'mariadb' db_type = frappe.conf.db_type or 'mariadb'


if not root_login and db_type == 'mariadb':
root_login='root'
elif not root_login and db_type == 'postgres':
root_login='postgres'

make_conf(db_name, site_config=site_config, db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port) make_conf(db_name, site_config=site_config, db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port)
frappe.flags.in_install_db = True frappe.flags.in_install_db = True


@@ -184,7 +189,7 @@ def install_app(name, verbose=False, set_as_patched=True):


def add_to_installed_apps(app_name, rebuild_website=True): def add_to_installed_apps(app_name, rebuild_website=True):
installed_apps = frappe.get_installed_apps() installed_apps = frappe.get_installed_apps()
if not app_name in installed_apps:
if app_name not in installed_apps:
installed_apps.append(app_name) installed_apps.append(app_name)
frappe.db.set_global("installed_apps", json.dumps(installed_apps)) frappe.db.set_global("installed_apps", json.dumps(installed_apps))
frappe.db.commit() frappe.db.commit()
@@ -529,10 +534,9 @@ def extract_sql_gzip(sql_gz_path):
import subprocess import subprocess


try: try:
# dvf - decompress, verbose, force
original_file = sql_gz_path original_file = sql_gz_path
decompressed_file = original_file.rstrip(".gz") 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) subprocess.check_call(cmd, shell=True)
except Exception: except Exception:
raise raise


+ 7
- 0
frappe/integrations/doctype/ldap_settings/ldap_settings.json View File

@@ -38,6 +38,7 @@
"local_ca_certs_file", "local_ca_certs_file",
"ldap_custom_settings_section", "ldap_custom_settings_section",
"ldap_group_objectclass", "ldap_group_objectclass",
"ldap_custom_group_search",
"column_break_33", "column_break_33",
"ldap_group_member_attribute", "ldap_group_member_attribute",
"ldap_group_mappings_section", "ldap_group_mappings_section",
@@ -247,6 +248,12 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Group Object Class" "label": "Group Object Class"
}, },
{
"description": "string value, i.e. {0} or uid={0},ou=users,dc=example,dc=com",
"fieldname": "ldap_custom_group_search",
"fieldtype": "Data",
"label": "Custom Group Search"
},
{ {
"description": "Requires any valid fdn path. i.e. ou=users,dc=example,dc=com", "description": "Requires any valid fdn path. i.e. ou=users,dc=example,dc=com",
"fieldname": "ldap_search_path_user", "fieldname": "ldap_search_path_user",


+ 8
- 3
frappe/integrations/doctype/ldap_settings/ldap_settings.py View File

@@ -45,10 +45,14 @@ class LDAPSettings(Document):
title=_("Misconfigured")) title=_("Misconfigured"))


if self.ldap_directory_server.lower() == 'custom': if self.ldap_directory_server.lower() == 'custom':
if not self.ldap_group_member_attribute or not self.ldap_group_mappings_section:
frappe.throw(_("Custom LDAP Directoy Selected, please ensure 'LDAP Group Member attribute' and 'LDAP Group Mappings' are entered"),
if not self.ldap_group_member_attribute or not self.ldap_group_objectclass:
frappe.throw(_("Custom LDAP Directoy Selected, please ensure 'LDAP Group Member attribute' and 'Group Object Class' are entered"),
title=_("Misconfigured")) title=_("Misconfigured"))


if self.ldap_custom_group_search and "{0}" not in self.ldap_custom_group_search:
frappe.throw(_("Custom Group Search if filled needs to contain the user placeholder {0}, eg uid={0},ou=users,dc=example,dc=com"),
title=_("Misconfigured"))

else: else:
frappe.throw(_("LDAP Search String must be enclosed in '()' and needs to contian the user placeholder {0}, eg sAMAccountName={0}")) frappe.throw(_("LDAP Search String must be enclosed in '()' and needs to contian the user placeholder {0}, eg sAMAccountName={0}"))


@@ -209,7 +213,8 @@ class LDAPSettings(Document):


ldap_object_class = self.ldap_group_objectclass ldap_object_class = self.ldap_group_objectclass
ldap_group_members_attribute = self.ldap_group_member_attribute ldap_group_members_attribute = self.ldap_group_member_attribute
user_search_str = getattr(user, self.ldap_username_field).value
ldap_custom_group_search = self.ldap_custom_group_search or "{0}"
user_search_str = ldap_custom_group_search.format(getattr(user, self.ldap_username_field).value)


else: else:
# NOTE: depreciate this else path # NOTE: depreciate this else path


+ 130
- 59
frappe/migrate.py View 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 # License: MIT. See LICENSE


import json import json
import os import os
import sys
from textwrap import dedent

import frappe import frappe
import frappe.translate
import frappe.modules.patch_handler
import frappe.model.sync 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.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.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.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.database.schema import add_column
from frappe.desk.notifications import clear_notifications
from frappe.modules.patch_handler import PatchType 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 before migrate hooks
- run patches - run patches
- sync doctypes (schema) - sync doctypes (schema)
@@ -35,70 +59,117 @@ def migrate(verbose=True, skip_failing=False, skip_search_index=False):
- sync languages - sync languages
- sync web pages (from /www) - sync web pages (from /www)
- run after migrate hooks - 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 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 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.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.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_jobs()
sync_fixtures() sync_fixtures()
sync_dashboards() sync_dashboards()
sync_customizations() sync_customizations()
sync_languages() 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 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)() 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()

+ 71
- 37
frappe/model/base_document.py View File

@@ -33,13 +33,12 @@ def get_controller(doctype):


module_name, custom = frappe.db.get_value( module_name, custom = frappe.db.get_value(
"DocType", doctype, ("module", "custom"), cache=True "DocType", doctype, ("module", "custom"), cache=True
) or ["Core", False]
) or ("Core", False)


if custom: 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 _class = NestedSet if is_tree else Document
else: else:
class_overrides = frappe.get_hooks('override_doctype_class') class_overrides = frappe.get_hooks('override_doctype_class')
@@ -73,9 +72,12 @@ def get_controller(doctype):
return site_controllers[doctype] return site_controllers[doctype]


class BaseDocument(object): 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): def __init__(self, d):
if d.get("doctype"):
self.doctype = d["doctype"]

self.update(d) self.update(d)
self.dont_update_if_missing = [] self.dont_update_if_missing = []


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


# QUESTION: why do we need the 1st for loop?
# we're essentially setting the values in d, in the 2nd for loop (?)

# 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(): for key, value in d.items():
self.set(key, value) self.set(key, value)
@@ -117,14 +115,18 @@ class BaseDocument(object):
return self return self


def update_if_missing(self, d): def update_if_missing(self, d):
"""Set default values for fields without existing values"""
if isinstance(d, BaseDocument): if isinstance(d, BaseDocument):
d = d.get_valid_dict() d = d.get_valid_dict()


if "doctype" in d:
self.set("doctype", d.get("doctype"))
for key, value in d.items(): for key, value in d.items():
# dont_update_if_missing is a list of fieldnames, for which, you don't want to set default value
if (self.get(key) is None) and (value is not None) and (key not in self.dont_update_if_missing):
if (
value is not None
and self.get(key) is None
# dont_update_if_missing is a list of fieldnames
# for which you don't want to set default value
and key not in self.dont_update_if_missing
):
self.set(key, value) self.set(key, value)


def get_db_value(self, key): def get_db_value(self, key):
@@ -144,10 +146,14 @@ class BaseDocument(object):
else: else:
value = self.__dict__.get(key, default) 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 return value
else: else:
@@ -157,6 +163,9 @@ class BaseDocument(object):
return self.get(key, filters=filters, limit=1)[0] return self.get(key, filters=filters, limit=1)[0]


def set(self, key, value, as_value=False): def set(self, key, value, as_value=False):
if key in self.ignore_in_setter:
return

if isinstance(value, list) and not as_value: if isinstance(value, list) and not as_value:
self.__dict__[key] = [] self.__dict__[key] = []
self.extend(key, value) self.extend(key, value)
@@ -182,6 +191,7 @@ class BaseDocument(object):
if isinstance(value, (dict, BaseDocument)): if isinstance(value, (dict, BaseDocument)):
if not self.__dict__.get(key): if not self.__dict__.get(key):
self.__dict__[key] = [] self.__dict__[key] = []

value = self._init_child(value, key) value = self._init_child(value, key)
self.__dict__[key].append(value) self.__dict__[key].append(value)


@@ -218,11 +228,11 @@ class BaseDocument(object):
def _init_child(self, value, key): def _init_child(self, value, key):
if not self.doctype: if not self.doctype:
return value return value

if not isinstance(value, BaseDocument): 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 = get_controller(value["doctype"])(value)
value.init_valid_columns() value.init_valid_columns()
@@ -242,7 +252,7 @@ class BaseDocument(object):


return value 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() d = frappe._dict()
for fieldname in self.meta.get_valid_columns(): for fieldname in self.meta.get_valid_columns():
d[fieldname] = self.get(fieldname) d[fieldname] = self.get(fieldname)
@@ -254,6 +264,10 @@ class BaseDocument(object):
df = self.meta.get_field(fieldname) df = self.meta.get_field(fieldname)


if df and df.get("is_virtual"): if df and df.get("is_virtual"):
if ignore_virtual:
del d[fieldname]
continue

from frappe.utils.safe_exec import get_safe_globals from frappe.utils.safe_exec import get_safe_globals


if d[fieldname] is None: if d[fieldname] is None:
@@ -389,26 +403,43 @@ class BaseDocument(object):
fieldname = [df.fieldname for df in self.meta.get_table_fields() if df.options==doctype] fieldname = [df.fieldname for df in self.meta.get_table_fields() if df.options==doctype]
return fieldname[0] if fieldname else None 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: if not self.name:
# name will be set by document class in most cases # name will be set by document class in most cases
set_new_name(self) 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: if not self.creation:
self.creation = self.modified = now() self.creation = self.modified = now()
self.created_by = self.modified_by = frappe.session.user 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 # 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) columns = list(d)
try: try:
frappe.db.sql("""INSERT INTO `tab{doctype}` ({columns}) 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())) ), list(d.values()))
except Exception as e: except Exception as e:
if frappe.db.is_primary_key_violation(e): if frappe.db.is_primary_key_violation(e):
@@ -421,8 +452,11 @@ class BaseDocument(object):
self.db_insert() self.db_insert()
return 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): elif frappe.db.is_unique_key_violation(e):
# unique constraint # unique constraint
@@ -750,7 +784,7 @@ class BaseDocument(object):


type_map = frappe.db.type_map 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) df = self.meta.get_field(fieldname)


if not df or df.fieldtype == 'Check': if not df or df.fieldtype == 'Check':
@@ -828,7 +862,7 @@ class BaseDocument(object):
if frappe.flags.in_install: if frappe.flags.in_install:
return 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): if not value or not isinstance(value, str):
continue continue




+ 2
- 2
frappe/model/db_query.py View File

@@ -330,7 +330,7 @@ class DatabaseQuery(object):
table_name = table_name[7:] table_name = table_name[7:]
if not table_name[0]=='`': if not table_name[0]=='`':
table_name = f"`{table_name}`" table_name = f"`{table_name}`"
if not table_name in self.tables:
if table_name not in self.tables:
self.append_table(table_name) self.append_table(table_name)


def append_table(self, table_name): def append_table(self, table_name):
@@ -428,7 +428,7 @@ class DatabaseQuery(object):
f = get_filter(self.doctype, f, additional_filters_config) f = get_filter(self.doctype, f, additional_filters_config)


tname = ('`tab' + f.doctype + '`') tname = ('`tab' + f.doctype + '`')
if not tname in self.tables:
if tname not in self.tables:
self.append_table(tname) self.append_table(tname)


if 'ifnull(' in f.fieldname: if 'ifnull(' in f.fieldname:


+ 1
- 1
frappe/model/delete_doc.py View File

@@ -115,7 +115,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa
# All the linked docs should be checked beforehand # All the linked docs should be checked beforehand
frappe.enqueue('frappe.model.delete_doc.delete_dynamic_links', frappe.enqueue('frappe.model.delete_doc.delete_dynamic_links',
doctype=doc.doctype, name=doc.name, doctype=doc.doctype, name=doc.name,
is_async=False if frappe.flags.in_test else True)
now=frappe.flags.in_test)


# clear cache for Document # clear cache for Document
doc.clear_cache() doc.clear_cache()


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

@@ -249,11 +249,7 @@ class Document(BaseDocument):
if getattr(self.meta, "issingle", 0): if getattr(self.meta, "issingle", 0):
self.update_single(self.get_valid_dict()) self.update_single(self.get_valid_dict())
else: 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 # children
for d in self.get_all_children(): for d in self.get_all_children():


+ 19
- 7
frappe/model/meta.py View File

@@ -14,16 +14,28 @@ Example:




''' '''
import json
import os
from datetime import datetime from datetime import datetime

import click 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.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.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): def get_meta(doctype, cached=True):
if cached: if cached:
@@ -553,7 +565,7 @@ class Meta(Document):
# For internal links parent doctype will be the key # For internal links parent doctype will be the key
doctype = link.parent_doctype or link.link_doctype doctype = link.parent_doctype or link.link_doctype
# group found # 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'): if doctype not in group.get('items'):
group.get('items').append(doctype) group.get('items').append(doctype)
link.added = True link.added = True


+ 2
- 1
frappe/model/naming.py View File

@@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE


from typing import Optional
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import now_datetime, cint, cstr from frappe.utils import now_datetime, cint, cstr
@@ -283,7 +284,7 @@ def get_default_naming_series(doctype):
return None return None




def validate_name(doctype, name, case=None, merge=False):
def validate_name(doctype: str, name: str, case: Optional[str] = None):
if not name: if not name:
frappe.throw(_("No Name Specified for {0}").format(doctype)) frappe.throw(_("No Name Specified for {0}").format(doctype))
if name.startswith("New "+doctype): if name.startswith("New "+doctype):


+ 79
- 47
frappe/model/rename_doc.py View File

@@ -1,48 +1,80 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE
from typing import TYPE_CHECKING, Dict, List, Optional

import frappe import frappe
from frappe import _, bold from frappe import _, bold
from frappe.model.dynamic_links import get_dynamic_link_map from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.model.naming import validate_name from frappe.model.naming import validate_name
from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data
from frappe.query_builder import Field
from frappe.utils import cint from frappe.utils import cint
from frappe.utils.password import rename_password from frappe.utils.password import rename_password
from frappe.query_builder import Field

if TYPE_CHECKING:
from frappe.model.meta import Meta




@frappe.whitelist() @frappe.whitelist()
def update_document_title(doctype, docname, title_field=None, old_title=None, new_title=None, new_name=None, merge=False):
def update_document_title(
*,
doctype: str,
docname: str,
title: Optional[str] = None,
name: Optional[str] = None,
merge: bool = False,
**kwargs
) -> str:
""" """
Update title from header in form view Update title from header in form view
""" """
if docname and new_name and not docname == new_name:
docname = rename_doc(doctype=doctype, old=docname, new=new_name, merge=merge)


if old_title and new_title and not old_title == new_title:
# to maintain backwards API compatibility
updated_title = kwargs.get("new_title") or title
updated_name = kwargs.get("new_name") or name

# TODO: omit this after runtime type checking (ref: https://github.com/frappe/frappe/pull/14927)
for obj in [docname, updated_title, updated_name]:
if not isinstance(obj, (str, type(None))):
frappe.throw(f"{obj=} must be of type str or None")

doc = frappe.get_doc(doctype, docname)
doc.check_permission(permtype="write")

title_field = doc.meta.get_title_field()

title_updated = (title_field != "name") and (updated_title != doc.get(title_field))
name_updated = updated_name != doc.name

if name_updated:
docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge)

if title_updated:
try: try:
frappe.db.set_value(doctype, docname, title_field, new_title)
frappe.msgprint(_('Saved'), alert=True, indicator='green')
frappe.db.set_value(doctype, docname, title_field, updated_title)
frappe.msgprint(_("Saved"), alert=True, indicator="green")
except Exception as e: except Exception as e:
if frappe.db.is_duplicate_entry(e): if frappe.db.is_duplicate_entry(e):
frappe.throw( frappe.throw(
_("{0} {1} already exists").format(doctype, frappe.bold(docname)), _("{0} {1} already exists").format(doctype, frappe.bold(docname)),
title=_("Duplicate Name"), title=_("Duplicate Name"),
exc=frappe.DuplicateEntryError
exc=frappe.DuplicateEntryError,
) )
raise


return docname return docname


def rename_doc( def rename_doc(
doctype,
old,
new,
force=False,
merge=False,
ignore_permissions=False,
ignore_if_exists=False,
show_alert=True,
rebuild_search=True
):
doctype: str,
old: str,
new: str,
force: bool = False,
merge: bool = False,
ignore_permissions: bool = False,
ignore_if_exists: bool = False,
show_alert: bool = True,
rebuild_search: bool = True,
) -> str:
"""Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link".""" """Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link"."""
if not frappe.db.exists(doctype, old): if not frappe.db.exists(doctype, old):
return return
@@ -79,7 +111,7 @@ def rename_doc(
update_user_settings(old, new, link_fields) update_user_settings(old, new, link_fields)


if doctype=='DocType': if doctype=='DocType':
rename_doctype(doctype, old, new, force)
rename_doctype(doctype, old, new)
update_customizations(old, new) update_customizations(old, new)


update_attachments(doctype, old, new) update_attachments(doctype, old, new)
@@ -121,7 +153,7 @@ def rename_doc(


return new return new


def update_assignments(old, new, doctype):
def update_assignments(old: str, new: str, doctype: str) -> None:
old_assignments = frappe.parse_json(frappe.db.get_value(doctype, old, '_assign')) or [] old_assignments = frappe.parse_json(frappe.db.get_value(doctype, old, '_assign')) or []
new_assignments = frappe.parse_json(frappe.db.get_value(doctype, new, '_assign')) or [] new_assignments = frappe.parse_json(frappe.db.get_value(doctype, new, '_assign')) or []
common_assignments = list(set(old_assignments).intersection(new_assignments)) common_assignments = list(set(old_assignments).intersection(new_assignments))
@@ -143,7 +175,7 @@ def update_assignments(old, new, doctype):
unique_assignments = list(set(old_assignments + new_assignments)) unique_assignments = list(set(old_assignments + new_assignments))
frappe.db.set_value(doctype, new, '_assign', frappe.as_json(unique_assignments, indent=0)) frappe.db.set_value(doctype, new, '_assign', frappe.as_json(unique_assignments, indent=0))


def update_user_settings(old, new, link_fields):
def update_user_settings(old: str, new: str, link_fields: List[Dict]) -> None:
''' '''
Update the user settings of all the linked doctypes while renaming. Update the user settings of all the linked doctypes while renaming.
''' '''
@@ -178,7 +210,7 @@ def update_user_settings(old, new, link_fields):
def update_customizations(old: str, new: str) -> None: def update_customizations(old: str, new: str) -> None:
frappe.db.set_value("Custom DocPerm", {"parent": old}, "parent", new, update_modified=False) frappe.db.set_value("Custom DocPerm", {"parent": old}, "parent", new, update_modified=False)


def update_attachments(doctype, old, new):
def update_attachments(doctype: str, old: str, new: str) -> None:
try: try:
if old != "File Data" and doctype != "DocType": if old != "File Data" and doctype != "DocType":
frappe.db.sql("""update `tabFile` set attached_to_name=%s frappe.db.sql("""update `tabFile` set attached_to_name=%s
@@ -187,11 +219,11 @@ def update_attachments(doctype, old, new):
if not frappe.db.is_column_missing(e): if not frappe.db.is_column_missing(e):
raise raise


def rename_versions(doctype, old, new):
def rename_versions(doctype: str, old: str, new: str) -> None:
frappe.db.sql("""UPDATE `tabVersion` SET `docname`=%s WHERE `ref_doctype`=%s AND `docname`=%s""", frappe.db.sql("""UPDATE `tabVersion` SET `docname`=%s WHERE `ref_doctype`=%s AND `docname`=%s""",
(new, doctype, old)) (new, doctype, old))


def rename_eps_records(doctype, old, new):
def rename_eps_records(doctype: str, old: str, new: str) -> None:
epl = frappe.qb.DocType("Energy Point Log") epl = frappe.qb.DocType("Energy Point Log")
(frappe.qb.update(epl) (frappe.qb.update(epl)
.set(epl.reference_name, new) .set(epl.reference_name, new)
@@ -201,20 +233,20 @@ def rename_eps_records(doctype, old, new):
) )
).run() ).run()


def rename_parent_and_child(doctype, old, new, meta):
def rename_parent_and_child(doctype: str, old: str, new: str, meta: "Meta") -> None:
# rename the doc # rename the doc
frappe.db.sql("UPDATE `tab{0}` SET `name`={1} WHERE `name`={1}".format(doctype, '%s'), (new, old)) frappe.db.sql("UPDATE `tab{0}` SET `name`={1} WHERE `name`={1}".format(doctype, '%s'), (new, old))
update_autoname_field(doctype, new, meta) update_autoname_field(doctype, new, meta)
update_child_docs(old, new, meta) update_child_docs(old, new, meta)


def update_autoname_field(doctype, new, meta):
def update_autoname_field(doctype: str, new: str, meta: "Meta") -> None:
# update the value of the autoname field on rename of the docname # update the value of the autoname field on rename of the docname
if meta.get('autoname'): if meta.get('autoname'):
field = meta.get('autoname').split(':') field = meta.get('autoname').split(':')
if field and field[0] == "field": if field and field[0] == "field":
frappe.db.sql("UPDATE `tab{0}` SET `{1}`={2} WHERE `name`={2}".format(doctype, field[1], '%s'), (new, new)) frappe.db.sql("UPDATE `tab{0}` SET `{1}`={2} WHERE `name`={2}".format(doctype, field[1], '%s'), (new, new))


def validate_rename(doctype, new, meta, merge, force, ignore_permissions):
def validate_rename(doctype: str, new: str, meta: "Meta", merge: bool, force: bool, ignore_permissions: bool) -> str:
# using for update so that it gets locked and someone else cannot edit it while this rename is going on! # using for update so that it gets locked and someone else cannot edit it while this rename is going on!
exists = ( exists = (
frappe.qb.from_(doctype) frappe.qb.from_(doctype)
@@ -226,27 +258,27 @@ def validate_rename(doctype, new, meta, merge, force, ignore_permissions):
exists = exists[0] if exists else None exists = exists[0] if exists else None


if merge and not exists: if merge and not exists:
frappe.msgprint(_("{0} {1} does not exist, select a new target to merge").format(doctype, new), raise_exception=1)
frappe.throw(_("{0} {1} does not exist, select a new target to merge").format(doctype, new))


if exists and exists != new: if exists and exists != new:
# for fixing case, accents # for fixing case, accents
exists = None exists = None


if (not merge) and exists: if (not merge) and exists:
frappe.msgprint(_("Another {0} with name {1} exists, select another name").format(doctype, new), raise_exception=1)
frappe.throw(_("Another {0} with name {1} exists, select another name").format(doctype, new))


if not (ignore_permissions or frappe.permissions.has_permission(doctype, "write", raise_exception=False)): if not (ignore_permissions or frappe.permissions.has_permission(doctype, "write", raise_exception=False)):
frappe.msgprint(_("You need write permission to rename"), raise_exception=1)
frappe.throw(_("You need write permission to rename"))


if not (force or ignore_permissions) and not meta.allow_rename: if not (force or ignore_permissions) and not meta.allow_rename:
frappe.msgprint(_("{0} not allowed to be renamed").format(_(doctype)), raise_exception=1)
frappe.throw(_("{0} not allowed to be renamed").format(_(doctype)))


# validate naming like it's done in doc.py # validate naming like it's done in doc.py
new = validate_name(doctype, new, merge=merge)
new = validate_name(doctype, new)


return new return new


def rename_doctype(doctype, old, new, force=False):
def rename_doctype(doctype: str, old: str, new: str) -> None:
# change options for fieldtype Table, Table MultiSelect and Link # change options for fieldtype Table, Table MultiSelect and Link
fields_with_options = ("Link",) + frappe.model.table_fields fields_with_options = ("Link",) + frappe.model.table_fields


@@ -261,13 +293,13 @@ def rename_doctype(doctype, old, new, force=False):
# change parenttype for fieldtype Table # change parenttype for fieldtype Table
update_parenttype_values(old, new) update_parenttype_values(old, new)


def update_child_docs(old, new, meta):
def update_child_docs(old: str, new: str, meta: "Meta") -> None:
# update "parent" # update "parent"
for df in meta.get_table_fields(): for df in meta.get_table_fields():
frappe.db.sql("update `tab%s` set parent=%s where parent=%s" \ frappe.db.sql("update `tab%s` set parent=%s where parent=%s" \
% (df.options, '%s', '%s'), (new, old)) % (df.options, '%s', '%s'), (new, old))


def update_link_field_values(link_fields, old, new, doctype):
def update_link_field_values(link_fields: List[Dict], old: str, new: str, doctype: str) -> None:
for field in link_fields: for field in link_fields:
if field['issingle']: if field['issingle']:
try: try:
@@ -302,12 +334,12 @@ def update_link_field_values(link_fields, old, new, doctype):
if doctype=='DocType' and field['parent'] == old: if doctype=='DocType' and field['parent'] == old:
field['parent'] = new field['parent'] = new


def get_link_fields(doctype):
def get_link_fields(doctype: str) -> List[Dict]:
# get link fields from tabDocField # get link fields from tabDocField
if not frappe.flags.link_fields: if not frappe.flags.link_fields:
frappe.flags.link_fields = {} frappe.flags.link_fields = {}


if not doctype in frappe.flags.link_fields:
if doctype not in frappe.flags.link_fields:
link_fields = frappe.db.sql("""\ link_fields = frappe.db.sql("""\
select parent, fieldname, select parent, fieldname,
(select issingle from tabDocType dt (select issingle from tabDocType dt
@@ -345,7 +377,7 @@ def get_link_fields(doctype):


return frappe.flags.link_fields[doctype] return frappe.flags.link_fields[doctype]


def update_options_for_fieldtype(fieldtype, old, new):
def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None:
if frappe.conf.developer_mode: if frappe.conf.developer_mode:
for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"): for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"):
doctype = frappe.get_doc("DocType", name) doctype = frappe.get_doc("DocType", name)
@@ -366,7 +398,7 @@ def update_options_for_fieldtype(fieldtype, old, new):
frappe.db.sql("""update `tabProperty Setter` set value=%s frappe.db.sql("""update `tabProperty Setter` set value=%s
where property='options' and value=%s""", (new, old)) where property='options' and value=%s""", (new, old))


def get_select_fields(old, new):
def get_select_fields(old: str, new: str) -> List[Dict]:
""" """
get select type fields where doctype's name is hardcoded as get select type fields where doctype's name is hardcoded as
new line separated list new line separated list
@@ -410,7 +442,7 @@ def get_select_fields(old, new):


return select_fields return select_fields


def update_select_field_values(old, new):
def update_select_field_values(old: str, new: str):
frappe.db.sql(""" frappe.db.sql("""
update `tabDocField` set options=replace(options, %s, %s) update `tabDocField` set options=replace(options, %s, %s)
where where
@@ -433,7 +465,7 @@ def update_select_field_values(old, new):
(value like {0} or value like {1})""" (value like {0} or value like {1})"""
.format(frappe.db.escape('%' + '\n' + old + '%'), frappe.db.escape('%' + old + '\n' + '%')), (old, new, new)) .format(frappe.db.escape('%' + '\n' + old + '%'), frappe.db.escape('%' + old + '\n' + '%')), (old, new, new))


def update_parenttype_values(old, new):
def update_parenttype_values(old: str, new: str):
child_doctypes = frappe.db.get_all('DocField', child_doctypes = frappe.db.get_all('DocField',
fields=['options', 'fieldname'], fields=['options', 'fieldname'],
filters={ filters={
@@ -469,7 +501,7 @@ def update_parenttype_values(old, new):
for doctype in child_doctypes: for doctype in child_doctypes:
frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old)) frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old))


def rename_dynamic_links(doctype, old, new):
def rename_dynamic_links(doctype: str, old: str, new: str):
for df in get_dynamic_link_map().get(doctype, []): for df in get_dynamic_link_map().get(doctype, []):
# dynamic link in single, just one value to check # dynamic link in single, just one value to check
if frappe.get_meta(df.parent).issingle: if frappe.get_meta(df.parent).issingle:
@@ -485,7 +517,7 @@ def rename_dynamic_links(doctype, old, new):
where {options}=%s and {fieldname}=%s""".format(parent = parent, where {options}=%s and {fieldname}=%s""".format(parent = parent,
fieldname=df.fieldname, options=df.options), (new, doctype, old)) fieldname=df.fieldname, options=df.options), (new, doctype, old))


def bulk_rename(doctype, rows=None, via_console = False):
def bulk_rename(doctype: str, rows: Optional[List[List]] = None, via_console: bool = False) -> Optional[List[str]]:
"""Bulk rename documents """Bulk rename documents


:param doctype: DocType to be renamed :param doctype: DocType to be renamed
@@ -523,7 +555,7 @@ def bulk_rename(doctype, rows=None, via_console = False):
if not via_console: if not via_console:
return rename_log return rename_log


def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None):
def update_linked_doctypes(doctype: str, docname: str, linked_to: str, value: str, ignore_doctypes: Optional[List] = None) -> None:
from frappe.model.utils.rename_doc import update_linked_doctypes from frappe.model.utils.rename_doc import update_linked_doctypes
show_deprecation_warning("update_linked_doctypes") show_deprecation_warning("update_linked_doctypes")


@@ -536,7 +568,7 @@ def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=N
) )




def get_fetch_fields(doctype, linked_to, ignore_doctypes=None):
def get_fetch_fields(doctype: str, linked_to: str, ignore_doctypes: Optional[List] = None) -> List[Dict]:
from frappe.model.utils.rename_doc import get_fetch_fields from frappe.model.utils.rename_doc import get_fetch_fields
show_deprecation_warning("get_fetch_fields") show_deprecation_warning("get_fetch_fields")


@@ -544,7 +576,7 @@ def get_fetch_fields(doctype, linked_to, ignore_doctypes=None):
doctype=doctype, linked_to=linked_to, ignore_doctypes=ignore_doctypes doctype=doctype, linked_to=linked_to, ignore_doctypes=ignore_doctypes
) )


def show_deprecation_warning(funct):
def show_deprecation_warning(funct: str) -> None:
from click import secho from click import secho
message = ( message = (
f"Function frappe.model.rename_doc.{funct} has been deprecated and " f"Function frappe.model.rename_doc.{funct} has been deprecated and "


+ 1
- 1
frappe/model/sync.py View File

@@ -117,7 +117,7 @@ def get_doc_files(files, start_path):
if os.path.isdir(os.path.join(doctype_path, docname)): if os.path.isdir(os.path.join(doctype_path, docname)):
doc_path = os.path.join(doctype_path, docname, docname) + ".json" doc_path = os.path.join(doctype_path, docname, docname) + ".json"
if os.path.exists(doc_path): if os.path.exists(doc_path):
if not doc_path in files:
if doc_path not in files:
files.append(doc_path) files.append(doc_path)


return files return files

+ 6
- 2
frappe/model/utils/rename_doc.py View File

@@ -1,10 +1,14 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE

from itertools import product from itertools import product
from typing import Dict, List, Optional


import frappe import frappe
from frappe.model.rename_doc import get_link_fields from frappe.model.rename_doc import get_link_fields




def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None):
def update_linked_doctypes(doctype: str, docname: str, linked_to: str, value: str, ignore_doctypes: Optional[List] = None):
""" """
linked_doctype_info_list = list formed by get_fetch_fields() function linked_doctype_info_list = list formed by get_fetch_fields() function
docname = Master DocType's name in which modification are made docname = Master DocType's name in which modification are made
@@ -24,7 +28,7 @@ def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=N
) )




def get_fetch_fields(doctype, linked_to, ignore_doctypes=None):
def get_fetch_fields(doctype: str, linked_to: str, ignore_doctypes: Optional[List] = None) -> List[Dict]:
""" """
doctype = Master DocType in which the changes are being made doctype = Master DocType in which the changes are being made
linked_to = DocType name of the field thats being updated in Master linked_to = DocType name of the field thats being updated in Master


+ 3
- 2
frappe/modules/import_file.py View File

@@ -115,10 +115,11 @@ def import_file_by_path(path: str,force: bool = False,data_import: bool = False,


if not force or db_modified_timestamp: if not force or db_modified_timestamp:
try: try:
stored_hash = frappe.db.get_value(doc["doctype"], doc["name"], "migration_hash")
stored_hash = None
if doc["doctype"] == "DocType":
stored_hash = frappe.db.get_value(doc["doctype"], doc["name"], "migration_hash")
except Exception: except Exception:
frappe.flags.dt += [doc["doctype"]] frappe.flags.dt += [doc["doctype"]]
stored_hash = None


# if hash exists and is equal no need to update # if hash exists and is equal no need to update
if stored_hash and stored_hash == calculated_hash: if stored_hash and stored_hash == calculated_hash:


+ 1
- 0
frappe/patches.txt View File

@@ -184,6 +184,7 @@ frappe.patches.v13_0.queryreport_columns
frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.jinja_hook
frappe.patches.v13_0.update_notification_channel_if_empty frappe.patches.v13_0.update_notification_channel_if_empty
frappe.patches.v13_0.set_first_day_of_the_week 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.update_workspace2 # 20.09.2021
frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021
frappe.patches.v14_0.transform_todo_schema frappe.patches.v14_0.transform_todo_schema


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

@@ -814,6 +814,13 @@
<path d="M16.814 13.3304L17.9274 12.6875" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/> <path d="M16.814 13.3304L17.9274 12.6875" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
</symbol> </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"> <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"/> <path d="M5 4V6.4H9V16H11.4V6.4H15.4V4H5Z" fill="var(--icon-stroke)" stroke="none"/>
</symbol> </symbol>


+ 3
- 1
frappe/public/js/frappe/form/controls/date.js View File

@@ -158,8 +158,10 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat
return value; return value;
} }
get_df_options() { get_df_options() {
let df_options = this.df.options;
if (!df_options) return {};
let options = {}; let options = {};
let df_options = this.df.options || '';
if (typeof df_options === 'string') { if (typeof df_options === 'string') {
try { try {
options = JSON.parse(df_options); options = JSON.parse(df_options);


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

@@ -29,7 +29,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
setTimeout(function() { setTimeout(function() {
if(me.$input.val() && me.get_options()) { if(me.$input.val() && me.get_options()) {
let doctype = 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.toggle(true);
me.$link_open.attr('href', frappe.utils.get_form_link(doctype, name)); 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(); 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() { open_advanced_search() {
var doctype = this.get_options(); var doctype = this.get_options();
if(!doctype) return; if(!doctype) return;
@@ -98,7 +151,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
} }


// partially entered name field // 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 // reference to calling link
frappe._from_link = this; frappe._from_link = this;
@@ -120,6 +173,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
maxItems: 99, maxItems: 99,
autoFirst: true, autoFirst: true,
list: [], 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) { data: function (item) {
return { return {
label: item.label || item.value, label: item.label || item.value,
@@ -236,9 +294,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
me.selected = false; me.selected = false;
return; 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 // prevent selection on tab
var TABKEY = 9; var TABKEY = 9;
if(e.keyCode === TABKEY) {
if (e.keyCode === TABKEY) {
e.preventDefault(); e.preventDefault();
me.awesomplete.close(); me.awesomplete.close();
return false; return false;
} }


if(item.action) {
if (item.action) {
item.value = ""; item.value = "";
item.label = "";
item.action.apply(me); 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; 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) { 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(""); me.$input.val("");
} }
}); });


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

@@ -83,15 +83,21 @@ frappe.ui.form.ControlMultiSelectPills = class ControlMultiSelectPills extends f
} }


get_pill_html(value) { get_pill_html(value) {
const label = this.get_label(value);
const encoded_value = encodeURIComponent(value); const encoded_value = encodeURIComponent(value);
return ` return `
<button class="data-pill btn tb-selected-value" data-value="${encoded_value}"> <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> <span class="btn-remove">${frappe.utils.icon('close')}</span>
</button> </button>
`; `;
} }


get_label(value) {
const item = this._data?.find(d => d.value === value);
return item ? item.label || item.value : null;
}

get_awesomplete_settings() { get_awesomplete_settings() {
const settings = super.get_awesomplete_settings(); const settings = super.get_awesomplete_settings();




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

@@ -49,7 +49,7 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f
setup_buttons() { setup_buttons() {
this.$input_area.find('.link-btn').remove(); this.$input_area.find('.link-btn').remove();
} }
parse(value) {
parse(value, label) {
const link_field = this.get_link_field(); const link_field = this.get_link_field();


if (value) { if (value) {
@@ -62,6 +62,7 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f
[link_field.fieldname]: value [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]); this._rows_list = this.rows.map(row => row[link_field.fieldname]);
return this.rows; return this.rows;
@@ -126,10 +127,12 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f
this.$input_area.prepend(html); this.$input_area.prepend(html);
} }
get_pill_html(value) { get_pill_html(value) {
const link_field = this.get_link_field();
const encoded_value = encodeURIComponent(value); const encoded_value = encodeURIComponent(value);
const pill_name = frappe.utils.get_link_title(link_field.options, value) || value;
return ` return `
<button class="data-pill btn tb-selected-value" data-value="${encoded_value}"> <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> <span class="btn-remove">${frappe.utils.icon('close')}</span>
</button> </button>
`; `;


+ 26
- 13
frappe/public/js/frappe/form/form.js View File

@@ -334,7 +334,7 @@ frappe.ui.form.Form = class FrappeForm {
this.doc = frappe.get_doc(this.doctype, this.docname); this.doc = frappe.get_doc(this.doctype, this.docname);


// check permissions // check permissions
if(!this.has_read_permission()) {
if (!this.has_read_permission()) {
frappe.show_not_permitted(__(this.doctype) + " " + __(this.docname)); frappe.show_not_permitted(__(this.doctype) + " " + __(this.docname));
return; return;
} }
@@ -1102,13 +1102,13 @@ frappe.ui.form.Form = class FrappeForm {
let list_view = frappe.get_list_view(this.doctype); let list_view = frappe.get_list_view(this.doctype);
if (list_view) { if (list_view) {
filters = list_view.get_filters_for_args(); filters = list_view.get_filters_for_args();
sort_field = list_view.sort_field;
sort_field = list_view.sort_by;
sort_order = list_view.sort_order; sort_order = list_view.sort_order;
} else { } else {
let list_settings = frappe.get_user_settings(this.doctype)['List']; let list_settings = frappe.get_user_settings(this.doctype)['List'];
if (list_settings) { if (list_settings) {
filters = list_settings.filters; filters = list_settings.filters;
sort_field = list_settings.sort_field;
sort_field = list_settings.sort_by;
sort_order = list_settings.sort_order; sort_order = list_settings.sort_order;
} }
} }
@@ -1363,6 +1363,7 @@ frappe.ui.form.Form = class FrappeForm {


set_df_property(fieldname, property, value, docname, table_field, table_row_name=null) { set_df_property(fieldname, property, value, docname, table_field, table_row_name=null) {
let df; let df;

if (!docname || !table_field) { if (!docname || !table_field) {
df = this.get_docfield(fieldname); df = this.get_docfield(fieldname);
} else { } 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); df = frappe.meta.get_docfield(filtered_fields[0].parent, table_field, table_row_name);
} }
} }

if (df && df[property] != value) { if (df && df[property] != value) {
df[property] = value; df[property] = value;

if (table_field && table_row_name) { if (table_field && table_row_name) {
if (this.fields_dict[fieldname].grid.grid_rows_by_docname[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); this.fields_dict[fieldname].grid.grid_rows_by_docname[table_row_name].refresh_field(fieldname);
@@ -1508,7 +1511,9 @@ frappe.ui.form.Form = class FrappeForm {
// update child doc // update child doc
opts.child = locals[opts.child.doctype][opts.child.name]; opts.child = locals[opts.child.doctype][opts.child.name];


var std_field_list = ["doctype"].concat(frappe.model.std_fields_list);
var std_field_list = ["doctype"]
.concat(frappe.model.std_fields_list)
.concat(frappe.model.child_table_field_list);
for (var key in r.message) { for (var key in r.message) {
if (std_field_list.indexOf(key)===-1) { if (std_field_list.indexOf(key)===-1) {
opts.child[key] = r.message[key]; opts.child[key] = r.message[key];
@@ -1659,23 +1664,17 @@ frappe.ui.form.Form = class FrappeForm {
// make new doctype from the current form // make new doctype from the current form
// will handover to `make_methods` if defined // will handover to `make_methods` if defined
// or will create and match link fields // or will create and match link fields
var me = this;
let me = this;
if(this.make_methods && this.make_methods[doctype]) { if(this.make_methods && this.make_methods[doctype]) {
return this.make_methods[doctype](this); return this.make_methods[doctype](this);
} else if(this.custom_make_buttons && this.custom_make_buttons[doctype]) { } else if(this.custom_make_buttons && this.custom_make_buttons[doctype]) {
this.custom_buttons[__(this.custom_make_buttons[doctype])].trigger('click'); this.custom_buttons[__(this.custom_make_buttons[doctype])].trigger('click');
} else { } else {
frappe.model.with_doctype(doctype, function() { 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) // 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.ui.form.make_quick_entry(doctype, null, null, new_doc);
// frappe.set_route('Form', doctype, new_doc.name); // frappe.set_route('Form', doctype, new_doc.name);
@@ -1683,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_in_all_rows(table_fieldname, fieldname, value) {
// update the child value in all tables where it is missing // update the child value in all tables where it is missing
if(!value) return; if(!value) return;


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

@@ -113,12 +113,14 @@ frappe.form.formatters = {
Link: function(value, docfield, options, doc) { Link: function(value, docfield, options, doc) {
var doctype = docfield._options || docfield.options; var doctype = docfield._options || docfield.options;
var original_value = value; var original_value = value;
let link_title = frappe.utils.get_link_title(doctype, value);

if(value && value.match && value.match(/^['"].*['"]$/)) { if(value && value.match && value.match(/^['"].*['"]$/)) {
value.replace(/^.(.*).$/, "$1"); value.replace(/^.(.*).$/, "$1");
} }


if(options && (options.for_print || options.only_value)) { if(options && (options.for_print || options.only_value)) {
return value;
return link_title || value;
} }


if(frappe.form.link_formatters[doctype]) { if(frappe.form.link_formatters[doctype]) {
@@ -142,13 +144,14 @@ frappe.form.formatters = {
return `<a return `<a
href="/app/${encodeURIComponent(frappe.router.slug(doctype))}/${encodeURIComponent(original_value)}" href="/app/${encodeURIComponent(frappe.router.slug(doctype))}/${encodeURIComponent(original_value)}"
data-doctype="${doctype}" 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 { } else {
return value;
return link_title || value;
} }
} else { } else {
return value;
return link_title || value;
} }
}, },
Date: function(value) { Date: function(value) {


+ 7
- 8
frappe/public/js/frappe/form/grid.js View File

@@ -502,10 +502,9 @@ export default class Grid {


set_column_disp(fieldname, show) { set_column_disp(fieldname, show) {
if ($.isArray(fieldname)) { 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 { } else {
this.get_docfield(fieldname).hidden = show ? 0 : 1; this.get_docfield(fieldname).hidden = show ? 0 : 1;
@@ -555,17 +554,17 @@ export default class Grid {
} }


toggle_reqd(fieldname, reqd) { toggle_reqd(fieldname, reqd) {
this.get_docfield(fieldname).reqd = reqd;
this.update_docfield_property(fieldname, "reqd", reqd);
this.debounced_refresh(); this.debounced_refresh();
} }


toggle_enable(fieldname, enable) { 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(); this.debounced_refresh();
} }


toggle_display(fieldname, show) { toggle_display(fieldname, show) {
this.get_docfield(fieldname).hidden = show ? 0 : 1;
this.update_docfield_property(fieldname, "hidden", show ? 0 : 1);
this.debounced_refresh(); this.debounced_refresh();
} }


@@ -747,7 +746,7 @@ export default class Grid {
var df = this.visible_columns[i][0]; var df = this.visible_columns[i][0];
var colsize = this.visible_columns[i][1]; var colsize = this.visible_columns[i][1];
if (colsize > 1 && colsize < 11 if (colsize > 1 && colsize < 11
&& !in_list(frappe.model.std_fields_list, df.fieldname)) {
&& frappe.model.is_non_std_field(df.fieldname)) {


if (passes < 3 && ["Int", "Currency", "Float", "Check", "Percent"].indexOf(df.fieldtype) !== -1) { if (passes < 3 && ["Int", "Currency", "Float", "Check", "Percent"].indexOf(df.fieldtype) !== -1) {
// don't increase col size of these fields in first 3 passes // don't increase col size of these fields in first 3 passes


+ 27
- 10
frappe/public/js/frappe/form/grid_row.js View File

@@ -5,11 +5,7 @@ export default class GridRow {
this.on_grid_fields_dict = {}; this.on_grid_fields_dict = {};
this.on_grid_fields = []; this.on_grid_fields = [];
$.extend(this, opts); $.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 = {};
this.columns_list = []; this.columns_list = [];
this.row_check_html = '<input type="checkbox" class="grid-row-check pull-left">'; this.row_check_html = '<input type="checkbox" class="grid-row-check pull-left">';
@@ -41,6 +37,22 @@ export default class GridRow {
this.set_data(); 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() { set_data() {
this.wrapper.data({ this.wrapper.data({
"doc": this.doc "doc": this.doc
@@ -148,6 +160,11 @@ export default class GridRow {
}, __('Move To'), 'Update'); }, __('Move To'), 'Update');
} }
refresh() { refresh() {
// update docfields for new record
if (this.frm && this.doc && this.doc.__islocal) {
this.set_docfields(true);
}

if(this.frm && this.doc) { if(this.frm && this.doc) {
this.doc = locals[this.doc.doctype][this.doc.name]; this.doc = locals[this.doc.doctype][this.doc.name];
} }
@@ -323,7 +340,7 @@ export default class GridRow {
</div> </div>
<div class='control-input-wrapper selected-fields'> <div class='control-input-wrapper selected-fields'>
</div> </div>
<p class='help-box small text-muted hidden-xs'>
<p class='help-box small text-muted'>
<a class='add-new-fields text-muted'> <a class='add-new-fields text-muted'>
+ ${__('Add / Remove Columns')} + ${__('Add / Remove Columns')}
</a> </a>
@@ -403,18 +420,18 @@ export default class GridRow {
data-label='${docfield.label}' data-type='${docfield.fieldtype}'> data-label='${docfield.label}' data-type='${docfield.fieldtype}'>


<div class='row'> <div class='row'>
<div class='col-md-1'>
<div class='col-md-1' style='padding-top: 2px'>
<a style='cursor: grabbing;'>${frappe.utils.icon('drag', 'xs')}</a> <a style='cursor: grabbing;'>${frappe.utils.icon('drag', 'xs')}</a>
</div> </div>
<div class='col-md-7' style='padding-left:0px;'>
<div class='col-md-7' style='padding-left:0px; padding-top:3px'>
${__(docfield.label)} ${__(docfield.label)}
</div> </div>
<div class='col-md-3' style='padding-left:0px;margin-top:-2px;' title='${__('Columns')}'> <div class='col-md-3' style='padding-left:0px;margin-top:-2px;' title='${__('Columns')}'>
<input class='form-control column-width input-xs text-right' <input class='form-control column-width input-xs text-right'
value='${docfield.columns || cint(d.columns)}' value='${docfield.columns || cint(d.columns)}'
data-fieldname='${docfield.fieldname}' style='background-color: #ffff; display: inline'>
data-fieldname='${docfield.fieldname}' style='background-color: var(--modal-bg); display: inline'>
</div> </div>
<div class='col-md-1'>
<div class='col-md-1' style='padding-top: 3px'>
<a class='text-muted remove-field' data-fieldname='${docfield.fieldname}'> <a class='text-muted remove-field' data-fieldname='${docfield.fieldname}'>
<i class='fa fa-trash-o' aria-hidden='true'></i> <i class='fa fa-trash-o' aria-hidden='true'></i>
</a> </a>


+ 12
- 8
frappe/public/js/frappe/form/layout.js View File

@@ -98,7 +98,7 @@ frappe.ui.form.Layout = class Layout {
// remove previous color // remove previous color
this.message.removeClass(this.message_color); this.message.removeClass(this.message_color);
} }
this.message_color = (color && ['yellow', 'blue'].includes(color)) ? color : 'blue';
this.message_color = (color && ['yellow', 'blue', 'red'].includes(color)) ? color : 'blue';
if (html) { if (html) {
if (html.substr(0, 1)!=='<') { if (html.substr(0, 1)!=='<') {
// wrap in a block // wrap in a block
@@ -547,24 +547,28 @@ frappe.ui.form.Layout = class Layout {
} }


refresh_dependency() { 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; 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) { if (f.df.depends_on || f.df.mandatory_depends_on || f.df.read_only_depends_on) {
has_dep = true; has_dep = true;
break;
} }
} }


if (!has_dep) return; if (!has_dep) return;


// show / hide based on values // 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; f.guardian_has_value = true;
if (f.df.depends_on) { if (f.df.depends_on) {
// evaluate guardian // evaluate guardian


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

@@ -1,6 +1,6 @@
frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { frappe.ui.form.MultiSelectDialog = class MultiSelectDialog {
constructor(opts) { 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); Object.assign(this, opts);
this.for_select = this.doctype == "[Select]"; this.for_select = this.doctype == "[Select]";
if (!this.for_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)); 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 = {}) { make_list_row(result = {}) {
var me = this; var me = this;
// Make a head row by default (if result not passed) // Make a head row by default (if result not passed)
let head = Object.keys(result).length === 0; let head = Object.keys(result).length === 0;


let contents = ``; 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"> contents += `<div class="list-item__content ellipsis">
${ ${
head ? `<span class="ellipsis text-muted" title="${__(frappe.model.unscrub(column))}">${__(frappe.model.unscrub(column))}</span>` 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() { get_filters_from_setters() {
let me = this; let me = this;
let filters = this.get_query ? this.get_query().filters : {} || {};
let filters = (this.get_query ? this.get_query().filters : {}) || {};
let filter_fields = []; let filter_fields = [];


if ($.isArray(this.setters)) { if ($.isArray(this.setters)) {


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

@@ -249,30 +249,39 @@ frappe.ui.form.update_calling_link = (newdoc) => {
}; };


if (is_valid_doctype()) { 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;
});
} }
} }

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

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


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


toggle(show) { toggle(show) {


+ 2
- 4
frappe/public/js/frappe/form/toolbar.js View File

@@ -101,10 +101,8 @@ frappe.ui.form.Toolbar = class Toolbar {
return frappe.xcall("frappe.model.rename_doc.update_document_title", { return frappe.xcall("frappe.model.rename_doc.update_document_title", {
doctype, doctype,
docname, docname,
new_name,
title_field,
old_title: this.frm.doc[title_field],
new_title,
name: new_name,
title: new_title,
merge merge
}).then(new_docname => { }).then(new_docname => {
if (new_name != docname) { if (new_name != docname) {


+ 26
- 3
frappe/public/js/frappe/list/base_list.js View File

@@ -204,6 +204,11 @@ frappe.views.BaseList = class BaseList {
}; };


if (frappe.boot.desk_settings.view_switcher) { if (frappe.boot.desk_settings.view_switcher) {
/* @preserve
for translation, don't remove
__("List View") __("Report View") __("Dashboard View") __("Gantt View"),
__("Kanban View") __("Calendar View") __("Image View") __("Inbox View"),
__("Tree View") __("Map View") */
this.views_menu = this.page.add_custom_button_group(__('{0} View', [this.view_name]), this.views_menu = this.page.add_custom_button_group(__('{0} View', [this.view_name]),
icon_map[this.view_name] || 'list'); icon_map[this.view_name] || 'list');
this.views_list = new frappe.views.ListViewSelect({ this.views_list = new frappe.views.ListViewSelect({
@@ -391,10 +396,10 @@ frappe.views.BaseList = class BaseList {
$this.addClass("btn-info"); $this.addClass("btn-info");


this.start = 0; 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")) { } else if ($this.is(".btn-more")) {
this.start = this.start + this.page_length; this.start = this.start + this.page_length;
this.page_length = 20;
this.page_length = this.selected_page_count || 20;
} }
this.refresh(); this.refresh();
}); });
@@ -465,9 +470,14 @@ frappe.views.BaseList = class BaseList {
} }


refresh() { refresh() {
let args = this.get_call_args();
if (this.no_change(args)) {
// console.log('throttled');
return Promise.resolve();
}
this.freeze(true); this.freeze(true);
// fetch data from server // fetch data from server
return frappe.call(this.get_call_args()).then((r) => {
return frappe.call(args).then((r) => {
// render // render
this.prepare_data(r); this.prepare_data(r);
this.toggle_result_area(); this.toggle_result_area();
@@ -482,6 +492,19 @@ frappe.views.BaseList = class BaseList {
}); });
} }


no_change(args) {
// returns true if arguments are same for the last 3 seconds
// this helps in throttling if called from various sources
if (this.last_args && JSON.stringify(args) === this.last_args) {
return true;
}
this.last_args = JSON.stringify(args);
setTimeout(() => {
this.last_args = null;
}, 3000);
return false;
}

prepare_data(r) { prepare_data(r) {
let data = r.message || {}; let data = r.message || {};




+ 1
- 1
frappe/public/js/frappe/list/list_view.js View File

@@ -1483,7 +1483,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
return [ return [
filter[1], filter[1],
"=", "=",
JSON.stringify([filter[2], filter[3]]),
encodeURIComponent(JSON.stringify([filter[2], filter[3]])),
].join(""); ].join("");
}) })
.join("&"); .join("&");


+ 2
- 2
frappe/public/js/frappe/model/meta.js View File

@@ -144,7 +144,7 @@ $.extend(frappe.meta, {


get_doctype_for_field: function(doctype, key) { get_doctype_for_field: function(doctype, key) {
var out = null; var out = null;
if(in_list(frappe.model.std_fields_list, key)) {
if (in_list(frappe.model.std_fields_list, key)) {
// standard // standard
out = doctype; out = doctype;
} else if(frappe.meta.has_field(doctype, key)) { } else if(frappe.meta.has_field(doctype, key)) {
@@ -152,7 +152,7 @@ $.extend(frappe.meta, {
out = doctype; out = doctype;
} else { } else {
frappe.meta.get_table_fields(doctype).every(function(d) { frappe.meta.get_table_fields(doctype).every(function(d) {
if(frappe.meta.has_field(d.options, key)) {
if (frappe.meta.has_field(d.options, key) || in_list(frappe.model.child_table_field_list, key)) {
out = d.options; out = d.options;
return false; return false;
} }


+ 3
- 1
frappe/public/js/frappe/model/model.js View File

@@ -12,6 +12,8 @@ $.extend(frappe.model, {
std_fields_list: ['name', 'owner', 'creation', 'modified', 'modified_by', std_fields_list: ['name', 'owner', 'creation', 'modified', 'modified_by',
'_user_tags', '_comments', '_assign', '_liked_by', 'docstatus', 'idx'], '_user_tags', '_comments', '_assign', '_liked_by', 'docstatus', 'idx'],


child_table_field_list: ['parent', 'parenttype', 'parentfield'],

core_doctypes_list: ['DocType', 'DocField', 'DocPerm', 'User', 'Role', 'Has Role', core_doctypes_list: ['DocType', 'DocField', 'DocPerm', 'User', 'Role', 'Has Role',
'Page', 'Module Def', 'Print Format', 'Report', 'Customize Form', 'Page', 'Module Def', 'Print Format', 'Report', 'Customize Form',
'Customize Form Field', 'Property Setter', 'Custom Field', 'Client Script'], 'Customize Form Field', 'Property Setter', 'Custom Field', 'Client Script'],
@@ -83,7 +85,7 @@ $.extend(frappe.model, {
}, },


is_non_std_field: function(fieldname) { is_non_std_field: function(fieldname) {
return !frappe.model.std_fields_list.includes(fieldname);
return ![...frappe.model.std_fields_list, ...frappe.model.child_table_field_list].includes(fieldname);
}, },


get_std_field: function(fieldname, ignore=false) { get_std_field: function(fieldname, ignore=false) {


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

@@ -260,6 +260,14 @@ frappe.request.call = function(opts) {
$.extend(frappe._messages, data.__messages); $.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 // callbacks
var status_code_handler = statusCode[xhr.statusCode().status]; var status_code_handler = statusCode[xhr.statusCode().status];
if (status_code_handler) { if (status_code_handler) {


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

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


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

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


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

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


+ 2
- 2
frappe/public/js/frappe/ui/filters/filter_list.js View File

@@ -170,7 +170,7 @@ frappe.ui.FilterGroup = class {
validate_args(doctype, fieldname) { validate_args(doctype, fieldname) {
if (doctype && fieldname if (doctype && fieldname
&& !frappe.meta.has_field(doctype, fieldname) && !frappe.meta.has_field(doctype, fieldname)
&& !frappe.model.std_fields_list.includes(fieldname)) {
&& frappe.model.is_non_std_field(fieldname)) {


frappe.msgprint({ frappe.msgprint({
message: __('Invalid filter: {0}', [fieldname.bold()]), message: __('Invalid filter: {0}', [fieldname.bold()]),
@@ -293,7 +293,7 @@ frappe.ui.FilterGroup = class {
</div> </div>
</div> </div>
<hr class="divider"></hr> <hr class="divider"></hr>
<div class="filter-action-buttons">
<div class="filter-action-buttons mt-2">
<button class="text-muted add-filter btn btn-xs"> <button class="text-muted add-filter btn btn-xs">
+ ${__('Add a Filter')} + ${__('Add a Filter')}
</button> </button>


+ 1
- 1
frappe/public/js/frappe/ui/link_preview.js View File

@@ -73,7 +73,7 @@ frappe.ui.LinkPreview = class {
} }


this.popover_timeout = setTimeout(() => { this.popover_timeout = setTimeout(() => {
if (this.popover) {
if (this.popover && this.popover.options) {
let new_content = this.get_popover_html(preview_data); let new_content = this.get_popover_html(preview_data);
this.popover.options.content = new_content; this.popover.options.content = new_content;
} else { } else {


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

@@ -1416,5 +1416,42 @@ Object.assign(frappe.utils, {
arr.push(i); arr.push(i);
} }
return arr; 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 View File

@@ -578,6 +578,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
args: { args: {
report_name: this.report_name, report_name: this.report_name,
filters: filters, filters: filters,
report_settings: this.report_settings
}, },
callback: resolve, callback: resolve,
always: () => this.page.btn_secondary.prop('disabled', false) 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 data = this.data;
let columns = this.columns.filter((col) => !col.hidden); 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 = data.slice();
data.splice(-1, 1); data.splice(-1, 1);
} }
@@ -854,7 +855,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
treeView: this.tree_report, treeView: this.tree_report,
layout: 'fixed', layout: 'fixed',
cellHeight: 33, 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', direction: frappe.utils.is_rtl() ? 'rtl' : 'ltr',
hooks: { hooks: {
columnTotal: frappe.utils.report_column_total columnTotal: frappe.utils.report_column_total


+ 1
- 1
frappe/public/js/frappe/views/reports/report_view.js View File

@@ -651,7 +651,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
&& !df.is_virtual && !df.is_virtual
&& !df.hidden && !df.hidden
// not a standard field i.e., owner, modified_by, etc. // not a standard field i.e., owner, modified_by, etc.
&& !frappe.model.std_fields_list.includes(df.fieldname))
&& frappe.model.is_non_std_field(df.fieldname))
return true; return true;
return false; return false;
} }


+ 6
- 4
frappe/public/scss/common/color_picker.scss View File

@@ -94,7 +94,10 @@


.frappe-control[data-fieldtype='Color'] { .frappe-control[data-fieldtype='Color'] {
input { input {
padding-left: 40px;
padding-left: 38px;
}
.control-input {
position: relative;
} }
.selected-color { .selected-color {
cursor: pointer; cursor: pointer;
@@ -103,7 +106,7 @@
border-radius: 5px; border-radius: 5px;
background-color: red; background-color: red;
position: absolute; position: absolute;
top: calc(50% + 1px);
top: 5px;
left: 8px; left: 8px;
content: ' '; content: ' ';
&.no-value { &.no-value {
@@ -113,10 +116,9 @@
} }
.like-disabled-input { .like-disabled-input {
.color-value { .color-value {
padding-left: 25px;
padding-left: 26px;
} }
.selected-color { .selected-color {
top: 20%;
cursor: default; cursor: default;
} }
} }


+ 2
- 0
frappe/public/scss/common/css_variables.scss View 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-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); --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); --modal-shadow: var(--shadow-md);
--card-shadow: var(--shadow-sm); --card-shadow: var(--shadow-sm);
--btn-shadow: var(--shadow-xs); --btn-shadow: var(--shadow-xs);


+ 1
- 1
frappe/public/scss/common/grid.scss View File

@@ -192,7 +192,7 @@
margin-left: var(--margin-xs); margin-left: var(--margin-xs);


button { button {
height: 27px;
height: 24px;
} }
} }




+ 5
- 0
frappe/public/scss/common/modal.scss View File

@@ -225,6 +225,11 @@ body.modal-open[style^="padding-right"] {
} }
} }


// modal is xs (for grids)
.modal .hidden-xs {
display: none !important;
}

.dialog-assignment-row { .dialog-assignment-row {
display: flex; display: flex;
align-items: center; align-items: center;


+ 2
- 2
frappe/public/scss/desk/frappe_datatable.scss View File

@@ -58,7 +58,7 @@
} }


.link-btn { .link-btn {
top: 6px;
top: 0px;
} }


select { select {
@@ -77,7 +77,7 @@
padding: 0; padding: 0;
border: var(--dt-focus-border-width) solid #9bccf8; border: var(--dt-focus-border-width) solid #9bccf8;


input {
input[type="text"] {
font-size: inherit; font-size: inherit;
height: 27px; height: 27px;




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

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


.list-paging-area, .footnote-area { .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 { .frappe-card {


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

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


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


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


+ 4
- 0
frappe/public/scss/website/web_form.scss View File

@@ -50,6 +50,10 @@
&:last-child { &:last-child {
padding-right: 0; padding-right: 0;
} }

@include media-breakpoint-down(sm) {
padding: 0;
}
} }
} }




+ 1
- 1
frappe/realtime.py View File

@@ -65,7 +65,7 @@ def publish_realtime(event=None, message=None, room=None,


if after_commit: if after_commit:
params = [event, message, room] params = [event, message, room]
if not params in frappe.local.realtime_log:
if params not in frappe.local.realtime_log:
frappe.local.realtime_log.append(params) frappe.local.realtime_log.append(params)
else: else:
emit_via_redis(event, message, room) emit_via_redis(event, message, room)


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

@@ -3,7 +3,7 @@
{% for page in layout %} {% for page in layout %}
<div class="page-break"> <div class="page-break">
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}> <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> </div>


{% if print_settings.repeat_header_footer %} {% if print_settings.repeat_header_footer %}


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save