Bladeren bron

Merge branch 'develop' into chart-in-custom-script-reports-dev

version-14
Suraj Shetty 3 jaren geleden
committed by GitHub
bovenliggende
commit
17882e4633
Geen bekende sleutel gevonden voor deze handtekening in de database GPG sleutel-ID: 4AEE18F83AFDEB23
100 gewijzigde bestanden met toevoegingen van 2138 en 542 verwijderingen
  1. BIN
      cypress/fixtures/sample_image.jpg
  2. +50
    -0
      cypress/integration/control_icon.js
  3. +20
    -0
      cypress/integration/file_uploader.js
  4. +57
    -0
      cypress/integration/sidebar.js
  5. +1
    -0
      cypress/integration/table_multiselect.js
  6. +94
    -0
      cypress/integration/timeline.js
  7. +71
    -0
      cypress/integration/timeline_email.js
  8. +29
    -3
      cypress/support/commands.js
  9. +1
    -1
      frappe/api.py
  10. +0
    -1
      frappe/auth.py
  11. +6
    -3
      frappe/automation/doctype/assignment_rule/assignment_rule.json
  12. +41
    -16
      frappe/commands/site.py
  13. +30
    -48
      frappe/commands/utils.py
  14. +2
    -2
      frappe/core/doctype/docfield/docfield.json
  15. +89
    -1
      frappe/core/doctype/doctype/doctype.js
  16. +16
    -0
      frappe/core/doctype/doctype/doctype.py
  17. +5
    -3
      frappe/core/doctype/doctype/test_doctype.py
  18. +25
    -1
      frappe/core/doctype/doctype_link/doctype_link.json
  19. +19
    -0
      frappe/core/doctype/file/file.js
  20. +31
    -4
      frappe/core/doctype/file/file.py
  21. +0
    -5
      frappe/core/doctype/server_script/server_script.py
  22. +10
    -0
      frappe/core/doctype/server_script/test_server_script.py
  23. +26
    -0
      frappe/coverage.py
  24. +2
    -2
      frappe/custom/doctype/custom_field/custom_field.json
  25. +26
    -0
      frappe/custom/doctype/customize_form/test_customize_form.py
  26. +2
    -2
      frappe/custom/doctype/customize_form_field/customize_form_field.json
  27. +1
    -1
      frappe/database/database.py
  28. +2
    -1
      frappe/database/mariadb/database.py
  29. +2
    -1
      frappe/database/postgres/database.py
  30. +17
    -3
      frappe/handler.py
  31. +18
    -1
      frappe/integrations/doctype/webhook/test_webhook.py
  32. +10
    -1
      frappe/integrations/doctype/webhook/webhook.json
  33. +15
    -9
      frappe/integrations/doctype/webhook/webhook.py
  34. +0
    -0
      frappe/integrations/doctype/webhook_request_log/__init__.py
  35. +8
    -0
      frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py
  36. +8
    -0
      frappe/integrations/doctype/webhook_request_log/webhook_request_log.js
  37. +81
    -0
      frappe/integrations/doctype/webhook_request_log/webhook_request_log.json
  38. +8
    -0
      frappe/integrations/doctype/webhook_request_log/webhook_request_log.py
  39. +14
    -26
      frappe/integrations/utils.py
  40. +2
    -1
      frappe/model/__init__.py
  41. +12
    -0
      frappe/model/base_document.py
  42. +12
    -4
      frappe/model/document.py
  43. +22
    -8
      frappe/model/meta.py
  44. +107
    -12
      frappe/model/naming.py
  45. +3
    -29
      frappe/parallel_test_runner.py
  46. +1
    -0
      frappe/patches.txt
  47. +213
    -0
      frappe/patches/v14_0/rename_cancelled_documents.py
  48. +1
    -1
      frappe/permissions.py
  49. +4
    -1
      frappe/public/icons/timeless/symbol-defs.svg
  50. +1
    -15
      frappe/public/js/controls.bundle.js
  51. +37
    -2
      frappe/public/js/frappe/file_uploader/FilePreview.vue
  52. +42
    -3
      frappe/public/js/frappe/file_uploader/FileUploader.vue
  53. +80
    -0
      frappe/public/js/frappe/file_uploader/ImageCropper.vue
  54. +20
    -0
      frappe/public/js/frappe/file_uploader/index.js
  55. +12
    -2
      frappe/public/js/frappe/form/controls/attach.js
  56. +1
    -0
      frappe/public/js/frappe/form/controls/code.js
  57. +1
    -0
      frappe/public/js/frappe/form/controls/control.js
  58. +62
    -0
      frappe/public/js/frappe/form/controls/datepicker_i18n.js
  59. +93
    -0
      frappe/public/js/frappe/form/controls/icon.js
  60. +2
    -0
      frappe/public/js/frappe/form/controls/select.js
  61. +1
    -0
      frappe/public/js/frappe/form/footer/version_timeline_content_builder.js
  62. +14
    -10
      frappe/public/js/frappe/form/form.js
  63. +1
    -1
      frappe/public/js/frappe/form/form_tour.js
  64. +6
    -0
      frappe/public/js/frappe/form/formatters.js
  65. +9
    -8
      frappe/public/js/frappe/form/grid.js
  66. +8
    -0
      frappe/public/js/frappe/form/layout.js
  67. +1
    -1
      frappe/public/js/frappe/form/sidebar/user_image.js
  68. +86
    -0
      frappe/public/js/frappe/icon_picker/icon_picker.js
  69. +9
    -6
      frappe/public/js/frappe/list/list_view.js
  70. +3
    -6
      frappe/public/js/frappe/recorder/RequestDetail.vue
  71. +11
    -7
      frappe/public/js/frappe/router.js
  72. +1
    -0
      frappe/public/js/frappe/ui/dialog.js
  73. +3
    -0
      frappe/public/js/frappe/views/reports/query_report.js
  74. +2
    -0
      frappe/public/js/frappe/views/reports/report_view.js
  75. +1
    -0
      frappe/public/scss/common/controls.scss
  76. +3
    -1
      frappe/public/scss/common/datepicker.scss
  77. +95
    -0
      frappe/public/scss/common/icon_picker.scss
  78. +1
    -0
      frappe/public/scss/desk/form.scss
  79. +10
    -1
      frappe/public/scss/desk/list.scss
  80. +26
    -3
      frappe/public/scss/desk/report.scss
  81. +5
    -2
      frappe/search/website_search.py
  82. BIN
      frappe/tests/data/sample_image_for_optimization.jpg
  83. +13
    -0
      frappe/tests/test_commands.py
  84. +38
    -0
      frappe/tests/test_hooks.py
  85. +34
    -0
      frappe/tests/test_naming.py
  86. +37
    -2
      frappe/tests/test_translate.py
  87. +44
    -2
      frappe/tests/test_utils.py
  88. +7
    -2
      frappe/translate.py
  89. +0
    -1
      frappe/utils/background_jobs.py
  90. +30
    -1
      frappe/utils/data.py
  91. +11
    -3
      frappe/utils/file_manager.py
  92. +18
    -4
      frappe/utils/image.py
  93. +3
    -3
      frappe/utils/nestedset.py
  94. +1
    -1
      frappe/utils/pdf.py
  95. +3
    -3
      frappe/website/doctype/website_settings/website_settings.json
  96. +1
    -1
      frappe/website/doctype/website_settings/website_settings.py
  97. +3
    -2
      frappe/website/utils.py
  98. +10
    -0
      frappe/workflow/doctype/workflow/test_workflow.py
  99. +22
    -257
      frappe/workflow/doctype/workflow_transition/workflow_transition.json
  100. +2
    -1
      package.json

BIN
cypress/fixtures/sample_image.jpg Bestand weergeven

Voor Na
Breedte: 1920  |  Hoogte: 1281  |  Grootte: 244 KiB

+ 50
- 0
cypress/integration/control_icon.js Bestand weergeven

@@ -0,0 +1,50 @@
context('Control Icon', () => {
before(() => {
cy.login();
cy.visit('/app/website');
});

function get_dialog_with_icon() {
return cy.dialog({
title: 'Icon',
fields: [{
label: 'Icon',
fieldname: 'icon',
fieldtype: 'Icon'
}]
});
}

it('should set icon', () => {
get_dialog_with_icon().as('dialog');
cy.get('.frappe-control[data-fieldname=icon] input').first().click();

cy.get('.icon-picker .icon-wrapper[id=active]').first().click();
cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'active');
cy.get('@dialog').then(dialog => {
let value = dialog.get_value('icon');
expect(value).to.equal('active');
});

cy.get('.icon-picker .icon-wrapper[id=resting]').first().click();
cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'resting');
cy.get('@dialog').then(dialog => {
let value = dialog.get_value('icon');
expect(value).to.equal('resting');
});
});

it('search for icon and clear search input', () => {
let search_text = 'ed';
cy.get('.icon-picker input[type=search]').first().click().type(search_text);
cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => {
cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => {
expect(i.length).to.equal(icons.length);
});
});

cy.get('.icon-picker input[type=search]').clear().blur();
cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden');
});

});

+ 20
- 0
cypress/integration/file_uploader.js Bestand weergeven

@@ -54,4 +54,24 @@ context('FileUploader', () => {
.should('have.property', 'file_url', 'https://github.com');
cy.get('.modal:visible').should('not.exist');
});

it('should allow cropping and optimization for valid images', () => {
open_upload_dialog();

cy.get_open_dialog().find('.file-upload-area').attachFile('sample_image.jpg', {
subjectType: 'drag-n-drop',
});

cy.get_open_dialog().find('.file-name').should('contain', 'sample_image.jpg');
cy.get_open_dialog().find('.btn-crop').first().click();
cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').should('contain', 'Crop');
cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').click();
cy.get_open_dialog().find('.optimize-checkbox').first().should('contain', 'Optimize');
cy.get_open_dialog().find('.optimize-checkbox').first().click();

cy.intercept('POST', '/api/method/upload_file').as('upload_file');
cy.get_open_dialog().find('.btn-modal-primary').click();
cy.wait('@upload_file').its('response.statusCode').should('eq', 200);
cy.get('.modal:visible').should('not.exist');
});
});

+ 57
- 0
cypress/integration/sidebar.js Bestand weergeven

@@ -0,0 +1,57 @@
context('Sidebar', () => {
before(() => {
cy.visit('/login');
cy.login();
cy.visit('/app/doctype');
});

it('Test for checking "Assigned To" counter value, adding filter and adding & removing an assignment', () => {
cy.click_sidebar_button(0);

//To check if no filter is available in "Assigned To" dropdown
cy.get('.empty-state').should('contain', 'No filters found');

cy.click_sidebar_button(1);

//To check if "Created By" dropdown contains filter
cy.get('.group-by-item > .dropdown-item').should('contain', 'Me');

//Assigning a doctype to a user
cy.click_listview_row_item(0);
cy.get('.form-assignments > .flex > .text-muted').click();
cy.get_field('assign_to_me', 'Check').click();
cy.get('.modal-footer > .standard-actions > .btn-primary').click();
cy.visit('/app/doctype');
cy.click_sidebar_button(0);

//To check if filter is added in "Assigned To" dropdown after assignment
cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').should('contain', '1');

//To check if there is no filter added to the listview
cy.get('.filter-selector > .btn').should('contain', 'Filter');

//To add a filter to display data into the listview
cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').click();

//To check if filter is applied
cy.click_filter_button().should('contain', '1 filter');
cy.get('.fieldname-select-area > .awesomplete > .form-control').should('have.value', 'Assigned To');
cy.get('.condition').should('have.value', 'like');
cy.get('.filter-field > .form-group > .input-with-feedback').should('have.value', '%Administrator%');

//To remove the applied filter
cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click();
cy.click_filter_button();
cy.get('.filter-selector > .btn').should('contain', 'Filter');

//To remove the assignment
cy.visit('/app/doctype');
cy.click_listview_row_item(0);
cy.get('.assignments > .avatar-group > .avatar > .avatar-frame').click();
cy.get('.remove-btn').click({force: true});
cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close').click();
cy.visit('/app/doctype');
cy.click_sidebar_button(0);
cy.get('.empty-state').should('contain', 'No filters found');
});
});

+ 1
- 0
cypress/integration/table_multiselect.js Bestand weergeven

@@ -9,6 +9,7 @@ context('Table MultiSelect', () => {
cy.new_form('Assignment Rule');
cy.fill_field('__newname', name);
cy.fill_field('document_type', 'Blog Post');
cy.get('.section-head').contains('Assignment Rules').scrollIntoView();
cy.fill_field('assign_condition', 'status=="Open"', 'Code');
cy.get('input[data-fieldname="users"]').focus().as('input');
cy.get('input[data-fieldname="users"] + ul').should('be.visible');


+ 94
- 0
cypress/integration/timeline.js Bestand weergeven

@@ -0,0 +1,94 @@
import custom_submittable_doctype from '../fixtures/custom_submittable_doctype';

context('Timeline', () => {
before(() => {
cy.visit('/login');
cy.login();
cy.visit('/app/todo');
});

it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => {
//Adding new ToDo
cy.click_listview_primary_button('Add ToDo');
cy.get('.modal-footer > .custom-actions > .btn').contains('Edit in full page').click();
cy.get('.row > .section-body > .form-column > form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').eq(0).type('Test ToDo', {force: true});
cy.wait(200);
cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .primary-action').contains('Save').click();
cy.wait(700);
cy.visit('/app/todo');
cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();

//To check if the comment box is initially empty and tying some text into it
cy.get('.comment-input-container > .frappe-control > .ql-container > .ql-editor').should('contain', '').type('Testing Timeline');

//Adding new comment
cy.get('.comment-input-wrapper > .btn').contains('Comment').click();

//To check if the commented text is visible in the timeline content
cy.get('.timeline-content').should('contain', 'Testing Timeline');

//Editing comment
cy.click_timeline_action_btn(0);
cy.get('.timeline-content > .timeline-message-box > .comment-edit-box > .frappe-control > .ql-container > .ql-editor').first().type(' 123');
cy.click_timeline_action_btn(0);

//To check if the edited comment text is visible in timeline content
cy.get('.timeline-content').should('contain', 'Testing Timeline 123');

//Discarding comment
cy.click_timeline_action_btn(0);
cy.get('.actions > .btn').eq(1).first().click();

//To check if after discarding the timeline content is same as previous
cy.get('.timeline-content').should('contain', 'Testing Timeline 123');

//Deleting the added comment
cy.get('.actions > .btn > .icon').first().click();
cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click();
cy.click_modal_primary_button('Yes');

//Deleting the added ToDo
cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click({force: true});
cy.get('.menu-btn-group > .dropdown-menu > li > .grey-link').eq(17).click({force: true});
cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').contains('Yes').click({force: true});
});

it('Timeline should have submit and cancel activity information', () => {
cy.visit('/app/doctype');

//Creating custom doctype
cy.insert_doc('DocType', custom_submittable_doctype, true);

cy.visit('/app/custom-submittable-doctype');
cy.click_listview_primary_button('Add Custom Submittable DocType');

//Adding a new entry for the created custom doctype
cy.fill_field('title', 'Test');
cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Save').click();
cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Submit').click();
cy.visit('/app/custom-submittable-doctype');
cy.get('.list-subject > .bold > .ellipsis').eq(0).click();

//To check if the submission of the documemt is visible in the timeline content
cy.get('.timeline-content').should('contain', 'Administrator submitted this document');
cy.get('.page-actions > .standard-actions > .btn-secondary').contains('Cancel').click({delay: 900});
cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click();
//To check if the cancellation of the documemt is visible in the timeline content
cy.get('.timeline-content').should('contain', 'Administrator cancelled this document');

//Deleting the document
cy.visit('/app/custom-submittable-doctype');
cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click();
cy.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click();
cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(7).click();
cy.click_modal_primary_button('Yes', {force: true, delay: 700});

//Deleting the custom doctype
cy.visit('/app/doctype');
cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click();
cy.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click();
cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click();
cy.click_modal_primary_button('Yes');
});
});

+ 71
- 0
cypress/integration/timeline_email.js Bestand weergeven

@@ -0,0 +1,71 @@
context('Timeline Email', () => {
before(() => {
cy.visit('/login');
cy.login();
cy.visit('/app/todo');
});

it('Adding new ToDo, adding email and verifying timeline content for email attachment, deleting attachment and ToDo', () => {
//Adding new ToDo
cy.click_listview_primary_button('Add ToDo');
cy.get('.custom-actions > .btn').trigger('click', {delay: 500});
cy.get('.row > .section-body > .form-column > form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').eq(0).type('Test ToDo', {force: true});
cy.wait(500);
//cy.click_listview_primary_button('Save');
cy.get('.primary-action').contains('Save').click({force: true});
cy.wait(700);
cy.visit('/app/todo');
cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();

//Creating a new email
cy.get('.timeline-actions > .btn').click();
cy.fill_field('recipients', 'test@example.com', 'MultiSelect');
cy.get('.modal.show > .modal-dialog > .modal-content > .modal-body > :nth-child(1) > .form-layout > .form-page > :nth-child(3) > .section-body > .form-column > form > [data-fieldtype="Text Editor"] > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').type('Test Mail');

//Adding attachment to the email
cy.get('.add-more-attachments > .btn').click();
cy.get('.mt-2 > .btn > .mt-1').eq(2).click();
cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
cy.get('.btn-primary').contains('Upload').click();

//Sending the email
cy.click_modal_primary_button('Send', {delay: 500});

//To check if the sent mail content is shown in the timeline content
cy.get('[data-doctype="Communication"] > .timeline-content').should('contain', 'Test Mail');

//To check if the attachment of email is shown in the timeline content
cy.get('.timeline-content').should('contain', 'Added 72402.jpg');

//Deleting the sent email
cy.get('[title="Open Communication"] > .icon').first().click({force: true});
cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click();
cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .dropdown-menu > li > .grey-link').eq(9).click();
cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click();
cy.visit('/app/todo');
cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();

//Removing the added attachment
cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click();
cy.get('.modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').contains('Yes').click();

//To check if the removed attachment is shown in the timeline content
cy.get('.timeline-content').should('contain', 'Removed 72402.jpg');
cy.wait(500);

//To check if the discard button functionality in email is working correctly
cy.get('.timeline-actions > .btn').click();
cy.fill_field('recipients', 'test@example.com', 'MultiSelect');
cy.get('.modal-footer > .standard-actions > .btn-secondary').contains('Discard').click();
cy.wait(500);
cy.get('.timeline-actions > .btn').click();
cy.wait(500);
cy.get_field('recipients', 'MultiSelect').should('have.text', '');
cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close > .icon').click();

//Deleting the added ToDo
cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click();
cy.get('.menu-btn-group > .dropdown-menu > li > .grey-link').eq(17).click();
cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click();
});
});

+ 29
- 3
cypress/support/commands.js Bestand weergeven

@@ -192,16 +192,16 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => {
});

Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => {
let selector = `.form-control[data-fieldname="${fieldname}"]`;
let selector = `[data-fieldname="${fieldname}"] input:visible`;

if (fieldtype === 'Text Editor') {
selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`;
selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]:visible`;
}
if (fieldtype === 'Code') {
selector = `[data-fieldname="${fieldname}"] .ace_text-input`;
}

return cy.get(selector);
return cy.get(selector).first();
});

Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, value, fieldtype = 'Data') => {
@@ -323,4 +323,30 @@ Cypress.Commands.add('clear_filters', () => {
cy.window().its('cur_list').then(cur_list => {
cur_list && cur_list.filter_area && cur_list.filter_area.clear();
});

});

Cypress.Commands.add('click_modal_primary_button', (btn_name) => {
cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).trigger('click', {force: true});
});

Cypress.Commands.add('click_sidebar_button', (btn_no) => {
cy.get('.list-group-by-fields > .group-by-field > .btn').eq(btn_no).click();
});

Cypress.Commands.add('click_listview_row_item', (row_no) => {
cy.get('.list-row > .level-left > .list-subject > .bold > .ellipsis').eq(row_no).click({force: true});
});

Cypress.Commands.add('click_filter_button', () => {
cy.get('.filter-selector > .btn').click();
});

Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
cy.get('.primary-action').contains(btn_name).click({force: true});
});

Cypress.Commands.add('click_timeline_action_btn', (btn_no) => {
cy.get('.timeline-content > .timeline-message-box > .justify-between > .actions > .btn').eq(btn_no).first().click();
});

+ 1
- 1
frappe/api.py Bestand weergeven

@@ -82,7 +82,7 @@ def handle():
if frappe.local.request.method=="PUT":
data = get_request_form_data()

doc = frappe.get_doc(doctype, name)
doc = frappe.get_doc(doctype, name, for_update=True)

if "flags" in data:
del data["flags"]


+ 0
- 1
frappe/auth.py Bestand weergeven

@@ -154,7 +154,6 @@ class LoginManager:
self.make_session()
self.setup_boot_cache()
self.set_user_info()
self.clear_preferred_language()

def get_user_info(self):
self.info = frappe.db.get_value("User", self.user,


+ 6
- 3
frappe/automation/doctype/assignment_rule/assignment_rule.json Bestand weergeven

@@ -72,6 +72,7 @@
"fieldtype": "Code",
"in_list_view": 1,
"label": "Assign Condition",
"options": "PythonExpression",
"reqd": 1
},
{
@@ -82,7 +83,8 @@
"description": "Simple Python Expression, Example: Status in (\"Closed\", \"Cancelled\")",
"fieldname": "unassign_condition",
"fieldtype": "Code",
"label": "Unassign Condition"
"label": "Unassign Condition",
"options": "PythonExpression"
},
{
"fieldname": "assign_to_users_section",
@@ -120,7 +122,8 @@
"description": "Simple Python Expression, Example: Status in (\"Invalid\")",
"fieldname": "close_condition",
"fieldtype": "Code",
"label": "Close Condition"
"label": "Close Condition",
"options": "PythonExpression"
},
{
"fieldname": "sb",
@@ -151,7 +154,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-10-20 14:47:20.662954",
"modified": "2021-07-16 22:51:35.505575",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule",


+ 41
- 16
frappe/commands/site.py Bestand weergeven

@@ -193,7 +193,7 @@ def install_app(context, apps):
print("App {} is Incompatible with Site {}{}".format(app, site, err_msg))
exit_code = 1
except Exception as err:
err_msg = ":\n{}".format(err if str(err) else frappe.get_traceback())
err_msg = ": {}\n{}".format(str(err), frappe.get_traceback())
print("An error occurred while installing {}{}".format(app, err_msg))
exit_code = 1

@@ -561,30 +561,54 @@ def move(dest_dir, site):
return final_new_path


@click.command('set-password')
@click.argument('user')
@click.argument('password', required=False)
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
@pass_context
def set_password(context, user, password=None, logout_all_sessions=False):
"Set password for a user on a site"
if not context.sites:
raise SiteNotSpecifiedError

for site in context.sites:
set_user_password(site, user, password, logout_all_sessions)


@click.command('set-admin-password')
@click.argument('admin-password')
@click.argument('admin-password', required=False)
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
@pass_context
def set_admin_password(context, admin_password, logout_all_sessions=False):
def set_admin_password(context, admin_password=None, logout_all_sessions=False):
"Set Administrator password for a site"
if not context.sites:
raise SiteNotSpecifiedError

for site in context.sites:
set_user_password(site, "Administrator", admin_password, logout_all_sessions)


def set_user_password(site, user, password, logout_all_sessions=False):
import getpass
from frappe.utils.password import update_password

for site in context.sites:
try:
frappe.init(site=site)
try:
frappe.init(site=site)

while not admin_password:
admin_password = getpass.getpass("Administrator's password for {0}: ".format(site))
while not password:
password = getpass.getpass(f"{user}'s password for {site}: ")

frappe.connect()
if not frappe.db.exists("User", user):
print(f"User {user} does not exist")
sys.exit(1)

update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions)
frappe.db.commit()
password = None
finally:
frappe.destroy()

frappe.connect()
update_password(user='Administrator', pwd=admin_password, logout_all_sessions=logout_all_sessions)
frappe.db.commit()
admin_password = None
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError

@click.command('set-last-active-for-user')
@click.option('--user', help="Setup last active date for user")
@@ -729,6 +753,7 @@ commands = [
remove_from_installed_apps,
restore,
run_patch,
set_password,
set_admin_password,
uninstall,
disable_user,


+ 30
- 48
frappe/commands/utils.py Bestand weergeven

@@ -9,8 +9,8 @@ import click
import frappe
from frappe.commands import get_site, pass_context
from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils import get_bench_path, update_progress_bar, cint
from frappe.utils import update_progress_bar, cint
from frappe.coverage import CodeCoverage

DATA_IMPORT_DEPRECATION = click.style(
"[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n"
@@ -530,52 +530,33 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal
coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None,
skip_test_records=False, skip_before_tests=False, failfast=False):

"Run tests"
import frappe.test_runner
tests = test

site = get_site(context)

allow_tests = frappe.get_conf(site).allow_tests

if not (allow_tests or os.environ.get('CI')):
click.secho('Testing is disabled for the site!', bold=True)
click.secho('You can enable tests by entering following command:')
click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green')
return
with CodeCoverage(coverage, app):
import frappe.test_runner
tests = test
site = get_site(context)

frappe.init(site=site)
allow_tests = frappe.get_conf(site).allow_tests

frappe.flags.skip_before_tests = skip_before_tests
frappe.flags.skip_test_records = skip_test_records
if not (allow_tests or os.environ.get('CI')):
click.secho('Testing is disabled for the site!', bold=True)
click.secho('You can enable tests by entering following command:')
click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green')
return

if coverage:
from coverage import Coverage
from frappe.coverage import STANDARD_INCLUSIONS, STANDARD_EXCLUSIONS, FRAPPE_EXCLUSIONS
frappe.init(site=site)

# Generate coverage report only for app that is being tested
source_path = os.path.join(get_bench_path(), 'apps', app or 'frappe')
omit = STANDARD_EXCLUSIONS[:]
frappe.flags.skip_before_tests = skip_before_tests
frappe.flags.skip_test_records = skip_test_records

if not app or app == 'frappe':
omit.extend(FRAPPE_EXCLUSIONS)
ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests,
force=context.force, profile=profile, junit_xml_output=junit_xml_output,
ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast)

cov = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
cov.start()
if len(ret.failures) == 0 and len(ret.errors) == 0:
ret = 0

ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests,
force=context.force, profile=profile, junit_xml_output=junit_xml_output,
ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast)

if coverage:
cov.stop()
cov.save()

if len(ret.failures) == 0 and len(ret.errors) == 0:
ret = 0

if os.environ.get('CI'):
sys.exit(ret)
if os.environ.get('CI'):
sys.exit(ret)

@click.command('run-parallel-tests')
@click.option('--app', help="For App", default='frappe')
@@ -585,13 +566,14 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal
@click.option('--use-orchestrator', is_flag=True, help="Use orchestrator to run parallel tests")
@pass_context
def run_parallel_tests(context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False):
site = get_site(context)
if use_orchestrator:
from frappe.parallel_test_runner import ParallelTestWithOrchestrator
ParallelTestWithOrchestrator(app, site=site, with_coverage=with_coverage)
else:
from frappe.parallel_test_runner import ParallelTestRunner
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds, with_coverage=with_coverage)
with CodeCoverage(with_coverage, app):
site = get_site(context)
if use_orchestrator:
from frappe.parallel_test_runner import ParallelTestWithOrchestrator
ParallelTestWithOrchestrator(app, site=site)
else:
from frappe.parallel_test_runner import ParallelTestRunner
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)

@click.command('run-ui-tests')
@click.argument('app')


+ 2
- 2
frappe/core/doctype/docfield/docfield.json Bestand weergeven

@@ -90,7 +90,7 @@
"label": "Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature",
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature",
"reqd": 1,
"search_index": 1
},
@@ -487,7 +487,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-10-29 06:09:26.454990",
"modified": "2021-07-10 21:56:04.167745",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",


+ 89
- 1
frappe/core/doctype/doctype/doctype.js Bestand weergeven

@@ -66,4 +66,92 @@ frappe.ui.form.on('DocType', {
autoname: function(frm) {
frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt');
}
})
});

frappe.ui.form.on("DocField", {
form_render(frm, doctype, docname) {
// Render two select fields for Fetch From instead of Small Text for better UX
let field = frm.cur_grid.grid_form.fields_dict.fetch_from;
$(field.input_area).hide();

let $doctype_select = $(`<select class="form-control">`);
let $field_select = $(`<select class="form-control">`);
let $wrapper = $('<div class="fetch-from-select row"><div>');
$wrapper.append($doctype_select, $field_select);
field.$input_wrapper.append($wrapper);
$doctype_select.wrap('<div class="col"></div>');
$field_select.wrap('<div class="col"></div>');

let row = frappe.get_doc(doctype, docname);
let curr_value = { doctype: null, fieldname: null };
if (row.fetch_from) {
let [doctype, fieldname] = row.fetch_from.split(".");
curr_value.doctype = doctype;
curr_value.fieldname = fieldname;
}
let curr_df_link_doctype = row.fieldtype == "Link" ? row.options : null;

let doctypes = frm.doc.fields
.filter(df => df.fieldtype == "Link")
.filter(df => df.options && df.options != curr_df_link_doctype)
.map(df => ({
label: `${df.options} (${df.fieldname})`,
value: df.fieldname
}));
$doctype_select.add_options([
{ label: __("Select DocType"), value: "", selected: true },
...doctypes
]);

$doctype_select.on("change", () => {
row.fetch_from = "";
frm.dirty();
update_fieldname_options();
});

function update_fieldname_options() {
$field_select.find("option").remove();

let link_fieldname = $doctype_select.val();
if (!link_fieldname) return;
let link_field = frm.doc.fields.find(
df => df.fieldname === link_fieldname
);
let link_doctype = link_field.options;
frappe.model.with_doctype(link_doctype, () => {
let fields = frappe.meta
.get_docfields(link_doctype, null, {
fieldtype: ["not in", frappe.model.no_value_type]
})
.map(df => ({
label: `${df.label} (${df.fieldtype})`,
value: df.fieldname
}));
$field_select.add_options([
{
label: __("Select Field"),
value: "",
selected: true,
disabled: true
},
...fields
]);

if (curr_value.fieldname) {
$field_select.val(curr_value.fieldname);
}
});
}

$field_select.on("change", () => {
let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`;
row.fetch_from = fetch_from;
frm.dirty();
});

if (curr_value.doctype) {
$doctype_select.val(curr_value.doctype);
update_fieldname_options();
}
}
});

+ 16
- 0
frappe/core/doctype/doctype/doctype.py Bestand weergeven

@@ -722,6 +722,19 @@ def validate_links_table_fieldnames(meta):
message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype))
frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname"))

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

if link.is_child_table:
if not link.parent_doctype:
message = _("Row #{0}: Parent DocType is mandatory for internal links").format(index+1)
frappe.throw(message, frappe.ValidationError, _("Parent Missing"))

if not link.table_fieldname:
message = _("Row #{0}: Table Fieldname is mandatory for internal links").format(index+1)
frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing"))

def validate_fields_for_doctype(doctype):
meta = frappe.get_meta(doctype, cached=False)
validate_links_table_fieldnames(meta)
@@ -931,6 +944,9 @@ def validate_fields(meta):
if meta.website_search_field not in fieldname_list:
frappe.throw(_("Website Search Field must be a valid fieldname"), InvalidFieldNameError)

if "title" not in fieldname_list:
frappe.throw(_('Field "title" is mandatory if "Website Search Field" is set.'), title=_("Missing Field"))

def check_timeline_field(meta):
if not meta.timeline_field:
return


+ 5
- 3
frappe/core/doctype/doctype/test_doctype.py Bestand weergeven

@@ -348,6 +348,7 @@ class TestDocType(unittest.TestCase):
dump_docs = json.dumps(docs.get('docs'))
cancel_all_linked_docs(dump_docs)
data_link_doc.cancel()
data_doc.name = '{}-CANC-0'.format(data_doc.name)
data_doc.load_from_db()
self.assertEqual(data_link_doc.docstatus, 2)
self.assertEqual(data_doc.docstatus, 2)
@@ -371,7 +372,7 @@ class TestDocType(unittest.TestCase):
for data in link_doc.get('permissions'):
data.submit = 1
data.cancel = 1
link_doc.insert()
link_doc.insert(ignore_if_duplicate=True)

#create first parent doctype
test_doc_1 = new_doctype('Test Doctype 1')
@@ -386,7 +387,7 @@ class TestDocType(unittest.TestCase):
for data in test_doc_1.get('permissions'):
data.submit = 1
data.cancel = 1
test_doc_1.insert()
test_doc_1.insert(ignore_if_duplicate=True)

#crete second parent doctype
doc = new_doctype('Test Doctype 2')
@@ -401,7 +402,7 @@ class TestDocType(unittest.TestCase):
for data in link_doc.get('permissions'):
data.submit = 1
data.cancel = 1
doc.insert()
doc.insert(ignore_if_duplicate=True)

# create doctype data
data_link_doc_1 = frappe.new_doc('Test Linked Doctype 1')
@@ -432,6 +433,7 @@ class TestDocType(unittest.TestCase):
# checking that doc for Test Doctype 2 is not canceled
self.assertRaises(frappe.LinkExistsError, data_link_doc_1.cancel)

data_doc_2.name = '{}-CANC-0'.format(data_doc_2.name)
data_doc.load_from_db()
data_doc_2.load_from_db()
self.assertEqual(data_link_doc_1.docstatus, 2)


+ 25
- 1
frappe/core/doctype/doctype_link/doctype_link.json Bestand weergeven

@@ -7,8 +7,11 @@
"field_order": [
"link_doctype",
"link_fieldname",
"parent_doctype",
"table_fieldname",
"group",
"hidden",
"is_child_table",
"custom"
],
"fields": [
@@ -45,12 +48,33 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Custom"
},
{
"depends_on": "is_child_table",
"fieldname": "parent_doctype",
"fieldtype": "Link",
"label": "Parent DocType",
"mandatory_depends_on": "is_child_table",
"options": "DocType"
},
{
"default": "0",
"fetch_from": "link_doctype.istable",
"fieldname": "is_child_table",
"fieldtype": "Check",
"label": "Is Child Table",
"read_only": 1
},
{
"fieldname": "table_fieldname",
"fieldtype": "Data",
"label": "Table Fieldname"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-09-24 14:19:25.189511",
"modified": "2021-07-31 15:23:12.237491",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType Link",


+ 19
- 0
frappe/core/doctype/file/file.js Bestand weergeven

@@ -23,6 +23,25 @@ frappe.ui.form.on("File", "refresh", function(frm) {
wrapper.empty();
}

var is_raster_image = (/\.(gif|jpg|jpeg|tiff|png)$/i).test(frm.doc.file_url);
var is_optimizable = !frm.doc.is_folder && is_raster_image && frm.doc.file_size > 0;

if (is_optimizable) {
frm.add_custom_button(__("Optimize"), function() {
frappe.show_alert(__("Optimizing image..."));
frappe.call({
method: "frappe.core.doctype.file.file.optimize_saved_image",
args: {
doc_name: frm.doc.name,
},
callback: function() {
frappe.show_alert(__("Image optimized"));
frappe.set_route("List", "File");
}
});
});
}

if(frm.doc.file_name && frm.doc.file_name.split('.').splice(-1)[0]==='zip') {
frm.add_custom_button(__('Unzip'), function() {
frappe.call({


+ 31
- 4
frappe/core/doctype/file/file.py Bestand weergeven

@@ -28,7 +28,7 @@ import frappe
from frappe import _, conf
from frappe.model.document import Document
from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip
from frappe.utils.image import strip_exif_data
from frappe.utils.image import strip_exif_data, optimize_image

class MaxFileSizeReachedError(frappe.ValidationError):
pass
@@ -703,7 +703,10 @@ def get_web_image(file_url):
frappe.msgprint(_("Unable to read file format for {0}").format(file_url))
raise

image = Image.open(StringIO(frappe.safe_decode(r.content)))
try:
image = Image.open(StringIO(frappe.safe_decode(r.content)))
except Exception as e:
frappe.msgprint(_("Image link '{0}' is not valid").format(file_url), raise_exception=e)

try:
filename, extn = file_url.rsplit("/", 1)[1].rsplit(".", 1)
@@ -876,6 +879,15 @@ def extract_images_from_html(doc, content):
data = match.group(1)
data = data.split("data:")[1]
headers, content = data.split(",")
mtype = headers.split(";")[0]

if isinstance(content, str):
content = content.encode("utf-8")
if b"," in content:
content = content.split(b",")[1]
content = base64.b64decode(content)
content = optimize_image(content, mtype)

if "filename=" in headers:
filename = headers.split("filename=")[-1]
@@ -884,7 +896,6 @@ def extract_images_from_html(doc, content):
if not isinstance(filename, str):
filename = str(filename, 'utf-8')
else:
mtype = headers.split(";")[0]
filename = get_random_filename(content_type=mtype)

doctype = doc.parenttype if doc.parent else doc.doctype
@@ -896,7 +907,7 @@ def extract_images_from_html(doc, content):
"attached_to_doctype": doctype,
"attached_to_name": name,
"content": content,
"decode": True
"decode": False
})
_file.save(ignore_permissions=True)
file_url = _file.file_url
@@ -929,6 +940,22 @@ def unzip_file(name):
files = file_obj.unzip()
return len(files)

@frappe.whitelist()
def optimize_saved_image(doc_name):
file_doc = frappe.get_doc('File', doc_name)
content = file_doc.get_content()
content_type = mimetypes.guess_type(file_doc.file_name)[0]

optimized_content = optimize_image(content, content_type)

file_path = get_files_path(is_private=file_doc.is_private)
file_path = os.path.join(file_path.encode('utf-8'), file_doc.file_name.encode('utf-8'))
with open(file_path, 'wb+') as f:
f.write(optimized_content)

file_doc.file_size = len(optimized_content)
file_doc.content_hash = get_content_hash(optimized_content)
file_doc.save()

@frappe.whitelist()
def get_attached_images(doctype, names):


+ 0
- 5
frappe/core/doctype/server_script/server_script.py Bestand weergeven

@@ -15,7 +15,6 @@ from frappe import _
class ServerScript(Document):
def validate(self):
frappe.only_for("Script Manager", True)
self.validate_script()
self.sync_scheduled_jobs()
self.clear_scheduled_events()

@@ -36,10 +35,6 @@ class ServerScript(Document):
fields=["name", "stopped"],
)

def validate_script(self):
"""Utilizes the ast module to check for syntax errors
"""
ast.parse(self.script)

def sync_scheduled_jobs(self):
"""Sync Scheduled Job Type statuses if Server Script's disabled status is changed


+ 10
- 0
frappe/core/doctype/server_script/test_server_script.py Bestand weergeven

@@ -109,3 +109,13 @@ class TestServerScript(unittest.TestCase):
"""Raise AttributeError if method not found in Namespace"""
note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"})
self.assertRaises(AttributeError, note.insert)

def test_syntax_validation(self):
server_script = scripts[0]
server_script["script"] = "js || code.?"

with self.assertRaises(frappe.ValidationError) as se:
frappe.get_doc(doctype="Server Script", **server_script).insert()

self.assertTrue("invalid python code" in str(se.exception).lower(),
msg="Python code validation not working")

+ 26
- 0
frappe/coverage.py Bestand weergeven

@@ -33,3 +33,29 @@ FRAPPE_EXCLUSIONS = [
"*/doctype/*/*_dashboard.py",
"*/patches/*",
]

class CodeCoverage():
def __init__(self, with_coverage, app):
self.with_coverage = with_coverage
self.app = app or 'frappe'

def __enter__(self):
if self.with_coverage:
import os
from coverage import Coverage
from frappe.utils import get_bench_path

# Generate coverage report only for app that is being tested
source_path = os.path.join(get_bench_path(), 'apps', self.app)
omit = STANDARD_EXCLUSIONS[:]

if self.app == 'frappe':
omit.extend(FRAPPE_EXCLUSIONS)

self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
self.coverage.start()

def __exit__(self, exc_type, exc_value, traceback):
if self.with_coverage:
self.coverage.stop()
self.coverage.save()

+ 2
- 2
frappe/custom/doctype/custom_field/custom_field.json Bestand weergeven

@@ -120,7 +120,7 @@
"label": "Field Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature",
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature",
"reqd": 1
},
{
@@ -417,7 +417,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-07-12 04:54:12.042319",
"modified": "2021-07-12 05:54:13.042319",
"modified_by": "Administrator",
"module": "Custom",
"name": "Custom Field",


+ 26
- 0
frappe/custom/doctype/customize_form/test_customize_form.py Bestand weergeven

@@ -232,6 +232,32 @@ class TestCustomizeForm(unittest.TestCase):
testdt.delete()
testdt1.delete()

def test_custom_internal_links(self):
# add a custom internal link
frappe.clear_cache()
d = self.get_customize_form("User Group")

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

d.run_method("save_customization")

frappe.clear_cache()
user_group = frappe.get_meta('User Group')

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

# remove the link
d = self.get_customize_form("User Group")
d.links = []
d.run_method("save_customization")

frappe.clear_cache()
user_group = frappe.get_meta('Event')
self.assertFalse([d.name for d in (user_group.links or []) if d.link_doctype == 'User Group Member'])

def test_custom_action(self):
test_route = '/app/List/DocType'



+ 2
- 2
frappe/custom/doctype/customize_form_field/customize_form_field.json Bestand weergeven

@@ -82,7 +82,7 @@
"label": "Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"reqd": 1,
"search_index": 1
},
@@ -428,7 +428,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-10-29 06:11:57.661039",
"modified": "2021-07-10 21:57:24.479749",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",


+ 1
- 1
frappe/database/database.py Bestand weergeven

@@ -543,7 +543,7 @@ class Database(object):
"""

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

if fieldname in self.value_cache[doctype]:
return self.value_cache[doctype][fieldname]


+ 2
- 1
frappe/database/mariadb/database.py Bestand weergeven

@@ -51,7 +51,8 @@ class MariaDBDatabase(Database):
'Color': ('varchar', self.VARCHAR_LEN),
'Barcode': ('longtext', ''),
'Geolocation': ('longtext', ''),
'Duration': ('decimal', '18,6')
'Duration': ('decimal', '18,6'),
'Icon': ('varchar', self.VARCHAR_LEN)
}

def get_connection(self):


+ 2
- 1
frappe/database/postgres/database.py Bestand weergeven

@@ -60,7 +60,8 @@ class PostgresDatabase(Database):
'Color': ('varchar', self.VARCHAR_LEN),
'Barcode': ('text', ''),
'Geolocation': ('text', ''),
'Duration': ('decimal', '18,6')
'Duration': ('decimal', '18,6'),
'Icon': ('varchar', self.VARCHAR_LEN)
}

def get_connection(self):


+ 17
- 3
frappe/handler.py Bestand weergeven

@@ -10,6 +10,8 @@ from frappe.utils import cint
from frappe import _, is_whitelisted
from frappe.utils.response import build_response
from frappe.utils.csvutils import build_csv_response
from frappe.utils.image import optimize_image
from mimetypes import guess_type
from frappe.core.doctype.server_script.server_script_utils import run_server_script_api


@@ -53,7 +55,7 @@ def execute_cmd(cmd, from_async=False):
try:
method = get_attr(cmd)
except Exception as e:
frappe.throw(_('Invalid Method'))
frappe.throw(_('Failed to get method for command {0} with {1}').format(cmd, e))

if from_async:
method = method.queue
@@ -145,6 +147,7 @@ def upload_file():
folder = frappe.form_dict.folder or 'Home'
method = frappe.form_dict.method
filename = frappe.form_dict.file_name
optimize = frappe.form_dict.optimize
content = None

if 'file' in files:
@@ -152,12 +155,23 @@ def upload_file():
content = file.stream.read()
filename = file.filename

content_type = guess_type(filename)[0]
if optimize and content_type.startswith("image/"):
args = {
"content": content,
"content_type": content_type
}
if frappe.form_dict.max_width:
args["max_width"] = int(frappe.form_dict.max_width)
if frappe.form_dict.max_height:
args["max_height"] = int(frappe.form_dict.max_height)
content = optimize_image(**args)

frappe.local.uploaded_file = content
frappe.local.uploaded_filename = filename

if not file_url and (frappe.session.user == "Guest" or (user and not user.has_desk_access())):
import mimetypes
filetype = mimetypes.guess_type(filename)[0]
filetype = guess_type(filename)[0]
if filetype not in ALLOWED_MIMETYPES:
frappe.throw(_("You can only upload JPG, PNG, PDF, or Microsoft documents."))



+ 18
- 1
frappe/integrations/doctype/webhook/test_webhook.py Bestand weergeven

@@ -4,7 +4,7 @@
import unittest

import frappe
from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data
from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data, enqueue_webhook


class TestWebhook(unittest.TestCase):
@@ -12,6 +12,8 @@ class TestWebhook(unittest.TestCase):
def setUpClass(cls):
# delete any existing webhooks
frappe.db.sql("DELETE FROM tabWebhook")
# Delete existing logs if any
frappe.db.sql("DELETE FROM `tabWebhook Request Log`")
# create test webhooks
cls.create_sample_webhooks()

@@ -162,3 +164,18 @@ class TestWebhook(unittest.TestCase):

data = get_webhook_data(doc=self.user, webhook=self.webhook)
self.assertEqual(data, {"name": self.user.name})

def test_webhook_req_log_creation(self):
if not frappe.db.get_value('User', 'user2@integration.webhooks.test.com'):
user = frappe.get_doc({
'doctype': 'User',
'email': 'user2@integration.webhooks.test.com',
'first_name': 'user2'
}).insert()
else:
user = frappe.get_doc('User', 'user2@integration.webhooks.test.com')

webhook = frappe.get_doc('Webhook', {'webhook_doctype': 'User'})
enqueue_webhook(user, webhook)

self.assertTrue(frappe.db.get_all('Webhook Request Log', pluck='name'))

+ 10
- 1
frappe/integrations/doctype/webhook/webhook.json Bestand weergeven

@@ -18,6 +18,7 @@
"html_condition",
"sb_webhook",
"request_url",
"request_method",
"cb_webhook",
"request_structure",
"sb_security",
@@ -154,10 +155,18 @@
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"default": "POST",
"fieldname": "request_method",
"fieldtype": "Select",
"label": "Request Method",
"options": "POST\nPUT\nDELETE",
"reqd": 1
}
],
"links": [],
"modified": "2021-04-14 05:35:28.532049",
"modified": "2021-05-25 11:11:28.555291",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Webhook",


+ 15
- 9
frappe/integrations/doctype/webhook/webhook.py Bestand weergeven

@@ -59,7 +59,6 @@ class Webhook(Document):
if self.request_structure == "Form URL-Encoded":
self.webhook_json = None
elif self.request_structure == "JSON":
validate_json(self.webhook_json)
validate_template(self.webhook_json)
self.webhook_data = []

@@ -83,18 +82,32 @@ def enqueue_webhook(doc, webhook):

for i in range(3):
try:
r = requests.post(webhook.request_url, data=json.dumps(data, default=str), headers=headers, timeout=5)
r = requests.request(method=webhook.request_method, url=webhook.request_url,
data=json.dumps(data, default=str), headers=headers, timeout=5)
r.raise_for_status()
frappe.logger().debug({"webhook_success": r.text})
log_request(webhook.request_url, headers, data, r)
break
except Exception as e:
frappe.logger().debug({"webhook_error": e, "try": i + 1})
log_request(webhook.request_url, headers, data, r)
sleep(3 * i + 1)
if i != 2:
continue
else:
raise e

def log_request(url, headers, data, res):
request_log = frappe.get_doc({
"doctype": "Webhook Request Log",
"user": frappe.session.user if frappe.session.user else None,
"url": url,
"headers": json.dumps(headers, indent=4) if headers else None,
"data": json.dumps(data, indent=4) if isinstance(data, dict) else data,
"response": json.dumps(res.json(), indent=4) if res else None
})

request_log.save(ignore_permissions=True)

def get_webhook_headers(doc, webhook):
headers = {}
@@ -129,10 +142,3 @@ def get_webhook_data(doc, webhook):
data = json.loads(data)

return data


def validate_json(string):
try:
json.loads(string)
except (TypeError, ValueError):
frappe.throw(_("Request Body consists of an invalid JSON structure"), title=_("Invalid JSON"))

+ 0
- 0
frappe/integrations/doctype/webhook_request_log/__init__.py Bestand weergeven


+ 8
- 0
frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py Bestand weergeven

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies and Contributors
# See license.txt

# import frappe
import unittest

class TestWebhookRequestLog(unittest.TestCase):
pass

+ 8
- 0
frappe/integrations/doctype/webhook_request_log/webhook_request_log.js Bestand weergeven

@@ -0,0 +1,8 @@
// Copyright (c) 2021, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on('Webhook Request Log', {
// refresh: function(frm) {

// }
});

+ 81
- 0
frappe/integrations/doctype/webhook_request_log/webhook_request_log.json Bestand weergeven

@@ -0,0 +1,81 @@
{
"actions": [],
"autoname": "WEBHOOK-REQ-.#####",
"creation": "2021-05-24 21:35:59.104776",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"user",
"headers",
"data",
"column_break_4",
"url",
"response"
],
"fields": [
{
"fieldname": "url",
"fieldtype": "Data",
"label": "URL",
"read_only": 1
},
{
"fieldname": "headers",
"fieldtype": "Code",
"label": "Headers",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "response",
"fieldtype": "Code",
"label": "Response",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "data",
"fieldtype": "Code",
"label": "Data",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User",
"read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-05-26 23:57:58.495261",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Webhook Request Log",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 8
- 0
frappe/integrations/doctype/webhook_request_log/webhook_request_log.py Bestand weergeven

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# For license information, please see license.txt

# import frappe
from frappe.model.document import Document

class WebhookRequestLog(Document):
pass

+ 14
- 26
frappe/integrations/utils.py Bestand weergeven

@@ -8,35 +8,14 @@ from urllib.parse import parse_qs
from frappe.utils import get_request_session
from frappe import _

def make_get_request(url, auth=None, headers=None, data=None):
if not auth:
auth = ''
if not data:
data = {}
if not headers:
headers = {}
def make_request(method, url, auth=None, headers=None, data=None):
auth = auth or ''
data = data or {}
headers = headers or {}

try:
s = get_request_session()
frappe.flags.integration_request = s.get(url, data={}, auth=auth, headers=headers)
frappe.flags.integration_request.raise_for_status()
return frappe.flags.integration_request.json()

except Exception as exc:
frappe.log_error(frappe.get_traceback())
raise exc

def make_post_request(url, auth=None, headers=None, data=None):
if not auth:
auth = ''
if not data:
data = {}
if not headers:
headers = {}

try:
s = get_request_session()
frappe.flags.integration_request = s.post(url, data=data, auth=auth, headers=headers)
frappe.flags.integration_request = s.request(method, url, data=data, auth=auth, headers=headers)
frappe.flags.integration_request.raise_for_status()

if frappe.flags.integration_request.headers.get("content-type") == "text/plain; charset=utf-8":
@@ -47,6 +26,15 @@ def make_post_request(url, auth=None, headers=None, data=None):
frappe.log_error()
raise exc

def make_get_request(url, **kwargs):
return make_request('GET', url, **kwargs)

def make_post_request(url, **kwargs):
return make_request('POST', url, **kwargs)

def make_put_request(url, **kwargs):
return make_request('PUT', url, **kwargs)

def create_request_log(data, integration_type, service_name, name=None, error=None):
if isinstance(data, str):
data = json.loads(data)


+ 2
- 1
frappe/model/__init__.py Bestand weergeven

@@ -34,7 +34,8 @@ data_fieldtypes = (
'Color',
'Barcode',
'Geolocation',
'Duration'
'Duration',
'Icon'
)

no_value_fields = (


+ 12
- 0
frappe/model/base_document.py Bestand weergeven

@@ -727,6 +727,18 @@ class BaseDocument(object):
if abs(cint(value)) > max_length:
self.throw_length_exceeded_error(df, max_length, value)

def _validate_code_fields(self):
for field in self.meta.get_code_fields():
code_string = self.get(field.fieldname)
language = field.get("options")

if language == "Python":
frappe.utils.validate_python_code(code_string, fieldname=field.label, is_expression=False)

elif language == "PythonExpression":
frappe.utils.validate_python_code(code_string, fieldname=field.label)


def throw_length_exceeded_error(self, df, max_length, value):
if self.parentfield and self.idx:
reference = _("{0}, Row {1}").format(_(self.doctype), self.idx)


+ 12
- 4
frappe/model/document.py Bestand weergeven

@@ -5,7 +5,7 @@ import time
from frappe import _, msgprint, is_whitelisted
from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff
from frappe.model.base_document import BaseDocument, get_controller
from frappe.model.naming import set_new_name
from frappe.model.naming import set_new_name, gen_new_name_for_cancelled_doc
from werkzeug.exceptions import NotFound, Forbidden
import hashlib, json
from frappe.model import optional_fields, table_fields
@@ -495,6 +495,7 @@ class Document(BaseDocument):
self._validate_selects()
self._validate_non_negative()
self._validate_length()
self._validate_code_fields()
self._extract_images_from_text_editor()
self._sanitize_content()
self._save_passwords()
@@ -506,6 +507,7 @@ class Document(BaseDocument):
d._validate_selects()
d._validate_non_negative()
d._validate_length()
d._validate_code_fields()
d._extract_images_from_text_editor()
d._sanitize_content()
d._save_passwords()
@@ -708,7 +710,6 @@ class Document(BaseDocument):
else:
tmp = frappe.db.sql("""select modified, docstatus from `tab{0}`
where name = %s for update""".format(self.doctype), self.name, as_dict=True)

if not tmp:
frappe.throw(_("Record does not exist"))
else:
@@ -919,8 +920,12 @@ class Document(BaseDocument):

@whitelist.__func__
def _cancel(self):
"""Cancel the document. Sets `docstatus` = 2, then saves."""
"""Cancel the document. Sets `docstatus` = 2, then saves.
"""
self.docstatus = 2
new_name = gen_new_name_for_cancelled_doc(self)
frappe.rename_doc(self.doctype, self.name, new_name, force=True, show_alert=False)
self.name = new_name
self.save()

@whitelist.__func__
@@ -1063,7 +1068,10 @@ class Document(BaseDocument):
self.set("modified", now())
self.set("modified_by", frappe.session.user)

self.load_doc_before_save()
# load but do not reload doc_before_save because before_change or on_change might expect it
if not self.get_doc_before_save():
self.load_doc_before_save()

# to trigger notification on value change
self.run_method('before_change')



+ 22
- 8
frappe/model/meta.py Bestand weergeven

@@ -141,6 +141,9 @@ class Meta(Document):
def get_image_fields(self):
return self.get("fields", {"fieldtype": "Attach Image"})

def get_code_fields(self):
return self.get("fields", {"fieldtype": "Code"})

def get_set_only_once_fields(self):
'''Return fields with `set_only_once` set'''
if not hasattr(self, "_set_only_once_fields"):
@@ -504,6 +507,9 @@ class Meta(Document):
if not data.non_standard_fieldnames:
data.non_standard_fieldnames = {}

if not data.internal_links:
data.internal_links = {}

for link in dashboard_links:
link.added = False
if link.hidden:
@@ -511,24 +517,32 @@ class Meta(Document):

for group in data.transactions:
group = frappe._dict(group)

# For internal links parent doctype will be the key
doctype = link.parent_doctype or link.link_doctype
# group found
if link.group and group.label == link.group:
if link.link_doctype not in group.get('items'):
group.get('items').append(link.link_doctype)
if doctype not in group.get('items'):
group.get('items').append(doctype)
link.added = True

if not link.added:
# group not found, make a new group
data.transactions.append(dict(
label = link.group,
items = [link.link_doctype]
items = [link.parent_doctype or link.link_doctype]
))

if link.link_fieldname != data.fieldname:
if data.fieldname:
data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname
else:
if not link.is_child_table:
if link.link_fieldname != data.fieldname:
if data.fieldname:
data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname
else:
data.fieldname = link.link_fieldname
elif link.is_child_table:
if not data.fieldname:
data.fieldname = link.link_fieldname
data.internal_links[link.parent_doctype] = [link.table_fieldname, link.link_fieldname]


def get_row_template(self):


+ 107
- 12
frappe/model/naming.py Bestand weergeven

@@ -1,3 +1,14 @@
"""utilities to generate a document name based on various rules defined.

NOTE:
Till version 13, whenever a submittable document is amended it's name is set to orig_name-X,
where X is a counter and it increments when amended again and so on.

From Version 14, The naming pattern is changed in a way that amended documents will
have the original name `orig_name` instead of `orig_name-X`. To make this happen
the cancelled document naming pattern is changed to 'orig_name-CANC-X'.
"""

# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt

@@ -28,7 +39,7 @@ def set_new_name(doc):
doc.name = None

if getattr(doc, "amended_from", None):
_set_amended_name(doc)
doc.name = _get_amended_name(doc)
return

elif getattr(doc.meta, "issingle", False):
@@ -221,6 +232,18 @@ def revert_series_if_last(key, name, doc=None):
* prefix = #### and hashes = 2021 (hash doesn't exist)
* will search hash in key then accordingly get prefix = ""
"""
if hasattr(doc, 'amended_from'):
# Do not revert the series if the document is amended.
if doc.amended_from:
return

# Get document name by parsing incase of fist cancelled document
if doc.docstatus == 2 and not doc.amended_from:
if doc.name.endswith('-CANC'):
name, _ = NameParser.parse_docname(doc.name, sep='-CANC')
else:
name, _ = NameParser.parse_docname(doc.name, sep='-CANC-')

if ".#" in key:
prefix, hashes = key.rsplit(".", 1)
if "#" not in hashes:
@@ -303,16 +326,9 @@ def append_number_if_name_exists(doctype, value, fieldname="name", separator="-"
return value


def _set_amended_name(doc):
am_id = 1
am_prefix = doc.amended_from
if frappe.db.get_value(doc.doctype, doc.amended_from, "amended_from"):
am_id = cint(doc.amended_from.split("-")[-1]) + 1
am_prefix = "-".join(doc.amended_from.split("-")[:-1]) # except the last hyphen

doc.name = am_prefix + "-" + str(am_id)
return doc.name

def _get_amended_name(doc):
name, _ = NameParser(doc).parse_amended_from()
return name

def _field_autoname(autoname, doc, skip_slicing=None):
"""
@@ -323,7 +339,6 @@ def _field_autoname(autoname, doc, skip_slicing=None):
name = (cstr(doc.get(fieldname)) or "").strip()
return name


def _prompt_autoname(autoname, doc):
"""
Generate a name using Prompt option. This simply means the user will have to set the name manually.
@@ -354,3 +369,83 @@ def _format_autoname(autoname, doc):
name = re.sub(r"(\{[\w | #]+\})", get_param_value_for_match, autoname_value)

return name

class NameParser:
"""Parse document name and return parts of it.

NOTE: It handles cancellend and amended doc parsing for now. It can be expanded.
"""
def __init__(self, doc):
self.doc = doc

def parse_amended_from(self):
"""
Cancelled document naming will be in one of these formats

* original_name-X-CANC - This is introduced to migrate old style naming to new style
* original_name-CANC - This is introduced to migrate old style naming to new style
* original_name-CANC-X - This is the new style naming

New style naming: In new style naming amended documents will have original name. That says,
when a document gets cancelled we need rename the document by adding `-CANC-X` to the end
so that amended documents can use the original name.

Old style naming: cancelled documents stay with original name and when amended, amended one
gets a new name as `original_name-X`. To bring new style naming we had to change the existing
cancelled document names and that is done by adding `-CANC` to cancelled documents through patch.
"""
if not getattr(self.doc, 'amended_from', None):
return (None, None)

# Handle old style cancelled documents (original_name-X-CANC, original_name-CANC)
if self.doc.amended_from.endswith('-CANC'):
name, _ = self.parse_docname(self.doc.amended_from, '-CANC')
amended_from_doc = frappe.get_all(
self.doc.doctype,
filters = {'name': self.doc.amended_from},
fields = ['amended_from'],
limit=1)

# Handle format original_name-X-CANC.
if amended_from_doc and amended_from_doc[0].amended_from:
return self.parse_docname(name, '-')
return name, None

# Handle new style cancelled documents
return self.parse_docname(self.doc.amended_from, '-CANC-')

@classmethod
def parse_docname(cls, name, sep='-'):
split_list = name.rsplit(sep, 1)

if len(split_list) == 1:
return (name, None)
return (split_list[0], split_list[1])

def get_cancelled_doc_latest_counter(tname, docname):
"""Get the latest counter used for cancelled docs of given docname.
"""
name_prefix = f'{docname}-CANC-'

rows = frappe.db.sql("""
select
name
from `tab{tname}`
where
name like %(name_prefix)s and docstatus=2
""".format(tname=tname), {'name_prefix': name_prefix+'%'}, as_dict=1)

if not rows:
return -1
return max([int(row.name.replace(name_prefix, '') or -1) for row in rows])

def gen_new_name_for_cancelled_doc(doc):
"""Generate a new name for cancelled document.
"""
if getattr(doc, "amended_from", None):
name, _ = NameParser(doc).parse_amended_from()
else:
name = doc.name

counter = get_cancelled_doc_latest_counter(doc.doctype, name)
return f'{name}-CANC-{counter+1}'

+ 3
- 29
frappe/parallel_test_runner.py Bestand weergeven

@@ -15,10 +15,9 @@ if click_ctx:
click_ctx.color = True

class ParallelTestRunner():
def __init__(self, app, site, build_number=1, total_builds=1, with_coverage=False):
def __init__(self, app, site, build_number=1, total_builds=1):
self.app = app
self.site = site
self.with_coverage = with_coverage
self.build_number = frappe.utils.cint(build_number) or 1
self.total_builds = frappe.utils.cint(total_builds)
self.setup_test_site()
@@ -53,12 +52,9 @@ class ParallelTestRunner():
def run_tests(self):
self.test_result = ParallelTestResult(stream=sys.stderr, descriptions=True, verbosity=2)

self.start_coverage()

for test_file_info in self.get_test_file_list():
self.run_tests_for_file(test_file_info)

self.save_coverage()
self.print_result()

def run_tests_for_file(self, file_info):
@@ -107,28 +103,6 @@ class ParallelTestRunner():
if os.environ.get('CI'):
sys.exit(1)

def start_coverage(self):
if self.with_coverage:
from coverage import Coverage
from frappe.utils import get_bench_path
from frappe.coverage import STANDARD_INCLUSIONS, STANDARD_EXCLUSIONS, FRAPPE_EXCLUSIONS

# Generate coverage report only for app that is being tested
source_path = os.path.join(get_bench_path(), 'apps', self.app)
omit = STANDARD_EXCLUSIONS[:]

if self.app == 'frappe':
omit.extend(FRAPPE_EXCLUSIONS)

self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
self.coverage.start()

def save_coverage(self):
if not self.with_coverage:
return
self.coverage.stop()
self.coverage.save()

def get_test_file_list(self):
test_list = get_all_tests(self.app)
split_size = frappe.utils.ceil(len(test_list) / self.total_builds)
@@ -224,7 +198,7 @@ class ParallelTestWithOrchestrator(ParallelTestRunner):
- get-next-test-spec (<build_id>, <instance_id>)
- test-completed (<build_id>, <instance_id>)
'''
def __init__(self, app, site, with_coverage=False):
def __init__(self, app, site):
self.orchestrator_url = os.environ.get('ORCHESTRATOR_URL')
if not self.orchestrator_url:
click.echo('ORCHESTRATOR_URL environment variable not found!')
@@ -237,7 +211,7 @@ class ParallelTestWithOrchestrator(ParallelTestRunner):
click.echo('CI_BUILD_ID environment variable not found!')
sys.exit(1)

ParallelTestRunner.__init__(self, app, site, with_coverage=with_coverage)
ParallelTestRunner.__init__(self, app, site)

def run_tests(self):
self.test_status = 'ongoing'


+ 1
- 0
frappe/patches.txt Bestand weergeven

@@ -181,3 +181,4 @@ frappe.patches.v13_0.queryreport_columns
frappe.patches.v13_0.jinja_hook
frappe.patches.v13_0.update_notification_channel_if_empty
frappe.patches.v14_0.drop_data_import_legacy
frappe.patches.v14_0.rename_cancelled_documents

+ 213
- 0
frappe/patches/v14_0/rename_cancelled_documents.py Bestand weergeven

@@ -0,0 +1,213 @@
import functools
import traceback

import frappe

def execute():
"""Rename cancelled documents by adding a postfix.
"""
rename_cancelled_docs()

def get_submittable_doctypes():
"""Returns list of submittable doctypes in the system.
"""
return frappe.db.get_all('DocType', filters={'is_submittable': 1}, pluck='name')

def get_cancelled_doc_names(doctype):
"""Return names of cancelled document names those are in old format.
"""
docs = frappe.db.get_all(doctype, filters={'docstatus': 2}, pluck='name')
return [each for each in docs if not (each.endswith('-CANC') or ('-CANC-' in each))]

@functools.lru_cache()
def get_linked_doctypes():
"""Returns list of doctypes those are linked with given doctype using 'Link' fieldtype.
"""
filters=[['fieldtype','=', 'Link']]
links = frappe.get_all("DocField",
fields=["parent", "fieldname", "options as linked_to"],
filters=filters,
as_list=1)

links+= frappe.get_all("Custom Field",
fields=["dt as parent", "fieldname", "options as linked_to"],
filters=filters,
as_list=1)

links_by_doctype = {}
for doctype, fieldname, linked_to in links:
links_by_doctype.setdefault(linked_to, []).append((doctype, fieldname))
return links_by_doctype

@functools.lru_cache()
def get_single_doctypes():
return frappe.get_all("DocType", filters={'issingle': 1}, pluck='name')

@functools.lru_cache()
def get_dynamic_linked_doctypes():
filters=[['fieldtype','=', 'Dynamic Link']]

# find dynamic links of parents
links = frappe.get_all("DocField",
fields=["parent as doctype", "fieldname", "options as doctype_fieldname"],
filters=filters,
as_list=1)
links+= frappe.get_all("Custom Field",
fields=["dt as doctype", "fieldname", "options as doctype_fieldname"],
filters=filters,
as_list=1)
return links

@functools.lru_cache()
def get_child_tables():
"""
"""
filters =[['fieldtype', 'in', ('Table', 'Table MultiSelect')]]
links = frappe.get_all("DocField",
fields=["parent as doctype", "options as child_table"],
filters=filters,
as_list=1)

links+= frappe.get_all("Custom Field",
fields=["dt as doctype", "options as child_table"],
filters=filters,
as_list=1)

map = {}
for doctype, child_table in links:
map.setdefault(doctype, []).append(child_table)
return map

def update_cancelled_document_names(doctype, cancelled_doc_names):
return frappe.db.sql("""
update
`tab{doctype}`
set
name=CONCAT(name, '-CANC')
where
docstatus=2
and
name in %(cancelled_doc_names)s;
""".format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names})

def update_amended_field(doctype, cancelled_doc_names):
return frappe.db.sql("""
update
`tab{doctype}`
set
amended_from=CONCAT(amended_from, '-CANC')
where
amended_from in %(cancelled_doc_names)s;
""".format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names})

def update_attachments(doctype, cancelled_doc_names):
frappe.db.sql("""
update
`tabFile`
set
attached_to_name=CONCAT(attached_to_name, '-CANC')
where
attached_to_doctype=%(dt)s and attached_to_name in %(cancelled_doc_names)s
""", {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype})

def update_versions(doctype, cancelled_doc_names):
frappe.db.sql("""
UPDATE
`tabVersion`
SET
docname=CONCAT(docname, '-CANC')
WHERE
ref_doctype=%(dt)s AND docname in %(cancelled_doc_names)s
""", {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype})

def update_linked_doctypes(doctype, cancelled_doc_names):
single_doctypes = get_single_doctypes()

for linked_dt, field in get_linked_doctypes().get(doctype, []):
if linked_dt not in single_doctypes:
frappe.db.sql("""
update
`tab{linked_dt}`
set
{column}=CONCAT({column}, '-CANC')
where
{column} in %(cancelled_doc_names)s;
""".format(linked_dt=linked_dt, column=field),
{'cancelled_doc_names': cancelled_doc_names})
else:
doc = frappe.get_single(linked_dt)
if getattr(doc, field) in cancelled_doc_names:
setattr(doc, field, getattr(doc, field)+'-CANC')
doc.flags.ignore_mandatory=True
doc.flags.ignore_validate=True
doc.save(ignore_permissions=True)

def update_dynamic_linked_doctypes(doctype, cancelled_doc_names):
single_doctypes = get_single_doctypes()

for linked_dt, fieldname, doctype_fieldname in get_dynamic_linked_doctypes():
if linked_dt not in single_doctypes:
frappe.db.sql("""
update
`tab{linked_dt}`
set
{column}=CONCAT({column}, '-CANC')
where
{column} in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s;
""".format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname),
{'cancelled_doc_names': cancelled_doc_names, 'dt': doctype})
else:
doc = frappe.get_single(linked_dt)
if getattr(doc, doctype_fieldname) == doctype and getattr(doc, fieldname) in cancelled_doc_names:
setattr(doc, fieldname, getattr(doc, fieldname)+'-CANC')
doc.flags.ignore_mandatory=True
doc.flags.ignore_validate=True
doc.save(ignore_permissions=True)

def update_child_tables(doctype, cancelled_doc_names):
child_tables = get_child_tables().get(doctype, [])
single_doctypes = get_single_doctypes()

for table in child_tables:
if table not in single_doctypes:
frappe.db.sql("""
update
`tab{table}`
set
parent=CONCAT(parent, '-CANC')
where
parenttype=%(dt)s and parent in %(cancelled_doc_names)s;
""".format(table=table), {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype})
else:
doc = frappe.get_single(table)
if getattr(doc, 'parenttype')==doctype and getattr(doc, 'parent') in cancelled_doc_names:
setattr(doc, 'parent', getattr(doc, 'parent')+'-CANC')
doc.flags.ignore_mandatory=True
doc.flags.ignore_validate=True
doc.save(ignore_permissions=True)

def rename_cancelled_docs():
submittable_doctypes = get_submittable_doctypes()

for dt in submittable_doctypes:
for retry in range(2):
try:
cancelled_doc_names = tuple(get_cancelled_doc_names(dt))
if not cancelled_doc_names:
break
update_cancelled_document_names(dt, cancelled_doc_names)
update_amended_field(dt, cancelled_doc_names)
update_child_tables(dt, cancelled_doc_names)
update_linked_doctypes(dt, cancelled_doc_names)
update_dynamic_linked_doctypes(dt, cancelled_doc_names)
update_attachments(dt, cancelled_doc_names)
update_versions(dt, cancelled_doc_names)
print(f"Renaming cancelled records of {dt} doctype")
frappe.db.commit()
break
except Exception:
if retry == 1:
print(f"Failed to rename the cancelled records of {dt} doctype, moving on!")
traceback.print_exc()
frappe.db.rollback()


+ 1
- 1
frappe/permissions.py Bestand weergeven

@@ -301,7 +301,7 @@ def has_controller_permissions(doc, ptype, user=None):
if not methods:
return None

for method in methods:
for method in reversed(methods):
controller_permission = frappe.call(frappe.get_attr(method), doc=doc, ptype=ptype, user=user)
if controller_permission is not None:
return controller_permission


+ 4
- 1
frappe/public/icons/timeless/symbol-defs.svg Bestand weergeven

@@ -1,4 +1,4 @@
<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg">
<svg id="frappe-symbols" aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg">
<symbol viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" id="icon-resting">
<path d="M7.606 3.799L8 4.302l.394-.503.106-.14c.048-.065.08-.108.129-.159a3.284 3.284 0 0 1 4.72 0c.424.434.655 1.245.65 2.278-.006 1.578-.685 2.931-1.728 4.159-1.05 1.234-2.439 2.308-3.814 3.328a.763.763 0 0 1-.914 0c-1.375-1.02-2.764-2.094-3.814-3.328C2.686 8.709 2.007 7.357 2 5.778c-.004-1.033.227-1.844.651-2.278a3.284 3.284 0 0 1 4.72 0c.05.05.081.094.129.158.028.038.061.083.106.14z"
stroke="var(--icon-stroke)"></path>
@@ -699,4 +699,7 @@
<path d="M7.971 8.259a1.305 1.305 0 100-2.61 1.305 1.305 0 000 2.61z"></path>
</g>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" id="icon-crop">
<path d="M14.88,11.63H4.33V1.12m7.34,10.51v3.25M6,4.37h5.64V10M1.13,4.37h3.2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</svg>

+ 1
- 15
frappe/public/js/controls.bundle.js Bestand weergeven

@@ -1,18 +1,4 @@
import "air-datepicker/dist/js/datepicker.min.js";
import "air-datepicker/dist/js/i18n/datepicker.cs.js";
import "air-datepicker/dist/js/i18n/datepicker.da.js";
import "air-datepicker/dist/js/i18n/datepicker.de.js";
import "air-datepicker/dist/js/i18n/datepicker.en.js";
import "air-datepicker/dist/js/i18n/datepicker.es.js";
import "air-datepicker/dist/js/i18n/datepicker.fi.js";
import "air-datepicker/dist/js/i18n/datepicker.fr.js";
import "air-datepicker/dist/js/i18n/datepicker.hu.js";
import "air-datepicker/dist/js/i18n/datepicker.nl.js";
import "air-datepicker/dist/js/i18n/datepicker.pl.js";
import "air-datepicker/dist/js/i18n/datepicker.pt-BR.js";
import "air-datepicker/dist/js/i18n/datepicker.pt.js";
import "air-datepicker/dist/js/i18n/datepicker.ro.js";
import "air-datepicker/dist/js/i18n/datepicker.sk.js";
import "air-datepicker/dist/js/i18n/datepicker.zh.js";
import "./frappe/form/controls/datepicker_i18n.js";
import "./frappe/ui/capture.js";
import "./frappe/form/controls/control.js";

+ 37
- 2
frappe/public/js/frappe/file_uploader/FilePreview.vue Bestand weergeven

@@ -28,6 +28,7 @@
{{ file.file_obj.size | file_size }}
</span>
</div>
<label v-if="is_optimizable" class="optimize-checkbox"><input type="checkbox" :checked="optimize" @change="$emit('toggle_optimize')">Optimize</label>
</div>
<div class="file-actions">
<ProgressRing
@@ -40,7 +41,10 @@
/>
<div v-if="uploaded" v-html="frappe.utils.icon('solid-success', 'lg')"></div>
<div v-if="file.failed" v-html="frappe.utils.icon('solid-red', 'lg')"></div>
<button v-if="!uploaded && !file.uploading" class="btn" @click="$emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
<div class="file-action-buttons">
<button v-if="is_cropable" class="btn btn-crop muted" @click="$emit('toggle_image_cropper')" v-html="frappe.utils.icon('crop', 'md')"></button>
<button v-if="!uploaded && !file.uploading" class="btn muted" @click="$emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
</div>
</div>
</div>
</template>
@@ -55,7 +59,8 @@ export default {
},
data() {
return {
src: null
src: null,
optimize: this.file.optimize
}
},
mounted() {
@@ -89,6 +94,14 @@ export default {
is_image() {
return this.file.file_obj.type.startsWith('image');
},
is_optimizable() {
let is_svg = this.file.file_obj.type == 'image/svg+xml';
return this.is_image && !is_svg;
},
is_cropable() {
let croppable_types = ['image/jpeg', 'image/png'];
return !this.uploaded && !this.file.uploading && croppable_types.includes(this.file.file_obj.type);
},
progress() {
let value = Math.round((this.file.progress * 100) / this.file.total);
if (isNaN(value)) {
@@ -173,4 +186,26 @@ export default {
padding: var(--padding-xs);
box-shadow: none;
}

.file-action-buttons {
display: flex;
justify-content: flex-end;
}

.muted {
opacity: 0.5;
transition: 0.3s;
}

.muted:hover {
opacity: 1;
}

.optimize-checkbox {
font-size: var(--text-sm);
color: var(--text-light);
display: flex;
align-items: center;
padding-top: 0.25rem;
}
</style>

+ 42
- 3
frappe/public/js/frappe/file_uploader/FileUploader.vue Bestand weergeven

@@ -46,7 +46,7 @@
</svg>
<div class="mt-1">{{ __('Library') }}</div>
</button>
<button class="btn btn-file-upload" @click="show_web_link = true">
<button class="btn btn-file-upload" v-if="allow_web_link" @click="show_web_link = true">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15" cy="15" r="15" fill="#ECAC4B"/>
<path d="M12.0469 17.9543L17.9558 12.0454" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
@@ -79,13 +79,15 @@
</div>
</div>
<div class="file-preview-area" v-show="files.length && !show_file_browser && !show_web_link">
<div class="file-preview-container">
<div class="file-preview-container" v-if="!show_image_cropper">
<FilePreview
v-for="(file, i) in files"
:key="file.name"
:file="file"
@remove="remove_file(file)"
@toggle_private="file.private = !file.private"
@toggle_optimize="file.optimize = !file.optimize"
@toggle_image_cropper="toggle_image_cropper(i)"
/>
</div>
<div class="flex align-center" v-if="show_upload_button && currently_uploading === -1">
@@ -105,6 +107,13 @@
</div>
</div>
</div>
<ImageCropper
v-if="show_image_cropper"
:file="files[crop_image_with_index]"
:attach_doc_image="attach_doc_image"
@toggle_image_cropper="toggle_image_cropper(-1)"
@upload_after_crop="trigger_upload=true"
/>
<FileBrowser
ref="file_browser"
v-if="show_file_browser && !disable_file_browser"
@@ -123,6 +132,7 @@ import FilePreview from './FilePreview.vue';
import FileBrowser from './FileBrowser.vue';
import WebLink from './WebLink.vue';
import GoogleDrivePicker from '../../integrations/google_drive_picker';
import ImageCropper from './ImageCropper.vue';

export default {
name: 'FileUploader',
@@ -164,6 +174,9 @@ export default {
allowed_file_types: [] // ['image/*', 'video/*', '.jpg', '.gif', '.pdf']
})
},
attach_doc_image: {
default: false
},
upload_notes: {
default: null // "Images or video, upto 2MB"
}
@@ -171,7 +184,8 @@ export default {
components: {
FilePreview,
FileBrowser,
WebLink
WebLink,
ImageCropper
},
data() {
return {
@@ -180,7 +194,12 @@ export default {
currently_uploading: -1,
show_file_browser: false,
show_web_link: false,
show_image_cropper: false,
crop_image_with_index: -1,
trigger_upload: false,
hide_dialog_footer: false,
allow_take_photo: false,
allow_web_link: true,
google_drive_settings: {
enabled: false
}
@@ -234,6 +253,11 @@ export default {
remove_file(file) {
this.files = this.files.filter(f => f !== file);
},
toggle_image_cropper(index) {
this.crop_image_with_index = this.show_image_cropper ? -1 : index;
this.hide_dialog_footer = !this.show_image_cropper;
this.show_image_cropper = !this.show_image_cropper;
},
toggle_all_private() {
let flag;
let private_values = this.files.filter(file => file.private);
@@ -257,6 +281,9 @@ export default {
let is_image = file.type.startsWith('image');
return {
file_obj: file,
cropper_file: file,
crop_box_data: null,
optimize: this.attach_doc_image ? true : false,
name: file.name,
doc: null,
progress: 0,
@@ -267,6 +294,9 @@ export default {
}
});
this.files = this.files.concat(files);
if(this.files.length != 0 && this.attach_doc_image) {
this.toggle_image_cropper(0);
}
},
check_restrictions(file) {
let { max_file_size, allowed_file_types } = this.restrictions;
@@ -447,6 +477,15 @@ export default {
form_data.append('method', this.method);
}

if (file.optimize) {
form_data.append('optimize', true);
}

if (this.attach_doc_image) {
form_data.append('max_width', 200);
form_data.append('max_height', 200);
}

xhr.send(form_data);
});
},


+ 80
- 0
frappe/public/js/frappe/file_uploader/ImageCropper.vue Bestand weergeven

@@ -0,0 +1,80 @@
<template>
<div>
<div>
<img ref="image" :src="src" :alt="file.name"/>
</div>
<br/>
<div class="image-cropper-actions">
<button class="btn btn-sm margin-right" v-if="!attach_doc_image" @click="$emit('toggle_image_cropper')">Back</button>
<button class="btn btn-primary btn-sm margin-right" @click="crop_image" v-html="crop_button_text"></button>
</div>
</div>
</template>

<script>
import Cropper from "cropperjs";
export default {
name: "ImageCropper",
props: ["file", "attach_doc_image"],
data() {
return {
src: null,
cropper: null,
image: null
};
},
mounted() {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => (this.src = fr.result);
fr.readAsDataURL(this.file.cropper_file);
}
aspect_ratio = this.attach_doc_image ? 1 : NaN;
crop_box = this.file.crop_box_data;
this.image = this.$refs.image;
this.image.onload = () => {
this.cropper = new Cropper(this.image, {
zoomable: false,
scalable: false,
viewMode: 1,
data: crop_box,
aspectRatio: aspect_ratio
});
};
},
computed: {
crop_button_text() {
return this.attach_doc_image ? "Upload" : "Crop";
}
},
methods: {
crop_image() {
this.file.crop_box_data = this.cropper.getData();
const canvas = this.cropper.getCroppedCanvas();
const file_type = this.file.file_obj.type;
canvas.toBlob(blob => {
var cropped_file_obj = new File([blob], this.file.name, {
type: blob.type
});
this.file.file_obj = cropped_file_obj;
this.$emit("toggle_image_cropper");
if(this.attach_doc_image) {
this.$emit("upload_after_crop");
}
}, file_type);
}
}
};
</script>
<style>
img {
display: block;
max-width: 100%;
max-height: 600px;
}

.image-cropper-actions {
display: flex;
justify-content: flex-end;
}
</style>

+ 20
- 0
frappe/public/js/frappe/file_uploader/index.js Bestand weergeven

@@ -15,6 +15,7 @@ export default class FileUploader {
allow_multiple,
as_dataurl,
disable_file_browser,
attach_doc_image,
frm
} = {}) {

@@ -26,6 +27,10 @@ export default class FileUploader {
this.wrapper = wrapper.get ? wrapper.get(0) : wrapper;
}

if (attach_doc_image) {
restrictions.allowed_file_types = ['.jpg', '.jpeg', '.png'];
}

this.$fileuploader = new Vue({
el: this.wrapper,
render: h => h(FileUploaderComponent, {
@@ -42,6 +47,7 @@ export default class FileUploader {
allow_multiple,
as_dataurl,
disable_file_browser,
attach_doc_image,
}
})
});
@@ -55,6 +61,20 @@ export default class FileUploader {
}
}, { deep: true });

this.uploader.$watch('trigger_upload', (trigger_upload) => {
if (trigger_upload) {
this.upload_files();
}
});

this.uploader.$watch('hide_dialog_footer', (hide_dialog_footer) => {
if (hide_dialog_footer) {
this.dialog && this.dialog.footer.addClass('hide');
} else {
this.dialog && this.dialog.footer.removeClass('hide');
}
});

if (files && files.length) {
this.uploader.add_files(files);
}


+ 12
- 2
frappe/public/js/frappe/form/controls/attach.js Bestand weergeven

@@ -4,8 +4,13 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
this.$input = $('<button class="btn btn-default btn-sm btn-attach">')
.html(__("Attach"))
.prependTo(me.input_area)
.on("click", function() {
me.on_attach_click();
.on({
click: function() {
me.on_attach_click();
},
attach_doc_image: function() {
me.on_attach_doc_image();
}
});
this.$value = $(
`<div class="attached-file flex justify-between align-center">
@@ -54,6 +59,11 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
this.set_upload_options();
this.file_uploader = new frappe.ui.FileUploader(this.upload_options);
}
on_attach_doc_image() {
this.set_upload_options();
this.upload_options["attach_doc_image"] = true;
this.file_uploader = new frappe.ui.FileUploader(this.upload_options);
}
set_upload_options() {
let options = {
allow_multiple: false,


+ 1
- 0
frappe/public/js/frappe/form/controls/code.js Bestand weergeven

@@ -24,6 +24,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex
this.editor = ace.edit(this.ace_editor_target.get(0));
this.editor.setTheme('ace/theme/tomorrow');
this.editor.setOption("showPrintMargin", false);
this.editor.setOption("wrap", this.df.wrap);
this.set_language();

// events


+ 1
- 0
frappe/public/js/frappe/form/controls/control.js Bestand weergeven

@@ -39,6 +39,7 @@ import './multiselect_pills';
import './multiselect_list';
import './rating';
import './duration';
import './icon';

frappe.ui.form.make_control = function (opts) {
var control_class_name = "Control" + opts.df.fieldtype.replace(/ /g, "");


+ 62
- 0
frappe/public/js/frappe/form/controls/datepicker_i18n.js Bestand weergeven

@@ -0,0 +1,62 @@
import "air-datepicker/dist/js/i18n/datepicker.cs.js";
import "air-datepicker/dist/js/i18n/datepicker.da.js";
import "air-datepicker/dist/js/i18n/datepicker.de.js";
import "air-datepicker/dist/js/i18n/datepicker.en.js";
import "air-datepicker/dist/js/i18n/datepicker.es.js";
import "air-datepicker/dist/js/i18n/datepicker.fi.js";
import "air-datepicker/dist/js/i18n/datepicker.fr.js";
import "air-datepicker/dist/js/i18n/datepicker.hu.js";
import "air-datepicker/dist/js/i18n/datepicker.nl.js";
import "air-datepicker/dist/js/i18n/datepicker.pl.js";
import "air-datepicker/dist/js/i18n/datepicker.pt-BR.js";
import "air-datepicker/dist/js/i18n/datepicker.pt.js";
import "air-datepicker/dist/js/i18n/datepicker.ro.js";
import "air-datepicker/dist/js/i18n/datepicker.sk.js";
import "air-datepicker/dist/js/i18n/datepicker.zh.js";

(function ($) {
$.fn.datepicker.language['ar'] = {
days: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'],
daysShort: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'],
daysMin: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'],
months: ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'اكتوبر', 'نوفمبر', 'ديسمبر'],
monthsShort: ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'اكتوبر', 'نوفمبر', 'ديسمبر'],
today: 'اليوم',
clear: 'Clear',
dateFormat: 'dd/mm/yyyy',
timeFormat: 'hh:ii aa',
firstDay: 0
};
})(jQuery);

(function ($) {
$.fn.datepicker.language['gr'] = {
days: ['Κυριακή', 'Δευτέρα', 'Τρίτη', 'Τετάρτη', 'Πέμπτη', 'Παρασκευή', 'Σάββατο'],
daysShort: ['Κυρ', 'Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ'],
daysMin: ['Κυ', 'Δε', 'Τρ', 'Τε', 'Πε', 'Πα', 'Σα'],
months: ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'],
monthsShort: ['Ιαν', 'Φεβ', 'Μαρ', 'Απρ', 'Μάι', 'Ι/ν', 'Ι/λ', 'Αυγ', 'Σεπ', 'Οκτ', 'Νοε', 'Δεκ'],
today: 'Σήμερα',
clear: 'Καθαρισμός',
dateFormat: 'dd/mm/yyyy',
timeFormat: 'hh:ii aa',
firstDay: 0
};
})(jQuery);


(function ($) {
$.fn.datepicker.language['it'] = {
days: ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'],
daysShort: ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'],
daysMin: ['Do', 'Lu', 'Ma', 'Me', 'Gi', 'Ve', 'Sa'],
months: ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto',
'Settembre', 'Ottobre', 'Novembre', 'Dicembre'],
monthsShort: ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'],
today: 'Oggi',
clear: 'Reset',
dateFormat: 'dd/mm/yyyy',
timeFormat: 'hh:ii',
firstDay: 1
};
})(jQuery);

+ 93
- 0
frappe/public/js/frappe/form/controls/icon.js Bestand weergeven

@@ -0,0 +1,93 @@
import Picker from '../../icon_picker/icon_picker';

frappe.ui.form.ControlIcon = class ControlIcon extends frappe.ui.form.ControlData {
make_input() {
this.df.placeholder = this.df.placeholder || __('Choose an icon');
super.make_input();
this.get_all_icons();
this.make_icon_input();
}

get_all_icons() {
frappe.symbols = [];
$("#frappe-symbols > symbol[id]").each(function() {
frappe.symbols.push(this.id.replace('icon-', ''));
});
}

make_icon_input() {
let picker_wrapper = $('<div>');
this.picker = new Picker({
parent: picker_wrapper,
icon: this.get_icon(),
icons: frappe.symbols
});

this.$wrapper.popover({
trigger: 'manual',
offset: `${-this.$wrapper.width() / 4.5}, 5`,
boundary: 'viewport',
placement: 'bottom',
template: `
<div class="popover icon-picker-popover">
<div class="picker-arrow arrow"></div>
<div class="popover-body popover-content"></div>
</div>
`,
content: () => picker_wrapper,
html: true
}).on('show.bs.popover', () => {
setTimeout(() => {
this.picker.refresh();
}, 10);
}).on('hidden.bs.popover', () => {
$('body').off('click.icon-popover');
$(window).off('hashchange.icon-popover');
});

this.picker.on_change = (icon) => {
this.set_value(icon);
};

if (!this.selected_icon) {
this.selected_icon = $(`<div class="selected-icon">${frappe.utils.icon("folder-normal", "md")}</div>`);
this.selected_icon.insertAfter(this.$input);
}

this.$wrapper.find('.selected-icon').parent().on('click', (e) => {
this.$wrapper.popover('toggle');
if (!this.get_icon()) {
this.$input.val('');
}
e.stopPropagation();
$('body').on('click.icon-popover', (ev) => {
if (!$(ev.target).parents().is('.popover')) {
this.$wrapper.popover('hide');
}
});
$(window).on('hashchange.icon-popover', () => {
this.$wrapper.popover('hide');
});
});
}

refresh() {
super.refresh();
let icon = this.get_icon();
if (this.picker && this.picker.icon !== icon) {
this.picker.icon = icon;
this.picker.refresh();
}
}

set_formatted_input(value) {
super.set_formatted_input(value);
this.$input.val(value);
this.selected_icon.find("use").attr("href", "#icon-"+(value || "folder-normal"));
this.selected_icon.toggleClass('no-value', !value);
}

get_icon() {
return this.get_value() || 'folder-normal';
}
};

+ 2
- 0
frappe/public/js/frappe/form/controls/select.js Bestand weergeven

@@ -113,6 +113,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro
var is_value_null = is_null(v.value);
var is_label_null = is_null(v.label);
var is_disabled = Boolean(v.disabled);
var is_selected = Boolean(v.selected);

if (is_value_null && is_label_null) {
value = v;
@@ -126,6 +127,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro
$('<option>').html(cstr(label))
.attr('value', value)
.prop('disabled', is_disabled)
.prop('selected', is_selected)
.appendTo(this);
}
// select the first option


+ 1
- 0
frappe/public/js/frappe/form/footer/version_timeline_content_builder.js Bestand weergeven

@@ -152,6 +152,7 @@ function get_version_comment(version_doc, text) {
let unlinked_content = "";

try {
text += '</>';
Array.from($(text)).forEach(element => {
if ($(element).is('a')) {
version_comment += unlinked_content ? frappe.utils.get_form_link('Version', version_doc.name, true, unlinked_content) : "";


+ 14
- 10
frappe/public/js/frappe/form/form.js Bestand weergeven

@@ -770,32 +770,36 @@ frappe.ui.form.Form = class FrappeForm {
}

_cancel(btn, callback, on_error, skip_confirm) {
const me = this;
const cancel_doc = () => {
frappe.validated = true;
me.script_manager.trigger("before_cancel").then(() => {
this.script_manager.trigger("before_cancel").then(() => {
if (!frappe.validated) {
return me.handle_save_fail(btn, on_error);
return this.handle_save_fail(btn, on_error);
}

var after_cancel = function(r) {
const original_name = this.docname;
const after_cancel = (r) => {
if (r.exc) {
me.handle_save_fail(btn, on_error);
this.handle_save_fail(btn, on_error);
} else {
frappe.utils.play_sound("cancel");
me.refresh();
callback && callback();
me.script_manager.trigger("after_cancel");
this.script_manager.trigger("after_cancel");
frappe.run_serially([
() => this.rename_notify(this.doctype, original_name, r.docs[0].name),
() => frappe.router.clear_re_route(this.doctype, original_name),
() => this.refresh(),
]);
}
};
frappe.ui.form.save(me, "cancel", after_cancel, btn);
frappe.ui.form.save(this, "cancel", after_cancel, btn);
});
}

if (skip_confirm) {
cancel_doc();
} else {
frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, me.handle_save_fail(btn, on_error));
frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, this.handle_save_fail(btn, on_error));
}
};

@@ -817,7 +821,7 @@ frappe.ui.form.Form = class FrappeForm {
'docname': this.doc.name
}).then(is_amended => {
if (is_amended) {
frappe.throw(__('This document is already amended, you cannot ammend it again'));
frappe.throw(__('This document is already amended, you cannot amend it again'));
}
this.validate_form_action("Amend");
var me = this;


+ 1
- 1
frappe/public/js/frappe/form/form_tour.js Bestand weergeven

@@ -92,7 +92,7 @@ frappe.ui.form.FormTour = class FormTour {
return {
element,
name,
popover: { title, description, position: frappe.router.slug(position) },
popover: { title, description, position: frappe.router.slug(position || 'Bottom') },
onNext: on_next
};
}


+ 6
- 0
frappe/public/js/frappe/form/formatters.js Bestand weergeven

@@ -303,6 +303,12 @@ frappe.form.formatters = {
<div class="selected-color" style="background-color: ${value}"></div>
<span class="color-value">${value}</span>
</div>` : '';
},
Icon: (value) => {
return value ? `<div>
<div class="selected-icon">${frappe.utils.icon(value, "md")}</div>
<span class="icon-value">${value}</span>
</div>` : '';
}
};



+ 9
- 8
frappe/public/js/frappe/form/grid.js Bestand weergeven

@@ -264,15 +264,16 @@ export default class Grid {

make_head() {
// labels
if (!this.header_row) {
this.header_row = new GridRow({
parent: $(this.parent).find(".grid-heading-row"),
parent_df: this.df,
docfields: this.docfields,
frm: this.frm,
grid: this
});
if (this.header_row) {
$(this.parent).find(".grid-heading-row .grid-row").remove();
}
this.header_row = new GridRow({
parent: $(this.parent).find(".grid-heading-row"),
parent_df: this.df,
docfields: this.docfields,
frm: this.frm,
grid: this
});
}

refresh(force) {


+ 8
- 0
frappe/public/js/frappe/form/layout.js Bestand weergeven

@@ -250,6 +250,14 @@ frappe.ui.form.Layout = class Layout {
// collapse sections
this.refresh_section_collapse();
}

if (document.activeElement) {
document.activeElement.focus();
if (document.activeElement.tagName == 'INPUT') {
document.activeElement.select();
}
}
}

refresh_sections() {


+ 1
- 1
frappe/public/js/frappe/form/sidebar/user_image.js Bestand weergeven

@@ -83,7 +83,7 @@ frappe.ui.form.setup_user_image_event = function(frm) {
if(!field.$input) {
field.make_input();
}
field.$input.trigger('click');
field.$input.trigger('attach_doc_image');
} else {
/// on remove event for a sidebar image wrapper remove attach file.
frm.attachments.remove_attachment_by_filename(frm.doc[frm.meta.image_field], function() {


+ 86
- 0
frappe/public/js/frappe/icon_picker/icon_picker.js Bestand weergeven

@@ -0,0 +1,86 @@
class Picker {
constructor(opts) {
this.parent = opts.parent;
this.width = opts.width;
this.height = opts.height;
this.set_icon(opts.icon);
this.icons = opts.icons;
this.setup_picker();
}

refresh() {
this.update_icon_selected(true);
}

setup_picker() {
this.icon_picker_wrapper = $(`
<div class="icon-picker">
<div class="search-icons">
<input type="search" placeholder="Search for icons.." class="form-control">
<span class="search-icon">${frappe.utils.icon('search', "sm")}</span>
</div>
<div class="icon-section">
<div class="icons"></div>
</div>
</div>
`);
this.parent.append(this.icon_picker_wrapper);
this.icon_wrapper = this.icon_picker_wrapper.find('.icons');
this.search_input = this.icon_picker_wrapper.find('.search-icons > input');
this.refresh();
this.setup_icons();
}

setup_icons() {
this.icons.forEach(icon => {
let $icon = $(`<div id="${icon}" class="icon-wrapper">${frappe.utils.icon(icon, "md")}</div>`);
this.icon_wrapper.append($icon);
const set_values = () => {
this.set_icon(icon);
this.update_icon_selected();
};
$icon.on('click', () => {
set_values();
});
$icon.keydown((e) => {
const key_code = e.keyCode;
if ([13, 32].includes(key_code)) {
e.preventDefault();
set_values();
}
});
this.search_input.keyup((e) => {
e.preventDefault();
this.filter_icons();
});

this.search_input.on('search', () => {
this.filter_icons();
});
});
}

filter_icons() {
let value = this.search_input.val();
if (!value) {
this.icon_wrapper.find(".icon-wrapper").removeClass('hidden');
} else {
this.icon_wrapper.find(".icon-wrapper").addClass('hidden');
this.icon_wrapper.find(`.icon-wrapper[id*='${value}']`).removeClass('hidden');
}
}

update_icon_selected(silent) {
!silent && this.on_change && this.on_change(this.get_icon());
}

set_icon(icon) {
this.icon = icon || '';
}

get_icon() {
return this.icon || '';
}
}

export default Picker;

+ 9
- 6
frappe/public/js/frappe/list/list_view.js Bestand weergeven

@@ -514,7 +514,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {

render_skeleton() {
const $row = this.get_list_row_html_skeleton(
'<div><input type="checkbox" /></div>'
'<div><input type="checkbox" class="render-list-checkbox"/></div>'
);
this.$result.append($row);
}
@@ -927,10 +927,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
const seen = this.get_seen_class(doc);

let subject_html = `
<input class="level-item list-row-checkbox hidden-xs" type="checkbox"
data-name="${escape(doc.name)}">
<span class="level-item" style="margin-bottom: 1px;">
${this.get_like_html(doc)}
<span class="level-item select-like">
<input class="list-row-checkbox hidden-xs" type="checkbox"
data-name="${escape(doc.name)}">
<span class="list-row-like style="margin-bottom: 1px;">
${this.get_like_html(doc)}
</span>
</span>
<span class="level-item ${seen} ellipsis" title="${escaped_subject}">
<a class="ellipsis"
@@ -1127,7 +1129,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
// don't open form when checkbox, like, filterable are clicked
if (
$target.hasClass("filterable") ||
$target.hasClass("icon-heart") ||
$target.hasClass("select-like") ||
$target.hasClass("list-row-like") ||
$target.is(":checkbox")
) {
e.stopPropagation();


+ 3
- 6
frappe/public/js/frappe/recorder/RequestDetail.vue Bestand weergeven

@@ -64,7 +64,7 @@
<div class="grid-body">
<div class="rows">
<div class="grid-row" :class="showing == call.index ? 'grid-row-open' : ''" v-for="call in paginated(sorted(grouped(request.calls)))" :key="call.index">
<div class="data-row row" v-if="showing != call.index" style="display: block;" @click="showing = call.index" >
<div class="data-row row" @click="showing = showing == call.index ? null : call.index" >
<div class="row-index col col-xs-1"><span>{{ call.index }}</span></div>
<div class="col grid-static-col col-xs-6" data-fieldtype="Code">
<div class="static-area"><span>{{ call.query }}</span></div>
@@ -76,16 +76,13 @@
<div class="static-area ellipsis text-right">{{ call.exact_copies }}</div>
</div>
<div class="col col-xs-1"><a class="close btn-open-row">
<span class="octicon octicon-triangle-down"></span></a>
<span class="octicon" :class="showing == call.index? 'octicon-triangle-up' : 'octicon-triangle-down'"></span></a>
</div>
</div>
<div class="recorder-form-in-grid" v-if="showing == call.index">
<div class="grid-form-heading" @click="showing = null">
<div class="toolbar grid-header-toolbar">
<span class="panel-title">{{ __("SQL Query") }} #<span class="grid-form-row-index">{{ call.index }}</span></span>
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;">
<span class="hidden-xs octicon octicon-triangle-up"></span>
</div>
</div>
</div>
<div class="grid-form-body">
@@ -116,7 +113,7 @@
</div>
<div class="frappe-control">
<div class="form-group">
<div class="clearfix"><label class="control-label"{{ __("Stack Trace") }}</label></div>
<div class="clearfix"><label class="control-label">{{ __("Stack Trace") }}</label></div>
<div class="control-value like-disabled-input for-description" style="overflow:auto">
<table class="table table-striped">
<thead>


+ 11
- 7
frappe/public/js/frappe/router.js Bestand weergeven

@@ -51,7 +51,7 @@ $('body').on('click', 'a', function(e) {
return override('/app');
}

if (href.startsWith('#')) {
if (href && href.startsWith('#')) {
// target startswith "#", this is a v1 style route, so remake it.
return override(e.currentTarget.hash);
}
@@ -169,10 +169,8 @@ frappe.router = {
standard_route = ['Tree', doctype_route.doctype];
} else {
standard_route = ['List', doctype_route.doctype, frappe.utils.to_title_case(route[2])];
if (route[3]) {
// calendar / kanban / dashboard / folder name
standard_route.push([...route].splice(3, route.length));
}
// calendar / kanban / dashboard / folder
if (route[3]) standard_route.push(...route.slice(3, route.length));
}
return standard_route;
},
@@ -234,6 +232,12 @@ frappe.router = {
}
},

clear_re_route(doctype, docname) {
delete frappe.re_route[
`${encodeURIComponent(frappe.router.slug(doctype))}/${encodeURIComponent(docname)}`
];
},

set_title(sub_path) {
if (frappe.route_titles[sub_path]) {
frappe.utils.set_title(frappe.route_titles[sub_path]);
@@ -245,7 +249,7 @@ frappe.router = {
// example 1: frappe.set_route('a', 'b', 'c');
// example 2: frappe.set_route(['a', 'b', 'c']);
// example 3: frappe.set_route('a/b/c');
let route = arguments;
let route = Array.from(arguments);

return new Promise(resolve => {
route = this.get_route_from_arguments(route);
@@ -297,7 +301,7 @@ frappe.router = {
new_route = [this.slug(route[1]), 'view', route[2].toLowerCase()];

// calendar / inbox / file folder
if (route[3]) new_route.push([...route].slice(3, route.length));
if (route[3]) new_route.push(...route.slice(3, route.length));
} else {
if ($.isPlainObject(route[2])) {
frappe.route_options = route[2];


+ 1
- 0
frappe/public/js/frappe/ui/dialog.js Bestand weergeven

@@ -98,6 +98,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
me.focus_on_first_input();
me.on_page_show && me.on_page_show();
$(document).trigger('frappe.ui.Dialog:shown');
$(document).off('focusin.modal');
})
.on('scroll', function() {
var $input = $('input:focus');


+ 3
- 0
frappe/public/js/frappe/views/reports/query_report.js Bestand weergeven

@@ -518,6 +518,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
} else {
this.page.show_form();
}

this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height'));
this.page.body.parent().css('margin-bottom', 'unset');
}

set_filters(filters) {


+ 2
- 0
frappe/public/js/frappe/views/reports/report_view.js Bestand weergeven

@@ -49,6 +49,8 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
this.setup_columns();
super.setup_new_doc_event();
this.page.main.addClass('report-view');
this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height'));
this.page.body.parent().css('margin-bottom', 'unset');
}

toggle_side_bar() {


+ 1
- 0
frappe/public/scss/common/controls.scss Bestand weergeven

@@ -1,5 +1,6 @@
@import "grid";
@import "color_picker";
@import "icon_picker";
@import "datepicker";

// password


+ 3
- 1
frappe/public/scss/common/datepicker.scss Bestand weergeven

@@ -1,7 +1,9 @@
/*rtl:begin:ignore*/
@import "~air-datepicker/dist/css/datepicker.min";
/*rtl:end:ignore*/

.datepicker {
direction: ltr;
font-family: inherit;
z-index: 9999 !important;
background: var(--fg-color);


+ 95
- 0
frappe/public/scss/common/icon_picker.scss Bestand weergeven

@@ -0,0 +1,95 @@
.icon-picker {
font-size: var(--text-xs);
color: var(--text-muted);
--icon-picker-width: 240px;
width: var(--icon-picker-width);
.icons {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
overflow-y: scroll;
max-height: 210px;
cursor: pointer;

/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */

/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}

.icon-wrapper {
display: flex;
width: 30px;
height: 30px;
text-align: center;
align-items: center;
}
}

.search-icons {
position: relative;

input[type='search'] {
height: inherit;
padding-left: 30px;
}

.search-icon {
position: absolute;
top: 7px;
left: 7px;
}
}
}
.icon-picker-popover {
.picker-arrow {
left: 15px !important;
}
}

.frappe-control[data-fieldtype='Icon'] {
input {
padding-left: 40px;
}
.selected-icon {
cursor: pointer;
width: 22px;
height: 22px;
border-radius: 5px;
position: absolute;
top: calc(50% + 1px);
left: 8px;
content: ' ';
}
.like-disabled-input {
.icon-value {
padding-left: 25px;
}
.selected-icon {
top: 20%;
cursor: default;
}
}
}

.data-row.row {
.selected-icon {
top: calc(50% - 11px);
z-index: 2;
}
}

.dt-cell__content {
.selected-icon {
display: contents;
}
}

.dt-cell__edit, .filter-field {
.selected-icon {
top: 5px !important;
}
}

+ 1
- 0
frappe/public/scss/desk/form.scss Bestand weergeven

@@ -1,4 +1,5 @@
@import "../common/form.scss";
@import '~cropperjs/dist/cropper.min';

.form-section, .form-dashboard-section {
margin: 0px;


+ 10
- 1
frappe/public/scss/desk/list.scss Bestand weergeven

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

.list-row {
padding: 15px;
padding: 15px 15px 15px 0px;
height: 45px;
cursor: pointer;
transition: color 0.2s;
@@ -130,10 +130,15 @@
margin-left: 5px;
}
}

.select-like {
padding: 15px 0px 15px 15px;
}
}

.list-row-head {
@extend .list-row;
padding: 15px;
cursor: default;

.list-subject {
@@ -200,6 +205,10 @@ input.list-check-all, input.list-row-checkbox {
--checkbox-right-margin: calc(var(--checkbox-size) / 2 + #{$level-margin-right});
}

.render-list-checkbox {
margin-left: 15px;
}

.filterable {
cursor: pointer;
}


+ 26
- 3
frappe/public/scss/desk/report.scss Bestand weergeven

@@ -84,14 +84,37 @@
margin-bottom: 10px;
}

.layout-main-section .frappe-card {
--report-filter-height: 0px;
}

.report-wrapper {
overflow: auto;

.datatable {
height: calc(100vh - var(--report-filter-height) - 205px);

.dt-scrollable {
height: calc(100vh - var(--report-filter-height) - 275px);
}
}
}

.report-view {
.dt-row:last-child:not(.dt-row-filter) {
.dt-cell {
border-bottom: 1px solid var(--border-color);
.result {
min-height: 50vh !important;
.dt-row:last-child:not(.dt-row-filter) {
.dt-cell {
border-bottom: 1px solid var(--border-color);
}
}

.datatable {
height: calc(100vh - var(--report-filter-height) - 225px);

.dt-scrollable {
height: calc(100vh - var(--report-filter-height) - 295px);
}
}
}
}


+ 5
- 2
frappe/search/website_search.py Bestand weergeven

@@ -90,19 +90,22 @@ class WebsiteSearch(FullTextSearch):
def slugs_with_web_view(_items_to_index):
all_routes = []
filters = { "has_web_view": 1, "allow_guest_to_view": 1, "index_web_pages_for_search": 1}
fields = ["name", "is_published_field", 'website_search_field']
fields = ["name", "is_published_field", "website_search_field"]
doctype_with_web_views = frappe.get_all("DocType", filters=filters, fields=fields)

for doctype in doctype_with_web_views:
if doctype.is_published_field:
docs = frappe.get_all(doctype.name, filters={doctype.is_published_field: 1}, fields=["route", doctype.website_search_field, 'title'])
fields=["route", doctype.website_search_field]
filters={doctype.is_published_field: 1},
if doctype.website_search_field:
docs = frappe.get_all(doctype.name, filters=filters, fields=fields.append("title"))
for doc in docs:
content = frappe.utils.md_to_html(getattr(doc, doctype.website_search_field))
soup = BeautifulSoup(content, "html.parser")
text_content = soup.text if soup else ""
_items_to_index += [frappe._dict(title=doc.title, content=text_content, path=doc.route)]
else:
docs = frappe.get_all(doctype.name, filters=filters, fields=fields)
all_routes += [route.route for route in docs]

return all_routes


BIN
frappe/tests/data/sample_image_for_optimization.jpg Bestand weergeven

Voor Na
Breedte: 1920  |  Hoogte: 1281  |  Grootte: 244 KiB

+ 13
- 0
frappe/tests/test_commands.py Bestand weergeven

@@ -436,3 +436,16 @@ class TestCommands(BaseTestCommands):

self.execute("bench version -f invalid")
self.assertEqual(self.returncode, 2)

def test_set_password(self):
from frappe.utils.password import check_password

self.execute("bench --site {site} set-password Administrator test1")
self.assertEqual(self.returncode, 0)
self.assertEqual(check_password('Administrator', 'test1'), 'Administrator')
# to release the lock taken by check_password
frappe.db.commit()

self.execute("bench --site {site} set-admin-password test2")
self.assertEqual(self.returncode, 0)
self.assertEqual(check_password('Administrator', 'test2'), 'Administrator')

+ 38
- 0
frappe/tests/test_hooks.py Bestand weergeven

@@ -31,6 +31,44 @@ class TestHooks(unittest.TestCase):
todo = frappe.get_doc(doctype='ToDo', description='asdf')
self.assertTrue(isinstance(todo, CustomToDo))

def test_has_permission(self):
from frappe import hooks

# Set hook
address_has_permission_hook = hooks.has_permission.get('Address', [])
if isinstance(address_has_permission_hook, str):
address_has_permission_hook = [address_has_permission_hook]

address_has_permission_hook.append(
'frappe.tests.test_hooks.custom_has_permission'
)

hooks.has_permission['Address'] = address_has_permission_hook

# Clear cache
frappe.cache().delete_value('app_hooks')

# Init User and Address
username = "test@example.com"
user = frappe.get_doc("User", username)
user.add_roles("System Manager")
address = frappe.new_doc("Address")

# Test!
self.assertTrue(
frappe.has_permission("Address", doc=address, user=username)
)

address.flags.dont_touch_me = True
self.assertFalse(
frappe.has_permission("Address", doc=address, user=username)
)


def custom_has_permission(doc, ptype, user):
if doc.flags.dont_touch_me:
return False


class CustomToDo(ToDo):
pass

+ 34
- 0
frappe/tests/test_naming.py Bestand weergeven

@@ -116,3 +116,37 @@ class TestNaming(unittest.TestCase):

self.assertEqual(current_index.get('current'), 2)
frappe.db.sql("""delete from `tabSeries` where name = %s""", series)

def test_naming_for_cancelled_and_amended_doc(self):
submittable_doctype = frappe.get_doc({
"doctype": "DocType",
"module": "Core",
"custom": 1,
"is_submittable": 1,
"permissions": [{
"role": "System Manager",
"read": 1
}],
"name": 'Submittable Doctype'
}).insert(ignore_if_duplicate=True)

doc = frappe.new_doc('Submittable Doctype')
doc.save()
original_name = doc.name

doc.submit()
doc.cancel()
cancelled_name = doc.name
self.assertEqual(cancelled_name, "{}-CANC-0".format(original_name))

amended_doc = frappe.copy_doc(doc)
amended_doc.docstatus = 0
amended_doc.amended_from = doc.name
amended_doc.save()
self.assertEqual(amended_doc.name, original_name)

amended_doc.submit()
amended_doc.cancel()
self.assertEqual(amended_doc.name, "{}-CANC-1".format(original_name))

submittable_doctype.delete()

+ 37
- 2
frappe/tests/test_translate.py Bestand weergeven

@@ -18,8 +18,19 @@ first_lang, second_lang, third_lang, fourth_lang, fifth_lang = choices(
)

class TestTranslate(unittest.TestCase):
guest_sessions_required = [
"test_guest_request_language_resolution_with_cookie",
"test_guest_request_language_resolution_with_request_header"
]

def setUp(self):
if self._testMethodName in self.guest_sessions_required:
frappe.set_user("Guest")

def tearDown(self):
frappe.form_dict.pop("_lang", None)
if self._testMethodName in self.guest_sessions_required:
frappe.set_user("Administrator")

def test_extract_message_from_file(self):
data = frappe.translate.get_messages_from_file(translation_string_file)
@@ -52,21 +63,45 @@ class TestTranslate(unittest.TestCase):
Case 2: frappe.form_dict._lang is not set, but preferred_language cookie is
"""

with patch.object(frappe.translate, "get_preferred_language_cookie", return_value=second_lang):
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
return_val = get_language()

self.assertNotIn(return_val, [second_lang, get_parent_language(second_lang)])

def test_guest_request_language_resolution_with_cookie(self):
"""Test for frappe.translate.get_language

Case 3: frappe.form_dict._lang is not set, but preferred_language cookie is [Guest User]
"""

with patch.object(frappe.translate, "get_preferred_language_cookie", return_value=second_lang):
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
return_val = get_language()

self.assertIn(return_val, [second_lang, get_parent_language(second_lang)])

def test_request_language_resolution_with_request_header(self):

def test_guest_request_language_resolution_with_request_header(self):
"""Test for frappe.translate.get_language

Case 3: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is
Case 4: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is [Guest User]
"""

set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
return_val = get_language()
self.assertIn(return_val, [third_lang, get_parent_language(third_lang)])

def test_request_language_resolution_with_request_header(self):
"""Test for frappe.translate.get_language

Case 5: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is
"""

set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
return_val = get_language()
self.assertNotIn(return_val, [third_lang, get_parent_language(third_lang)])


expected_output = [
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2),


+ 44
- 2
frappe/tests/test_utils.py Bestand weergeven

@@ -6,10 +6,12 @@ import frappe
from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url
from frappe.utils import validate_url, validate_email_address
from frappe.utils import ceil, floor
from frappe.utils.data import validate_python_code

from PIL import Image
from frappe.utils.image import strip_exif_data
from frappe.utils.image import strip_exif_data, optimize_image
import io
from mimetypes import guess_type

class TestFilters(unittest.TestCase):
def test_simple_dict(self):
@@ -187,4 +189,44 @@ class TestImage(unittest.TestCase):
new_image = Image.open(io.BytesIO(new_image_content))

self.assertEqual(new_image._getexif(), None)
self.assertNotEqual(original_image._getexif(), new_image._getexif())
self.assertNotEqual(original_image._getexif(), new_image._getexif())

def test_optimize_image(self):
image_file_path = "../apps/frappe/frappe/tests/data/sample_image_for_optimization.jpg"
content_type = guess_type(image_file_path)[0]
original_content = io.open(image_file_path, mode='rb').read()

optimized_content = optimize_image(original_content, content_type, max_width=500, max_height=500)
optimized_image = Image.open(io.BytesIO(optimized_content))
width, height = optimized_image.size

self.assertLessEqual(width, 500)
self.assertLessEqual(height, 500)
self.assertLess(len(optimized_content), len(original_content))

class TestPythonExpressions(unittest.TestCase):

def test_validation_for_good_python_expression(self):
valid_expressions = [
"foo == bar",
"foo == 42",
"password != 'hunter2'",
"complex != comparison and more_complex == condition",
"escaped_values == 'str with newline\\n'",
"check_box_field",
]
for expr in valid_expressions:
try:
validate_python_code(expr)
except Exception as e:
self.fail(f"Invalid error thrown for valid expression: {expr}: {str(e)}")

def test_validation_for_bad_python_expression(self):
invalid_expressions = [
"these_are && js_conditions",
"more || js_conditions",
"curly_quotes_bad == “const”",
"oops = forgot_equals",
]
for expr in invalid_expressions:
self.assertRaises(frappe.ValidationError, validate_python_code, expr)

+ 7
- 2
frappe/translate.py Bestand weergeven

@@ -27,11 +27,12 @@ def get_language(lang_list: List = None) -> str:

Order of priority for setting language:
1. Form Dict => _lang
2. Cookie => preferred_language
3. Request Header => Accept-Language
2. Cookie => preferred_language (Non authorized user)
3. Request Header => Accept-Language (Non authorized user)
4. User document => language
5. System Settings => language
"""
is_logged_in = frappe.session.user != "Guest"

# fetch language from form_dict
if frappe.form_dict._lang:
@@ -41,6 +42,10 @@ def get_language(lang_list: List = None) -> str:
if language:
return language

# use language set in User or System Settings if user is logged in
if is_logged_in:
return frappe.local.lang

lang_set = set(lang_list or get_all_languages() or [])

# fetch language from cookie


+ 0
- 1
frappe/utils/background_jobs.py Bestand weergeven

@@ -20,7 +20,6 @@ from frappe.utils.commands import log

default_timeout = 300
queue_timeout = {
'background': 2500,
'long': 1500,
'default': 300,
'short': 300


+ 30
- 1
frappe/utils/data.py Bestand weergeven

@@ -5,6 +5,7 @@ import frappe
import operator
import json
import re, datetime, math, time
from code import compile_command
from urllib.parse import quote, urljoin
from frappe.desk.utils import slug

@@ -323,7 +324,7 @@ def format_date(string_date=None, format_string=None):
date = getdate(string_date)
if not format_string:
format_string = get_user_date_format()
format_string = format_string.replace("mm", "MM")
format_string = format_string.replace("mm", "MM").replace("Y", "y")
try:
formatted_date = babel.dates.format_date(
date, format_string,
@@ -1511,6 +1512,34 @@ def get_user_info_for_avatar(user_id):
return user_info


def validate_python_code(string: str, fieldname=None, is_expression: bool = True) -> None:
""" Validate python code fields by using compile_command to ensure that expression is valid python.

args:
fieldname: name of field being validated.
is_expression: true for validating simple single line python expression, else validated as script.
"""

if not string:
return

try:
compile_command(string, symbol="eval" if is_expression else "exec")
except SyntaxError as se:
line_no = se.lineno - 1 or 0
offset = se.offset - 1 or 0
error_line = string if is_expression else string.split("\n")[line_no]
msg = (frappe._("{} Invalid python code on line {}")
.format(fieldname + ":" if fieldname else "", line_no+1))
msg += f"<br><pre>{error_line}</pre>"
msg += f"<pre>{' ' * offset}^</pre>"

frappe.throw(msg, title=frappe._("Syntax Error"))
except Exception as e:
frappe.msgprint(frappe._("{} Possibly invalid python code. <br>{}")
.format(fieldname + ": " or "", str(e)), indicator="orange")


class UnicodeWithAttrs(str):
def __init__(self, text):
self.toc_html = text.toc_html


+ 11
- 3
frappe/utils/file_manager.py Bestand weergeven

@@ -11,7 +11,7 @@ from frappe import _
from frappe import conf
from copy import copy
from urllib.parse import unquote
from frappe.utils.image import optimize_image

class MaxFileSizeReachedError(frappe.ValidationError):
pass
@@ -386,6 +386,15 @@ def extract_images_from_html(doc, content):
data = match.group(1)
data = data.split("data:")[1]
headers, content = data.split(",")
mtype = headers.split(";")[0]

if isinstance(content, str):
content = content.encode("utf-8")
if b"," in content:
content = content.split(b",")[1]
content = base64.b64decode(content)
content = optimize_image(content, mtype)

if "filename=" in headers:
filename = headers.split("filename=")[-1]
@@ -394,7 +403,6 @@ def extract_images_from_html(doc, content):
if not isinstance(filename, str):
filename = str(filename, 'utf-8')
else:
mtype = headers.split(";")[0]
filename = get_random_filename(content_type=mtype)

doctype = doc.parenttype if doc.parent else doc.doctype
@@ -405,7 +413,7 @@ def extract_images_from_html(doc, content):
name = doc.reference_name

# TODO fix this
file_url = save_file(filename, content, doctype, name, decode=True).get("file_url")
file_url = save_file(filename, content, doctype, name, decode=False).get("file_url")
if not frappe.flags.has_dataurl:
frappe.flags.has_dataurl = True



+ 18
- 4
frappe/utils/image.py Bestand weergeven

@@ -1,6 +1,8 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import os
from PIL import Image
import io

def resize_images(path, maxdim=700):
from PIL import Image
@@ -26,9 +28,6 @@ def strip_exif_data(content, content_type):
Bytes: Stripped image content
"""

from PIL import Image
import io

original_image = Image.open(io.BytesIO(content))
output = io.BytesIO()

@@ -38,4 +37,19 @@ def strip_exif_data(content, content_type):

content = output.getvalue()

return content
return content

def optimize_image(content, content_type, max_width=1920, max_height=1080, optimize=True, quality=85):
if content_type == 'image/svg+xml':
return content

image = Image.open(io.BytesIO(content))
image_format = content_type.split('/')[1]
size = max_width, max_height
image.thumbnail(size, Image.LANCZOS)

output = io.BytesIO()
image.save(output, format=image_format, optimize=optimize, quality=quality, save_all=True if image_format=='gif' else None)

optimized_content = output.getvalue()
return optimized_content if len(optimized_content) < len(content) else content

+ 3
- 3
frappe/utils/nestedset.py Bestand weergeven

@@ -57,7 +57,7 @@ def update_add_node(doc, parent, parent_field):

# get the last sibling of the parent
if parent:
left, right = frappe.db.sql("select lft, rgt from `tab{0}` where name=%s"
left, right = frappe.db.sql("select lft, rgt from `tab{0}` where name=%s for update"
.format(doctype), parent)[0]
validate_loop(doc.doctype, doc.name, left, right)
else: # root
@@ -89,7 +89,7 @@ def update_move_node(doc, parent_field):

if parent:
new_parent = frappe.db.sql("""select lft, rgt from `tab{0}`
where name = %s""".format(doc.doctype), parent, as_dict=1)[0]
where name = %s for update""".format(doc.doctype), parent, as_dict=1)[0]

validate_loop(doc.doctype, doc.name, new_parent.lft, new_parent.rgt)

@@ -108,7 +108,7 @@ def update_move_node(doc, parent_field):

if parent:
new_parent = frappe.db.sql("""select lft, rgt from `tab%s`
where name = %s""" % (doc.doctype, '%s'), parent, as_dict=1)[0]
where name = %s for update""" % (doc.doctype, '%s'), parent, as_dict=1)[0]


# set parent lft, rgt


+ 1
- 1
frappe/utils/pdf.py Bestand weergeven

@@ -179,7 +179,7 @@ def prepare_header_footer(soup):
"html_id": html_id,
"css": css,
"lang": frappe.local.lang,
"layout_direction": "rtl" if is_rtl else "ltr"
"layout_direction": "rtl" if is_rtl() else "ltr"
})

# create temp file


+ 3
- 3
frappe/website/doctype/website_settings/website_settings.json Bestand weergeven

@@ -77,7 +77,7 @@
"label": "Landing Page"
},
{
"description": "Link that is the website home page. Standard Links (index, login, products, blog, about, contact)",
"description": "Link that is the website home page. Standard Links (home, login, products, blog, about, contact)",
"fieldname": "home_page",
"fieldtype": "Data",
"in_list_view": 1,
@@ -433,7 +433,7 @@
"issingle": 1,
"links": [],
"max_attachments": 10,
"modified": "2021-04-14 17:39:56.609771",
"modified": "2021-07-15 17:39:56.609771",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Settings",
@@ -457,4 +457,4 @@
"sort_field": "modified",
"sort_order": "ASC",
"track_changes": 1
}
}

+ 1
- 1
frappe/website/doctype/website_settings/website_settings.py Bestand weergeven

@@ -23,7 +23,7 @@ class WebsiteSettings(Document):
return
from frappe.website.path_resolver import PathResolver
if self.home_page and not PathResolver(self.home_page).is_valid_path():
frappe.msgprint(_("Invalid Home Page") + " (Standard pages - index, login, products, blog, about, contact)")
frappe.msgprint(_("Invalid Home Page") + " (Standard pages - home, login, products, blog, about, contact)")
self.home_page = ''

def validate_top_bar_items(self):


+ 3
- 2
frappe/website/utils.py Bestand weergeven

@@ -488,11 +488,12 @@ def set_content_type(response, data, path):
return data

def add_preload_headers(response):
from bs4 import BeautifulSoup
from bs4 import BeautifulSoup, SoupStrainer

try:
preload = []
soup = BeautifulSoup(response.data, "lxml")
strainer = SoupStrainer(re.compile("script|link"))
soup = BeautifulSoup(response.data, "lxml", parse_only=strainer)
for elem in soup.find_all('script', src=re.compile(".*")):
preload.append(("script", elem.get("src")))



+ 10
- 0
frappe/workflow/doctype/workflow/test_workflow.py Bestand weergeven

@@ -121,6 +121,16 @@ class TestWorkflow(unittest.TestCase):
self.workflow.states[1].doc_status = 0
self.workflow.save()

def test_syntax_error_in_transition_rule(self):
self.workflow.transitions[0].condition = 'doc.status =! "Closed"'

with self.assertRaises(frappe.ValidationError) as se:
self.workflow.save()

self.assertTrue("invalid python code" in str(se.exception).lower(),
msg="Python code validation not working")


def create_todo_workflow():
if frappe.db.exists('Workflow', 'Test ToDo'):
frappe.delete_doc('Workflow', 'Test ToDo')


+ 22
- 257
frappe/workflow/doctype/workflow_transition/workflow_transition.json Bestand weergeven

@@ -1,335 +1,100 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"actions": [],
"creation": "2013-02-22 01:27:36",
"custom": 0,
"description": "Defines actions on states and the next step and allowed roles.",
"docstatus": 0,
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"state",
"action",
"next_state",
"allowed",
"allow_self_approval",
"conditions",
"condition",
"column_break_7",
"example"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "state",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "State",
"length": 0,
"no_copy": 0,
"options": "Workflow State",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "200px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "200px"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "action",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Action",
"length": 0,
"no_copy": 0,
"options": "Workflow Action Master",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "200px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "200px"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "next_state",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Next State",
"length": 0,
"no_copy": 0,
"options": "Workflow State",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "200px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "200px"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "allowed",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Allowed",
"length": 0,
"no_copy": 0,
"options": "Role",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "200px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "200px"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1",
"description": "Allow approval for creator of the document",
"fieldname": "allow_self_approval",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Allow Self Approval",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Allow Self Approval"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "conditions",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Conditions",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Conditions"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "condition",
"fieldtype": "Code",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Condition",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"options": "PythonExpression"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_7",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldtype": "Column Break"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "example",
"fieldtype": "HTML",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Example",
"length": 0,
"no_copy": 0,
"options": "<pre><code>doc.grand_total &gt; 0</code></pre>\n\n<p>Conditions should be written in simple Python. Please use properties available in the form only.</p>\n<p>Allowed functions: \n</p><ul>\n<li>frappe.db.get_value</li>\n<li>frappe.db.get_list</li>\n<li>frappe.session</li>\n<li>frappe.utils.now_datetime</li>\n<li>frappe.utils.get_datetime</li>\n<li>frappe.utils.add_to_date</li>\n<li>frappe.utils.now</li>\n</ul>\n<p>Example: </p><pre><code>doc.creation &gt; frappe.utils.add_to_date(frappe.utils.now_datetime(), days=-5, as_string=True, as_datetime=True) </code></pre><p></p>",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"options": "<pre><code>doc.grand_total &gt; 0</code></pre>\n\n<p>Conditions should be written in simple Python. Please use properties available in the form only.</p>\n<p>Allowed functions: \n</p><ul>\n<li>frappe.db.get_value</li>\n<li>frappe.db.get_list</li>\n<li>frappe.session</li>\n<li>frappe.utils.now_datetime</li>\n<li>frappe.utils.get_datetime</li>\n<li>frappe.utils.add_to_date</li>\n<li>frappe.utils.now</li>\n</ul>\n<p>Example: </p><pre><code>doc.creation &gt; frappe.utils.add_to_date(frappe.utils.now_datetime(), days=-5, as_string=True, as_datetime=True) </code></pre><p></p>"
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 1,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2020-11-08 12:11:00.294908",
"links": [],
"modified": "2021-07-21 13:24:59.084836",
"modified_by": "Administrator",
"module": "Workflow",
"name": "Workflow Transition",
"owner": "Administrator",
"permissions": [],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"track_changes": 0,
"track_seen": 0
"sort_field": "modified",
"sort_order": "DESC"
}

+ 2
- 1
package.json Bestand weergeven

@@ -27,6 +27,7 @@
"bootstrap": "4.5.0",
"cliui": "^7.0.4",
"cookie": "^0.4.0",
"cropperjs": "^1.5.12",
"cssnano": "^5.0.0",
"driver.js": "^0.9.8",
"express": "^4.17.1",
@@ -52,7 +53,7 @@
"qz-tray": "^2.0.8",
"redis": "^3.1.1",
"showdown": "^1.9.1",
"snyk": "^1.518.0",
"snyk": "^1.667.0",
"socket.io": "^2.4.0",
"superagent": "^3.8.2",
"touch": "^3.1.0",


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

Laden…
Annuleren
Opslaan