@@ -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'); | |||
}); | |||
}); |
@@ -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'); | |||
}); | |||
}); |
@@ -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'); | |||
@@ -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'); | |||
}); | |||
}); |
@@ -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(); | |||
}); | |||
}); |
@@ -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(); | |||
}); |
@@ -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", | |||
@@ -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 | |||
@@ -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') | |||
@@ -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) | |||
@@ -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) | |||
@@ -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", | |||
@@ -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({ | |||
@@ -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): | |||
@@ -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 | |||
@@ -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") |
@@ -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() |
@@ -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' | |||
@@ -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] | |||
@@ -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.")) | |||
@@ -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) | |||
@@ -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') | |||
@@ -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): | |||
@@ -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}' |
@@ -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' | |||
@@ -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 |
@@ -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() | |||
@@ -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 | |||
@@ -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,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"; |
@@ -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> |
@@ -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); | |||
}); | |||
}, | |||
@@ -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> |
@@ -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); | |||
} | |||
@@ -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, | |||
@@ -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 | |||
@@ -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); |
@@ -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; | |||
@@ -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() { | |||
@@ -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]); | |||
@@ -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,4 +1,5 @@ | |||
@import "../common/form.scss"; | |||
@import '~cropperjs/dist/cropper.min'; | |||
.form-section, .form-dashboard-section { | |||
margin: 0px; | |||
@@ -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 |
@@ -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() |
@@ -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) |
@@ -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,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 | |||
@@ -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 |
@@ -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 | |||
@@ -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 | |||
} | |||
} |
@@ -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): | |||
@@ -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') | |||
@@ -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 > 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 > 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 > 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 > 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" | |||
} |
@@ -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", | |||
@@ -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" | |||