Преглед на файлове

Merge branch 'frappe:develop' into wiki-based-desk

version-14
Shariq Ansari преди 3 години
committed by GitHub
родител
ревизия
1ab70d2145
No known key found for this signature in database GPG ключ ID: 4AEE18F83AFDEB23
променени са 57 файла, в които са добавени 1354 реда и са изтрити 432 реда
  1. Двоични данни
      cypress/fixtures/sample_image.jpg
  2. +20
    -0
      cypress/integration/file_uploader.js
  3. +57
    -0
      cypress/integration/sidebar.js
  4. +1
    -0
      cypress/integration/table_multiselect.js
  5. +94
    -0
      cypress/integration/timeline.js
  6. +71
    -0
      cypress/integration/timeline_email.js
  7. +29
    -3
      cypress/support/commands.js
  8. +6
    -3
      frappe/automation/doctype/assignment_rule/assignment_rule.json
  9. +1
    -1
      frappe/commands/site.py
  10. +30
    -48
      frappe/commands/utils.py
  11. +13
    -0
      frappe/core/doctype/doctype/doctype.py
  12. +5
    -3
      frappe/core/doctype/doctype/test_doctype.py
  13. +25
    -1
      frappe/core/doctype/doctype_link/doctype_link.json
  14. +19
    -0
      frappe/core/doctype/file/file.js
  15. +31
    -4
      frappe/core/doctype/file/file.py
  16. +0
    -5
      frappe/core/doctype/server_script/server_script.py
  17. +10
    -0
      frappe/core/doctype/server_script/test_server_script.py
  18. +26
    -0
      frappe/coverage.py
  19. +26
    -0
      frappe/custom/doctype/customize_form/test_customize_form.py
  20. +1
    -1
      frappe/database/database.py
  21. +17
    -3
      frappe/handler.py
  22. +12
    -0
      frappe/model/base_document.py
  23. +12
    -4
      frappe/model/document.py
  24. +22
    -8
      frappe/model/meta.py
  25. +107
    -12
      frappe/model/naming.py
  26. +3
    -29
      frappe/parallel_test_runner.py
  27. +1
    -0
      frappe/patches.txt
  28. +213
    -0
      frappe/patches/v14_0/rename_cancelled_documents.py
  29. +1
    -1
      frappe/permissions.py
  30. +3
    -0
      frappe/public/icons/timeless/symbol-defs.svg
  31. +1
    -15
      frappe/public/js/controls.bundle.js
  32. +37
    -2
      frappe/public/js/frappe/file_uploader/FilePreview.vue
  33. +42
    -3
      frappe/public/js/frappe/file_uploader/FileUploader.vue
  34. +80
    -0
      frappe/public/js/frappe/file_uploader/ImageCropper.vue
  35. +20
    -0
      frappe/public/js/frappe/file_uploader/index.js
  36. +12
    -2
      frappe/public/js/frappe/form/controls/attach.js
  37. +1
    -0
      frappe/public/js/frappe/form/controls/code.js
  38. +62
    -0
      frappe/public/js/frappe/form/controls/datepicker_i18n.js
  39. +14
    -10
      frappe/public/js/frappe/form/form.js
  40. +1
    -1
      frappe/public/js/frappe/form/sidebar/user_image.js
  41. +6
    -0
      frappe/public/js/frappe/router.js
  42. +3
    -1
      frappe/public/scss/common/datepicker.scss
  43. +1
    -0
      frappe/public/scss/desk/form.scss
  44. Двоични данни
      frappe/tests/data/sample_image_for_optimization.jpg
  45. +38
    -0
      frappe/tests/test_hooks.py
  46. +34
    -0
      frappe/tests/test_naming.py
  47. +44
    -2
      frappe/tests/test_utils.py
  48. +30
    -1
      frappe/utils/data.py
  49. +11
    -3
      frappe/utils/file_manager.py
  50. +18
    -4
      frappe/utils/image.py
  51. +1
    -1
      frappe/utils/pdf.py
  52. +3
    -3
      frappe/website/doctype/website_settings/website_settings.json
  53. +1
    -1
      frappe/website/doctype/website_settings/website_settings.py
  54. +10
    -0
      frappe/workflow/doctype/workflow/test_workflow.py
  55. +22
    -257
      frappe/workflow/doctype/workflow_transition/workflow_transition.json
  56. +1
    -0
      package.json
  57. +5
    -0
      yarn.lock

Двоични данни
cypress/fixtures/sample_image.jpg Целия файл

Преди След
Ширина: 1920  |  Височина: 1281  |  Големина: 244 KiB

+ 20
- 0
cypress/integration/file_uploader.js Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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

+ 6
- 3
frappe/automation/doctype/assignment_rule/assignment_rule.json Целия файл

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


+ 1
- 1
frappe/commands/site.py Целия файл

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



+ 30
- 48
frappe/commands/utils.py Целия файл

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


+ 13
- 0
frappe/core/doctype/doctype/doctype.py Целия файл

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


+ 5
- 3
frappe/core/doctype/doctype/test_doctype.py Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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

+ 26
- 0
frappe/custom/doctype/customize_form/test_customize_form.py Целия файл

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



+ 1
- 1
frappe/database/database.py Целия файл

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


+ 17
- 3
frappe/handler.py Целия файл

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



+ 12
- 0
frappe/model/base_document.py Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -182,3 +182,4 @@ frappe.patches.v13_0.jinja_hook
frappe.patches.v13_0.update_notification_channel_if_empty
frappe.patches.v13_0.update_workspace2
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 Целия файл

@@ -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 Целия файл

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


+ 3
- 0
frappe/public/icons/timeless/symbol-defs.svg Целия файл

@@ -702,4 +702,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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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


+ 62
- 0
frappe/public/js/frappe/form/controls/datepicker_i18n.js Целия файл

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

+ 14
- 10
frappe/public/js/frappe/form/form.js Целия файл

@@ -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/sidebar/user_image.js Целия файл

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


+ 6
- 0
frappe/public/js/frappe/router.js Целия файл

@@ -241,6 +241,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]);


+ 3
- 1
frappe/public/scss/common/datepicker.scss Целия файл

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


+ 1
- 0
frappe/public/scss/desk/form.scss Целия файл

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

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


Двоични данни
frappe/tests/data/sample_image_for_optimization.jpg Целия файл

Преди След
Ширина: 1920  |  Височина: 1281  |  Големина: 244 KiB

+ 38
- 0
frappe/tests/test_hooks.py Целия файл

@@ -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 Целия файл

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

+ 44
- 2
frappe/tests/test_utils.py Целия файл

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

+ 30
- 1
frappe/utils/data.py Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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

+ 1
- 1
frappe/utils/pdf.py Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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


+ 10
- 0
frappe/workflow/doctype/workflow/test_workflow.py Целия файл

@@ -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 Целия файл

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

+ 1
- 0
package.json Целия файл

@@ -28,6 +28,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",
"editorjs-undo": "0.1.6",


+ 5
- 0
yarn.lock Целия файл

@@ -1698,6 +1698,11 @@ cosmiconfig@^7.0.0:
path-type "^4.0.0"
yaml "^1.10.0"

cropperjs@^1.5.12:
version "1.5.12"
resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.5.12.tgz#d9c0db2bfb8c0d769d51739e8f916bbc44e10f50"
integrity sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw==

cross-spawn@7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"


Зареждане…
Отказ
Запис