@@ -0,0 +1,50 @@ | |||
context('Control Icon', () => { | |||
before(() => { | |||
cy.login(); | |||
cy.visit('/app/website'); | |||
}); | |||
function get_dialog_with_icon() { | |||
return cy.dialog({ | |||
title: 'Icon', | |||
fields: [{ | |||
label: 'Icon', | |||
fieldname: 'icon', | |||
fieldtype: 'Icon' | |||
}] | |||
}); | |||
} | |||
it('should set icon', () => { | |||
get_dialog_with_icon().as('dialog'); | |||
cy.get('.frappe-control[data-fieldname=icon] input').first().click(); | |||
cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); | |||
cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'active'); | |||
cy.get('@dialog').then(dialog => { | |||
let value = dialog.get_value('icon'); | |||
expect(value).to.equal('active'); | |||
}); | |||
cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); | |||
cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'resting'); | |||
cy.get('@dialog').then(dialog => { | |||
let value = dialog.get_value('icon'); | |||
expect(value).to.equal('resting'); | |||
}); | |||
}); | |||
it('search for icon and clear search input', () => { | |||
let search_text = 'ed'; | |||
cy.get('.icon-picker input[type=search]').first().click().type(search_text); | |||
cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => { | |||
cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => { | |||
expect(i.length).to.equal(icons.length); | |||
}); | |||
}); | |||
cy.get('.icon-picker input[type=search]').clear().blur(); | |||
cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden'); | |||
}); | |||
}); |
@@ -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(); | |||
}); |
@@ -82,7 +82,7 @@ def handle(): | |||
if frappe.local.request.method=="PUT": | |||
data = get_request_form_data() | |||
doc = frappe.get_doc(doctype, name) | |||
doc = frappe.get_doc(doctype, name, for_update=True) | |||
if "flags" in data: | |||
del data["flags"] | |||
@@ -154,7 +154,6 @@ class LoginManager: | |||
self.make_session() | |||
self.setup_boot_cache() | |||
self.set_user_info() | |||
self.clear_preferred_language() | |||
def get_user_info(self): | |||
self.info = frappe.db.get_value("User", self.user, | |||
@@ -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 | |||
@@ -561,30 +561,54 @@ def move(dest_dir, site): | |||
return final_new_path | |||
@click.command('set-password') | |||
@click.argument('user') | |||
@click.argument('password', required=False) | |||
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) | |||
@pass_context | |||
def set_password(context, user, password=None, logout_all_sessions=False): | |||
"Set password for a user on a site" | |||
if not context.sites: | |||
raise SiteNotSpecifiedError | |||
for site in context.sites: | |||
set_user_password(site, user, password, logout_all_sessions) | |||
@click.command('set-admin-password') | |||
@click.argument('admin-password') | |||
@click.argument('admin-password', required=False) | |||
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) | |||
@pass_context | |||
def set_admin_password(context, admin_password, logout_all_sessions=False): | |||
def set_admin_password(context, admin_password=None, logout_all_sessions=False): | |||
"Set Administrator password for a site" | |||
if not context.sites: | |||
raise SiteNotSpecifiedError | |||
for site in context.sites: | |||
set_user_password(site, "Administrator", admin_password, logout_all_sessions) | |||
def set_user_password(site, user, password, logout_all_sessions=False): | |||
import getpass | |||
from frappe.utils.password import update_password | |||
for site in context.sites: | |||
try: | |||
frappe.init(site=site) | |||
try: | |||
frappe.init(site=site) | |||
while not admin_password: | |||
admin_password = getpass.getpass("Administrator's password for {0}: ".format(site)) | |||
while not password: | |||
password = getpass.getpass(f"{user}'s password for {site}: ") | |||
frappe.connect() | |||
if not frappe.db.exists("User", user): | |||
print(f"User {user} does not exist") | |||
sys.exit(1) | |||
update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions) | |||
frappe.db.commit() | |||
password = None | |||
finally: | |||
frappe.destroy() | |||
frappe.connect() | |||
update_password(user='Administrator', pwd=admin_password, logout_all_sessions=logout_all_sessions) | |||
frappe.db.commit() | |||
admin_password = None | |||
finally: | |||
frappe.destroy() | |||
if not context.sites: | |||
raise SiteNotSpecifiedError | |||
@click.command('set-last-active-for-user') | |||
@click.option('--user', help="Setup last active date for user") | |||
@@ -729,6 +753,7 @@ commands = [ | |||
remove_from_installed_apps, | |||
restore, | |||
run_patch, | |||
set_password, | |||
set_admin_password, | |||
uninstall, | |||
disable_user, | |||
@@ -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') | |||
@@ -90,7 +90,7 @@ | |||
"label": "Type", | |||
"oldfieldname": "fieldtype", | |||
"oldfieldtype": "Select", | |||
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", | |||
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", | |||
"reqd": 1, | |||
"search_index": 1 | |||
}, | |||
@@ -487,7 +487,7 @@ | |||
"index_web_pages_for_search": 1, | |||
"istable": 1, | |||
"links": [], | |||
"modified": "2020-10-29 06:09:26.454990", | |||
"modified": "2021-07-10 21:56:04.167745", | |||
"modified_by": "Administrator", | |||
"module": "Core", | |||
"name": "DocField", | |||
@@ -66,4 +66,92 @@ frappe.ui.form.on('DocType', { | |||
autoname: function(frm) { | |||
frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt'); | |||
} | |||
}) | |||
}); | |||
frappe.ui.form.on("DocField", { | |||
form_render(frm, doctype, docname) { | |||
// Render two select fields for Fetch From instead of Small Text for better UX | |||
let field = frm.cur_grid.grid_form.fields_dict.fetch_from; | |||
$(field.input_area).hide(); | |||
let $doctype_select = $(`<select class="form-control">`); | |||
let $field_select = $(`<select class="form-control">`); | |||
let $wrapper = $('<div class="fetch-from-select row"><div>'); | |||
$wrapper.append($doctype_select, $field_select); | |||
field.$input_wrapper.append($wrapper); | |||
$doctype_select.wrap('<div class="col"></div>'); | |||
$field_select.wrap('<div class="col"></div>'); | |||
let row = frappe.get_doc(doctype, docname); | |||
let curr_value = { doctype: null, fieldname: null }; | |||
if (row.fetch_from) { | |||
let [doctype, fieldname] = row.fetch_from.split("."); | |||
curr_value.doctype = doctype; | |||
curr_value.fieldname = fieldname; | |||
} | |||
let curr_df_link_doctype = row.fieldtype == "Link" ? row.options : null; | |||
let doctypes = frm.doc.fields | |||
.filter(df => df.fieldtype == "Link") | |||
.filter(df => df.options && df.options != curr_df_link_doctype) | |||
.map(df => ({ | |||
label: `${df.options} (${df.fieldname})`, | |||
value: df.fieldname | |||
})); | |||
$doctype_select.add_options([ | |||
{ label: __("Select DocType"), value: "", selected: true }, | |||
...doctypes | |||
]); | |||
$doctype_select.on("change", () => { | |||
row.fetch_from = ""; | |||
frm.dirty(); | |||
update_fieldname_options(); | |||
}); | |||
function update_fieldname_options() { | |||
$field_select.find("option").remove(); | |||
let link_fieldname = $doctype_select.val(); | |||
if (!link_fieldname) return; | |||
let link_field = frm.doc.fields.find( | |||
df => df.fieldname === link_fieldname | |||
); | |||
let link_doctype = link_field.options; | |||
frappe.model.with_doctype(link_doctype, () => { | |||
let fields = frappe.meta | |||
.get_docfields(link_doctype, null, { | |||
fieldtype: ["not in", frappe.model.no_value_type] | |||
}) | |||
.map(df => ({ | |||
label: `${df.label} (${df.fieldtype})`, | |||
value: df.fieldname | |||
})); | |||
$field_select.add_options([ | |||
{ | |||
label: __("Select Field"), | |||
value: "", | |||
selected: true, | |||
disabled: true | |||
}, | |||
...fields | |||
]); | |||
if (curr_value.fieldname) { | |||
$field_select.val(curr_value.fieldname); | |||
} | |||
}); | |||
} | |||
$field_select.on("change", () => { | |||
let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`; | |||
row.fetch_from = fetch_from; | |||
frm.dirty(); | |||
}); | |||
if (curr_value.doctype) { | |||
$doctype_select.val(curr_value.doctype); | |||
update_fieldname_options(); | |||
} | |||
} | |||
}); |
@@ -722,6 +722,19 @@ def validate_links_table_fieldnames(meta): | |||
message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype)) | |||
frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname")) | |||
if link.is_child_table and not meta.get_field(link.table_fieldname): | |||
message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.table_fieldname), frappe.bold(meta.name)) | |||
frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname")) | |||
if link.is_child_table: | |||
if not link.parent_doctype: | |||
message = _("Row #{0}: Parent DocType is mandatory for internal links").format(index+1) | |||
frappe.throw(message, frappe.ValidationError, _("Parent Missing")) | |||
if not link.table_fieldname: | |||
message = _("Row #{0}: Table Fieldname is mandatory for internal links").format(index+1) | |||
frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing")) | |||
def validate_fields_for_doctype(doctype): | |||
meta = frappe.get_meta(doctype, cached=False) | |||
validate_links_table_fieldnames(meta) | |||
@@ -931,6 +944,9 @@ def validate_fields(meta): | |||
if meta.website_search_field not in fieldname_list: | |||
frappe.throw(_("Website Search Field must be a valid fieldname"), InvalidFieldNameError) | |||
if "title" not in fieldname_list: | |||
frappe.throw(_('Field "title" is mandatory if "Website Search Field" is set.'), title=_("Missing Field")) | |||
def check_timeline_field(meta): | |||
if not meta.timeline_field: | |||
return | |||
@@ -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() |
@@ -120,7 +120,7 @@ | |||
"label": "Field Type", | |||
"oldfieldname": "fieldtype", | |||
"oldfieldtype": "Select", | |||
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", | |||
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", | |||
"reqd": 1 | |||
}, | |||
{ | |||
@@ -417,7 +417,7 @@ | |||
"idx": 1, | |||
"index_web_pages_for_search": 1, | |||
"links": [], | |||
"modified": "2021-07-12 04:54:12.042319", | |||
"modified": "2021-07-12 05:54:13.042319", | |||
"modified_by": "Administrator", | |||
"module": "Custom", | |||
"name": "Custom Field", | |||
@@ -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' | |||
@@ -82,7 +82,7 @@ | |||
"label": "Type", | |||
"oldfieldname": "fieldtype", | |||
"oldfieldtype": "Select", | |||
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime", | |||
"options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime", | |||
"reqd": 1, | |||
"search_index": 1 | |||
}, | |||
@@ -428,7 +428,7 @@ | |||
"index_web_pages_for_search": 1, | |||
"istable": 1, | |||
"links": [], | |||
"modified": "2020-10-29 06:11:57.661039", | |||
"modified": "2021-07-10 21:57:24.479749", | |||
"modified_by": "Administrator", | |||
"module": "Custom", | |||
"name": "Customize Form Field", | |||
@@ -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] | |||
@@ -51,7 +51,8 @@ class MariaDBDatabase(Database): | |||
'Color': ('varchar', self.VARCHAR_LEN), | |||
'Barcode': ('longtext', ''), | |||
'Geolocation': ('longtext', ''), | |||
'Duration': ('decimal', '18,6') | |||
'Duration': ('decimal', '18,6'), | |||
'Icon': ('varchar', self.VARCHAR_LEN) | |||
} | |||
def get_connection(self): | |||
@@ -60,7 +60,8 @@ class PostgresDatabase(Database): | |||
'Color': ('varchar', self.VARCHAR_LEN), | |||
'Barcode': ('text', ''), | |||
'Geolocation': ('text', ''), | |||
'Duration': ('decimal', '18,6') | |||
'Duration': ('decimal', '18,6'), | |||
'Icon': ('varchar', self.VARCHAR_LEN) | |||
} | |||
def get_connection(self): | |||
@@ -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.")) | |||
@@ -4,7 +4,7 @@ | |||
import unittest | |||
import frappe | |||
from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data | |||
from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data, enqueue_webhook | |||
class TestWebhook(unittest.TestCase): | |||
@@ -12,6 +12,8 @@ class TestWebhook(unittest.TestCase): | |||
def setUpClass(cls): | |||
# delete any existing webhooks | |||
frappe.db.sql("DELETE FROM tabWebhook") | |||
# Delete existing logs if any | |||
frappe.db.sql("DELETE FROM `tabWebhook Request Log`") | |||
# create test webhooks | |||
cls.create_sample_webhooks() | |||
@@ -162,3 +164,18 @@ class TestWebhook(unittest.TestCase): | |||
data = get_webhook_data(doc=self.user, webhook=self.webhook) | |||
self.assertEqual(data, {"name": self.user.name}) | |||
def test_webhook_req_log_creation(self): | |||
if not frappe.db.get_value('User', 'user2@integration.webhooks.test.com'): | |||
user = frappe.get_doc({ | |||
'doctype': 'User', | |||
'email': 'user2@integration.webhooks.test.com', | |||
'first_name': 'user2' | |||
}).insert() | |||
else: | |||
user = frappe.get_doc('User', 'user2@integration.webhooks.test.com') | |||
webhook = frappe.get_doc('Webhook', {'webhook_doctype': 'User'}) | |||
enqueue_webhook(user, webhook) | |||
self.assertTrue(frappe.db.get_all('Webhook Request Log', pluck='name')) |
@@ -18,6 +18,7 @@ | |||
"html_condition", | |||
"sb_webhook", | |||
"request_url", | |||
"request_method", | |||
"cb_webhook", | |||
"request_structure", | |||
"sb_security", | |||
@@ -154,10 +155,18 @@ | |||
"fieldname": "enabled", | |||
"fieldtype": "Check", | |||
"label": "Enabled" | |||
}, | |||
{ | |||
"default": "POST", | |||
"fieldname": "request_method", | |||
"fieldtype": "Select", | |||
"label": "Request Method", | |||
"options": "POST\nPUT\nDELETE", | |||
"reqd": 1 | |||
} | |||
], | |||
"links": [], | |||
"modified": "2021-04-14 05:35:28.532049", | |||
"modified": "2021-05-25 11:11:28.555291", | |||
"modified_by": "Administrator", | |||
"module": "Integrations", | |||
"name": "Webhook", | |||
@@ -59,7 +59,6 @@ class Webhook(Document): | |||
if self.request_structure == "Form URL-Encoded": | |||
self.webhook_json = None | |||
elif self.request_structure == "JSON": | |||
validate_json(self.webhook_json) | |||
validate_template(self.webhook_json) | |||
self.webhook_data = [] | |||
@@ -83,18 +82,32 @@ def enqueue_webhook(doc, webhook): | |||
for i in range(3): | |||
try: | |||
r = requests.post(webhook.request_url, data=json.dumps(data, default=str), headers=headers, timeout=5) | |||
r = requests.request(method=webhook.request_method, url=webhook.request_url, | |||
data=json.dumps(data, default=str), headers=headers, timeout=5) | |||
r.raise_for_status() | |||
frappe.logger().debug({"webhook_success": r.text}) | |||
log_request(webhook.request_url, headers, data, r) | |||
break | |||
except Exception as e: | |||
frappe.logger().debug({"webhook_error": e, "try": i + 1}) | |||
log_request(webhook.request_url, headers, data, r) | |||
sleep(3 * i + 1) | |||
if i != 2: | |||
continue | |||
else: | |||
raise e | |||
def log_request(url, headers, data, res): | |||
request_log = frappe.get_doc({ | |||
"doctype": "Webhook Request Log", | |||
"user": frappe.session.user if frappe.session.user else None, | |||
"url": url, | |||
"headers": json.dumps(headers, indent=4) if headers else None, | |||
"data": json.dumps(data, indent=4) if isinstance(data, dict) else data, | |||
"response": json.dumps(res.json(), indent=4) if res else None | |||
}) | |||
request_log.save(ignore_permissions=True) | |||
def get_webhook_headers(doc, webhook): | |||
headers = {} | |||
@@ -129,10 +142,3 @@ def get_webhook_data(doc, webhook): | |||
data = json.loads(data) | |||
return data | |||
def validate_json(string): | |||
try: | |||
json.loads(string) | |||
except (TypeError, ValueError): | |||
frappe.throw(_("Request Body consists of an invalid JSON structure"), title=_("Invalid JSON")) |
@@ -0,0 +1,8 @@ | |||
# Copyright (c) 2021, Frappe Technologies and Contributors | |||
# See license.txt | |||
# import frappe | |||
import unittest | |||
class TestWebhookRequestLog(unittest.TestCase): | |||
pass |
@@ -0,0 +1,8 @@ | |||
// Copyright (c) 2021, Frappe Technologies and contributors | |||
// For license information, please see license.txt | |||
frappe.ui.form.on('Webhook Request Log', { | |||
// refresh: function(frm) { | |||
// } | |||
}); |
@@ -0,0 +1,81 @@ | |||
{ | |||
"actions": [], | |||
"autoname": "WEBHOOK-REQ-.#####", | |||
"creation": "2021-05-24 21:35:59.104776", | |||
"doctype": "DocType", | |||
"editable_grid": 1, | |||
"engine": "InnoDB", | |||
"field_order": [ | |||
"user", | |||
"headers", | |||
"data", | |||
"column_break_4", | |||
"url", | |||
"response" | |||
], | |||
"fields": [ | |||
{ | |||
"fieldname": "url", | |||
"fieldtype": "Data", | |||
"label": "URL", | |||
"read_only": 1 | |||
}, | |||
{ | |||
"fieldname": "headers", | |||
"fieldtype": "Code", | |||
"label": "Headers", | |||
"options": "JSON", | |||
"read_only": 1 | |||
}, | |||
{ | |||
"fieldname": "response", | |||
"fieldtype": "Code", | |||
"label": "Response", | |||
"options": "JSON", | |||
"read_only": 1 | |||
}, | |||
{ | |||
"fieldname": "column_break_4", | |||
"fieldtype": "Column Break" | |||
}, | |||
{ | |||
"fieldname": "data", | |||
"fieldtype": "Code", | |||
"label": "Data", | |||
"options": "JSON", | |||
"read_only": 1 | |||
}, | |||
{ | |||
"fieldname": "user", | |||
"fieldtype": "Link", | |||
"label": "User", | |||
"options": "User", | |||
"read_only": 1 | |||
} | |||
], | |||
"in_create": 1, | |||
"index_web_pages_for_search": 1, | |||
"links": [], | |||
"modified": "2021-05-26 23:57:58.495261", | |||
"modified_by": "Administrator", | |||
"module": "Integrations", | |||
"name": "Webhook Request Log", | |||
"owner": "Administrator", | |||
"permissions": [ | |||
{ | |||
"create": 1, | |||
"delete": 1, | |||
"email": 1, | |||
"export": 1, | |||
"print": 1, | |||
"read": 1, | |||
"report": 1, | |||
"role": "System Manager", | |||
"share": 1, | |||
"write": 1 | |||
} | |||
], | |||
"sort_field": "modified", | |||
"sort_order": "DESC", | |||
"track_changes": 1 | |||
} |
@@ -0,0 +1,8 @@ | |||
# Copyright (c) 2021, Frappe Technologies and contributors | |||
# For license information, please see license.txt | |||
# import frappe | |||
from frappe.model.document import Document | |||
class WebhookRequestLog(Document): | |||
pass |
@@ -8,35 +8,14 @@ from urllib.parse import parse_qs | |||
from frappe.utils import get_request_session | |||
from frappe import _ | |||
def make_get_request(url, auth=None, headers=None, data=None): | |||
if not auth: | |||
auth = '' | |||
if not data: | |||
data = {} | |||
if not headers: | |||
headers = {} | |||
def make_request(method, url, auth=None, headers=None, data=None): | |||
auth = auth or '' | |||
data = data or {} | |||
headers = headers or {} | |||
try: | |||
s = get_request_session() | |||
frappe.flags.integration_request = s.get(url, data={}, auth=auth, headers=headers) | |||
frappe.flags.integration_request.raise_for_status() | |||
return frappe.flags.integration_request.json() | |||
except Exception as exc: | |||
frappe.log_error(frappe.get_traceback()) | |||
raise exc | |||
def make_post_request(url, auth=None, headers=None, data=None): | |||
if not auth: | |||
auth = '' | |||
if not data: | |||
data = {} | |||
if not headers: | |||
headers = {} | |||
try: | |||
s = get_request_session() | |||
frappe.flags.integration_request = s.post(url, data=data, auth=auth, headers=headers) | |||
frappe.flags.integration_request = s.request(method, url, data=data, auth=auth, headers=headers) | |||
frappe.flags.integration_request.raise_for_status() | |||
if frappe.flags.integration_request.headers.get("content-type") == "text/plain; charset=utf-8": | |||
@@ -47,6 +26,15 @@ def make_post_request(url, auth=None, headers=None, data=None): | |||
frappe.log_error() | |||
raise exc | |||
def make_get_request(url, **kwargs): | |||
return make_request('GET', url, **kwargs) | |||
def make_post_request(url, **kwargs): | |||
return make_request('POST', url, **kwargs) | |||
def make_put_request(url, **kwargs): | |||
return make_request('PUT', url, **kwargs) | |||
def create_request_log(data, integration_type, service_name, name=None, error=None): | |||
if isinstance(data, str): | |||
data = json.loads(data) | |||
@@ -34,7 +34,8 @@ data_fieldtypes = ( | |||
'Color', | |||
'Barcode', | |||
'Geolocation', | |||
'Duration' | |||
'Duration', | |||
'Icon' | |||
) | |||
no_value_fields = ( | |||
@@ -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' | |||
@@ -181,3 +181,4 @@ frappe.patches.v13_0.queryreport_columns | |||
frappe.patches.v13_0.jinja_hook | |||
frappe.patches.v13_0.update_notification_channel_if_empty | |||
frappe.patches.v14_0.drop_data_import_legacy | |||
frappe.patches.v14_0.rename_cancelled_documents |
@@ -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 | |||
@@ -1,4 +1,4 @@ | |||
<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg"> | |||
<svg id="frappe-symbols" aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg"> | |||
<symbol viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" id="icon-resting"> | |||
<path d="M7.606 3.799L8 4.302l.394-.503.106-.14c.048-.065.08-.108.129-.159a3.284 3.284 0 0 1 4.72 0c.424.434.655 1.245.65 2.278-.006 1.578-.685 2.931-1.728 4.159-1.05 1.234-2.439 2.308-3.814 3.328a.763.763 0 0 1-.914 0c-1.375-1.02-2.764-2.094-3.814-3.328C2.686 8.709 2.007 7.357 2 5.778c-.004-1.033.227-1.844.651-2.278a3.284 3.284 0 0 1 4.72 0c.05.05.081.094.129.158.028.038.061.083.106.14z" | |||
stroke="var(--icon-stroke)"></path> | |||
@@ -699,4 +699,7 @@ | |||
<path d="M7.971 8.259a1.305 1.305 0 100-2.61 1.305 1.305 0 000 2.61z"></path> | |||
</g> | |||
</symbol> | |||
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" id="icon-crop"> | |||
<path d="M14.88,11.63H4.33V1.12m7.34,10.51v3.25M6,4.37h5.64V10M1.13,4.37h3.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
</symbol> | |||
</svg> |
@@ -1,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 | |||
@@ -39,6 +39,7 @@ import './multiselect_pills'; | |||
import './multiselect_list'; | |||
import './rating'; | |||
import './duration'; | |||
import './icon'; | |||
frappe.ui.form.make_control = function (opts) { | |||
var control_class_name = "Control" + opts.df.fieldtype.replace(/ /g, ""); | |||
@@ -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); |
@@ -0,0 +1,93 @@ | |||
import Picker from '../../icon_picker/icon_picker'; | |||
frappe.ui.form.ControlIcon = class ControlIcon extends frappe.ui.form.ControlData { | |||
make_input() { | |||
this.df.placeholder = this.df.placeholder || __('Choose an icon'); | |||
super.make_input(); | |||
this.get_all_icons(); | |||
this.make_icon_input(); | |||
} | |||
get_all_icons() { | |||
frappe.symbols = []; | |||
$("#frappe-symbols > symbol[id]").each(function() { | |||
frappe.symbols.push(this.id.replace('icon-', '')); | |||
}); | |||
} | |||
make_icon_input() { | |||
let picker_wrapper = $('<div>'); | |||
this.picker = new Picker({ | |||
parent: picker_wrapper, | |||
icon: this.get_icon(), | |||
icons: frappe.symbols | |||
}); | |||
this.$wrapper.popover({ | |||
trigger: 'manual', | |||
offset: `${-this.$wrapper.width() / 4.5}, 5`, | |||
boundary: 'viewport', | |||
placement: 'bottom', | |||
template: ` | |||
<div class="popover icon-picker-popover"> | |||
<div class="picker-arrow arrow"></div> | |||
<div class="popover-body popover-content"></div> | |||
</div> | |||
`, | |||
content: () => picker_wrapper, | |||
html: true | |||
}).on('show.bs.popover', () => { | |||
setTimeout(() => { | |||
this.picker.refresh(); | |||
}, 10); | |||
}).on('hidden.bs.popover', () => { | |||
$('body').off('click.icon-popover'); | |||
$(window).off('hashchange.icon-popover'); | |||
}); | |||
this.picker.on_change = (icon) => { | |||
this.set_value(icon); | |||
}; | |||
if (!this.selected_icon) { | |||
this.selected_icon = $(`<div class="selected-icon">${frappe.utils.icon("folder-normal", "md")}</div>`); | |||
this.selected_icon.insertAfter(this.$input); | |||
} | |||
this.$wrapper.find('.selected-icon').parent().on('click', (e) => { | |||
this.$wrapper.popover('toggle'); | |||
if (!this.get_icon()) { | |||
this.$input.val(''); | |||
} | |||
e.stopPropagation(); | |||
$('body').on('click.icon-popover', (ev) => { | |||
if (!$(ev.target).parents().is('.popover')) { | |||
this.$wrapper.popover('hide'); | |||
} | |||
}); | |||
$(window).on('hashchange.icon-popover', () => { | |||
this.$wrapper.popover('hide'); | |||
}); | |||
}); | |||
} | |||
refresh() { | |||
super.refresh(); | |||
let icon = this.get_icon(); | |||
if (this.picker && this.picker.icon !== icon) { | |||
this.picker.icon = icon; | |||
this.picker.refresh(); | |||
} | |||
} | |||
set_formatted_input(value) { | |||
super.set_formatted_input(value); | |||
this.$input.val(value); | |||
this.selected_icon.find("use").attr("href", "#icon-"+(value || "folder-normal")); | |||
this.selected_icon.toggleClass('no-value', !value); | |||
} | |||
get_icon() { | |||
return this.get_value() || 'folder-normal'; | |||
} | |||
}; |
@@ -113,6 +113,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro | |||
var is_value_null = is_null(v.value); | |||
var is_label_null = is_null(v.label); | |||
var is_disabled = Boolean(v.disabled); | |||
var is_selected = Boolean(v.selected); | |||
if (is_value_null && is_label_null) { | |||
value = v; | |||
@@ -126,6 +127,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro | |||
$('<option>').html(cstr(label)) | |||
.attr('value', value) | |||
.prop('disabled', is_disabled) | |||
.prop('selected', is_selected) | |||
.appendTo(this); | |||
} | |||
// select the first option | |||
@@ -152,6 +152,7 @@ function get_version_comment(version_doc, text) { | |||
let unlinked_content = ""; | |||
try { | |||
text += '</>'; | |||
Array.from($(text)).forEach(element => { | |||
if ($(element).is('a')) { | |||
version_comment += unlinked_content ? frappe.utils.get_form_link('Version', version_doc.name, true, unlinked_content) : ""; | |||
@@ -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; | |||
@@ -92,7 +92,7 @@ frappe.ui.form.FormTour = class FormTour { | |||
return { | |||
element, | |||
name, | |||
popover: { title, description, position: frappe.router.slug(position) }, | |||
popover: { title, description, position: frappe.router.slug(position || 'Bottom') }, | |||
onNext: on_next | |||
}; | |||
} | |||
@@ -303,6 +303,12 @@ frappe.form.formatters = { | |||
<div class="selected-color" style="background-color: ${value}"></div> | |||
<span class="color-value">${value}</span> | |||
</div>` : ''; | |||
}, | |||
Icon: (value) => { | |||
return value ? `<div> | |||
<div class="selected-icon">${frappe.utils.icon(value, "md")}</div> | |||
<span class="icon-value">${value}</span> | |||
</div>` : ''; | |||
} | |||
}; | |||
@@ -264,15 +264,16 @@ export default class Grid { | |||
make_head() { | |||
// labels | |||
if (!this.header_row) { | |||
this.header_row = new GridRow({ | |||
parent: $(this.parent).find(".grid-heading-row"), | |||
parent_df: this.df, | |||
docfields: this.docfields, | |||
frm: this.frm, | |||
grid: this | |||
}); | |||
if (this.header_row) { | |||
$(this.parent).find(".grid-heading-row .grid-row").remove(); | |||
} | |||
this.header_row = new GridRow({ | |||
parent: $(this.parent).find(".grid-heading-row"), | |||
parent_df: this.df, | |||
docfields: this.docfields, | |||
frm: this.frm, | |||
grid: this | |||
}); | |||
} | |||
refresh(force) { | |||
@@ -250,6 +250,14 @@ frappe.ui.form.Layout = class Layout { | |||
// collapse sections | |||
this.refresh_section_collapse(); | |||
} | |||
if (document.activeElement) { | |||
document.activeElement.focus(); | |||
if (document.activeElement.tagName == 'INPUT') { | |||
document.activeElement.select(); | |||
} | |||
} | |||
} | |||
refresh_sections() { | |||
@@ -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() { | |||
@@ -0,0 +1,86 @@ | |||
class Picker { | |||
constructor(opts) { | |||
this.parent = opts.parent; | |||
this.width = opts.width; | |||
this.height = opts.height; | |||
this.set_icon(opts.icon); | |||
this.icons = opts.icons; | |||
this.setup_picker(); | |||
} | |||
refresh() { | |||
this.update_icon_selected(true); | |||
} | |||
setup_picker() { | |||
this.icon_picker_wrapper = $(` | |||
<div class="icon-picker"> | |||
<div class="search-icons"> | |||
<input type="search" placeholder="Search for icons.." class="form-control"> | |||
<span class="search-icon">${frappe.utils.icon('search', "sm")}</span> | |||
</div> | |||
<div class="icon-section"> | |||
<div class="icons"></div> | |||
</div> | |||
</div> | |||
`); | |||
this.parent.append(this.icon_picker_wrapper); | |||
this.icon_wrapper = this.icon_picker_wrapper.find('.icons'); | |||
this.search_input = this.icon_picker_wrapper.find('.search-icons > input'); | |||
this.refresh(); | |||
this.setup_icons(); | |||
} | |||
setup_icons() { | |||
this.icons.forEach(icon => { | |||
let $icon = $(`<div id="${icon}" class="icon-wrapper">${frappe.utils.icon(icon, "md")}</div>`); | |||
this.icon_wrapper.append($icon); | |||
const set_values = () => { | |||
this.set_icon(icon); | |||
this.update_icon_selected(); | |||
}; | |||
$icon.on('click', () => { | |||
set_values(); | |||
}); | |||
$icon.keydown((e) => { | |||
const key_code = e.keyCode; | |||
if ([13, 32].includes(key_code)) { | |||
e.preventDefault(); | |||
set_values(); | |||
} | |||
}); | |||
this.search_input.keyup((e) => { | |||
e.preventDefault(); | |||
this.filter_icons(); | |||
}); | |||
this.search_input.on('search', () => { | |||
this.filter_icons(); | |||
}); | |||
}); | |||
} | |||
filter_icons() { | |||
let value = this.search_input.val(); | |||
if (!value) { | |||
this.icon_wrapper.find(".icon-wrapper").removeClass('hidden'); | |||
} else { | |||
this.icon_wrapper.find(".icon-wrapper").addClass('hidden'); | |||
this.icon_wrapper.find(`.icon-wrapper[id*='${value}']`).removeClass('hidden'); | |||
} | |||
} | |||
update_icon_selected(silent) { | |||
!silent && this.on_change && this.on_change(this.get_icon()); | |||
} | |||
set_icon(icon) { | |||
this.icon = icon || ''; | |||
} | |||
get_icon() { | |||
return this.icon || ''; | |||
} | |||
} | |||
export default Picker; |
@@ -514,7 +514,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { | |||
render_skeleton() { | |||
const $row = this.get_list_row_html_skeleton( | |||
'<div><input type="checkbox" /></div>' | |||
'<div><input type="checkbox" class="render-list-checkbox"/></div>' | |||
); | |||
this.$result.append($row); | |||
} | |||
@@ -927,10 +927,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { | |||
const seen = this.get_seen_class(doc); | |||
let subject_html = ` | |||
<input class="level-item list-row-checkbox hidden-xs" type="checkbox" | |||
data-name="${escape(doc.name)}"> | |||
<span class="level-item" style="margin-bottom: 1px;"> | |||
${this.get_like_html(doc)} | |||
<span class="level-item select-like"> | |||
<input class="list-row-checkbox hidden-xs" type="checkbox" | |||
data-name="${escape(doc.name)}"> | |||
<span class="list-row-like style="margin-bottom: 1px;"> | |||
${this.get_like_html(doc)} | |||
</span> | |||
</span> | |||
<span class="level-item ${seen} ellipsis" title="${escaped_subject}"> | |||
<a class="ellipsis" | |||
@@ -1127,7 +1129,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { | |||
// don't open form when checkbox, like, filterable are clicked | |||
if ( | |||
$target.hasClass("filterable") || | |||
$target.hasClass("icon-heart") || | |||
$target.hasClass("select-like") || | |||
$target.hasClass("list-row-like") || | |||
$target.is(":checkbox") | |||
) { | |||
e.stopPropagation(); | |||
@@ -64,7 +64,7 @@ | |||
<div class="grid-body"> | |||
<div class="rows"> | |||
<div class="grid-row" :class="showing == call.index ? 'grid-row-open' : ''" v-for="call in paginated(sorted(grouped(request.calls)))" :key="call.index"> | |||
<div class="data-row row" v-if="showing != call.index" style="display: block;" @click="showing = call.index" > | |||
<div class="data-row row" @click="showing = showing == call.index ? null : call.index" > | |||
<div class="row-index col col-xs-1"><span>{{ call.index }}</span></div> | |||
<div class="col grid-static-col col-xs-6" data-fieldtype="Code"> | |||
<div class="static-area"><span>{{ call.query }}</span></div> | |||
@@ -76,16 +76,13 @@ | |||
<div class="static-area ellipsis text-right">{{ call.exact_copies }}</div> | |||
</div> | |||
<div class="col col-xs-1"><a class="close btn-open-row"> | |||
<span class="octicon octicon-triangle-down"></span></a> | |||
<span class="octicon" :class="showing == call.index? 'octicon-triangle-up' : 'octicon-triangle-down'"></span></a> | |||
</div> | |||
</div> | |||
<div class="recorder-form-in-grid" v-if="showing == call.index"> | |||
<div class="grid-form-heading" @click="showing = null"> | |||
<div class="toolbar grid-header-toolbar"> | |||
<span class="panel-title">{{ __("SQL Query") }} #<span class="grid-form-row-index">{{ call.index }}</span></span> | |||
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;"> | |||
<span class="hidden-xs octicon octicon-triangle-up"></span> | |||
</div> | |||
</div> | |||
</div> | |||
<div class="grid-form-body"> | |||
@@ -116,7 +113,7 @@ | |||
</div> | |||
<div class="frappe-control"> | |||
<div class="form-group"> | |||
<div class="clearfix"><label class="control-label"{{ __("Stack Trace") }}</label></div> | |||
<div class="clearfix"><label class="control-label">{{ __("Stack Trace") }}</label></div> | |||
<div class="control-value like-disabled-input for-description" style="overflow:auto"> | |||
<table class="table table-striped"> | |||
<thead> | |||
@@ -51,7 +51,7 @@ $('body').on('click', 'a', function(e) { | |||
return override('/app'); | |||
} | |||
if (href.startsWith('#')) { | |||
if (href && href.startsWith('#')) { | |||
// target startswith "#", this is a v1 style route, so remake it. | |||
return override(e.currentTarget.hash); | |||
} | |||
@@ -169,10 +169,8 @@ frappe.router = { | |||
standard_route = ['Tree', doctype_route.doctype]; | |||
} else { | |||
standard_route = ['List', doctype_route.doctype, frappe.utils.to_title_case(route[2])]; | |||
if (route[3]) { | |||
// calendar / kanban / dashboard / folder name | |||
standard_route.push([...route].splice(3, route.length)); | |||
} | |||
// calendar / kanban / dashboard / folder | |||
if (route[3]) standard_route.push(...route.slice(3, route.length)); | |||
} | |||
return standard_route; | |||
}, | |||
@@ -234,6 +232,12 @@ frappe.router = { | |||
} | |||
}, | |||
clear_re_route(doctype, docname) { | |||
delete frappe.re_route[ | |||
`${encodeURIComponent(frappe.router.slug(doctype))}/${encodeURIComponent(docname)}` | |||
]; | |||
}, | |||
set_title(sub_path) { | |||
if (frappe.route_titles[sub_path]) { | |||
frappe.utils.set_title(frappe.route_titles[sub_path]); | |||
@@ -245,7 +249,7 @@ frappe.router = { | |||
// example 1: frappe.set_route('a', 'b', 'c'); | |||
// example 2: frappe.set_route(['a', 'b', 'c']); | |||
// example 3: frappe.set_route('a/b/c'); | |||
let route = arguments; | |||
let route = Array.from(arguments); | |||
return new Promise(resolve => { | |||
route = this.get_route_from_arguments(route); | |||
@@ -297,7 +301,7 @@ frappe.router = { | |||
new_route = [this.slug(route[1]), 'view', route[2].toLowerCase()]; | |||
// calendar / inbox / file folder | |||
if (route[3]) new_route.push([...route].slice(3, route.length)); | |||
if (route[3]) new_route.push(...route.slice(3, route.length)); | |||
} else { | |||
if ($.isPlainObject(route[2])) { | |||
frappe.route_options = route[2]; | |||
@@ -98,6 +98,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { | |||
me.focus_on_first_input(); | |||
me.on_page_show && me.on_page_show(); | |||
$(document).trigger('frappe.ui.Dialog:shown'); | |||
$(document).off('focusin.modal'); | |||
}) | |||
.on('scroll', function() { | |||
var $input = $('input:focus'); | |||
@@ -518,6 +518,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||
} else { | |||
this.page.show_form(); | |||
} | |||
this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height')); | |||
this.page.body.parent().css('margin-bottom', 'unset'); | |||
} | |||
set_filters(filters) { | |||
@@ -49,6 +49,8 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { | |||
this.setup_columns(); | |||
super.setup_new_doc_event(); | |||
this.page.main.addClass('report-view'); | |||
this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height')); | |||
this.page.body.parent().css('margin-bottom', 'unset'); | |||
} | |||
toggle_side_bar() { | |||
@@ -1,5 +1,6 @@ | |||
@import "grid"; | |||
@import "color_picker"; | |||
@import "icon_picker"; | |||
@import "datepicker"; | |||
// password | |||
@@ -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); | |||
@@ -0,0 +1,95 @@ | |||
.icon-picker { | |||
font-size: var(--text-xs); | |||
color: var(--text-muted); | |||
--icon-picker-width: 240px; | |||
width: var(--icon-picker-width); | |||
.icons { | |||
margin-top: 10px; | |||
display: flex; | |||
flex-wrap: wrap; | |||
overflow-y: scroll; | |||
max-height: 210px; | |||
cursor: pointer; | |||
/* Hide scrollbar for IE, Edge and Firefox */ | |||
-ms-overflow-style: none; /* IE and Edge */ | |||
scrollbar-width: none; /* Firefox */ | |||
/* Hide scrollbar for Chrome, Safari and Opera */ | |||
&::-webkit-scrollbar { | |||
display: none; | |||
} | |||
.icon-wrapper { | |||
display: flex; | |||
width: 30px; | |||
height: 30px; | |||
text-align: center; | |||
align-items: center; | |||
} | |||
} | |||
.search-icons { | |||
position: relative; | |||
input[type='search'] { | |||
height: inherit; | |||
padding-left: 30px; | |||
} | |||
.search-icon { | |||
position: absolute; | |||
top: 7px; | |||
left: 7px; | |||
} | |||
} | |||
} | |||
.icon-picker-popover { | |||
.picker-arrow { | |||
left: 15px !important; | |||
} | |||
} | |||
.frappe-control[data-fieldtype='Icon'] { | |||
input { | |||
padding-left: 40px; | |||
} | |||
.selected-icon { | |||
cursor: pointer; | |||
width: 22px; | |||
height: 22px; | |||
border-radius: 5px; | |||
position: absolute; | |||
top: calc(50% + 1px); | |||
left: 8px; | |||
content: ' '; | |||
} | |||
.like-disabled-input { | |||
.icon-value { | |||
padding-left: 25px; | |||
} | |||
.selected-icon { | |||
top: 20%; | |||
cursor: default; | |||
} | |||
} | |||
} | |||
.data-row.row { | |||
.selected-icon { | |||
top: calc(50% - 11px); | |||
z-index: 2; | |||
} | |||
} | |||
.dt-cell__content { | |||
.selected-icon { | |||
display: contents; | |||
} | |||
} | |||
.dt-cell__edit, .filter-field { | |||
.selected-icon { | |||
top: 5px !important; | |||
} | |||
} |
@@ -1,4 +1,5 @@ | |||
@import "../common/form.scss"; | |||
@import '~cropperjs/dist/cropper.min'; | |||
.form-section, .form-dashboard-section { | |||
margin: 0px; | |||
@@ -58,7 +58,7 @@ | |||
} | |||
.list-row { | |||
padding: 15px; | |||
padding: 15px 15px 15px 0px; | |||
height: 45px; | |||
cursor: pointer; | |||
transition: color 0.2s; | |||
@@ -130,10 +130,15 @@ | |||
margin-left: 5px; | |||
} | |||
} | |||
.select-like { | |||
padding: 15px 0px 15px 15px; | |||
} | |||
} | |||
.list-row-head { | |||
@extend .list-row; | |||
padding: 15px; | |||
cursor: default; | |||
.list-subject { | |||
@@ -200,6 +205,10 @@ input.list-check-all, input.list-row-checkbox { | |||
--checkbox-right-margin: calc(var(--checkbox-size) / 2 + #{$level-margin-right}); | |||
} | |||
.render-list-checkbox { | |||
margin-left: 15px; | |||
} | |||
.filterable { | |||
cursor: pointer; | |||
} | |||
@@ -84,14 +84,37 @@ | |||
margin-bottom: 10px; | |||
} | |||
.layout-main-section .frappe-card { | |||
--report-filter-height: 0px; | |||
} | |||
.report-wrapper { | |||
overflow: auto; | |||
.datatable { | |||
height: calc(100vh - var(--report-filter-height) - 205px); | |||
.dt-scrollable { | |||
height: calc(100vh - var(--report-filter-height) - 275px); | |||
} | |||
} | |||
} | |||
.report-view { | |||
.dt-row:last-child:not(.dt-row-filter) { | |||
.dt-cell { | |||
border-bottom: 1px solid var(--border-color); | |||
.result { | |||
min-height: 50vh !important; | |||
.dt-row:last-child:not(.dt-row-filter) { | |||
.dt-cell { | |||
border-bottom: 1px solid var(--border-color); | |||
} | |||
} | |||
.datatable { | |||
height: calc(100vh - var(--report-filter-height) - 225px); | |||
.dt-scrollable { | |||
height: calc(100vh - var(--report-filter-height) - 295px); | |||
} | |||
} | |||
} | |||
} | |||
@@ -90,19 +90,22 @@ class WebsiteSearch(FullTextSearch): | |||
def slugs_with_web_view(_items_to_index): | |||
all_routes = [] | |||
filters = { "has_web_view": 1, "allow_guest_to_view": 1, "index_web_pages_for_search": 1} | |||
fields = ["name", "is_published_field", 'website_search_field'] | |||
fields = ["name", "is_published_field", "website_search_field"] | |||
doctype_with_web_views = frappe.get_all("DocType", filters=filters, fields=fields) | |||
for doctype in doctype_with_web_views: | |||
if doctype.is_published_field: | |||
docs = frappe.get_all(doctype.name, filters={doctype.is_published_field: 1}, fields=["route", doctype.website_search_field, 'title']) | |||
fields=["route", doctype.website_search_field] | |||
filters={doctype.is_published_field: 1}, | |||
if doctype.website_search_field: | |||
docs = frappe.get_all(doctype.name, filters=filters, fields=fields.append("title")) | |||
for doc in docs: | |||
content = frappe.utils.md_to_html(getattr(doc, doctype.website_search_field)) | |||
soup = BeautifulSoup(content, "html.parser") | |||
text_content = soup.text if soup else "" | |||
_items_to_index += [frappe._dict(title=doc.title, content=text_content, path=doc.route)] | |||
else: | |||
docs = frappe.get_all(doctype.name, filters=filters, fields=fields) | |||
all_routes += [route.route for route in docs] | |||
return all_routes | |||
@@ -436,3 +436,16 @@ class TestCommands(BaseTestCommands): | |||
self.execute("bench version -f invalid") | |||
self.assertEqual(self.returncode, 2) | |||
def test_set_password(self): | |||
from frappe.utils.password import check_password | |||
self.execute("bench --site {site} set-password Administrator test1") | |||
self.assertEqual(self.returncode, 0) | |||
self.assertEqual(check_password('Administrator', 'test1'), 'Administrator') | |||
# to release the lock taken by check_password | |||
frappe.db.commit() | |||
self.execute("bench --site {site} set-admin-password test2") | |||
self.assertEqual(self.returncode, 0) | |||
self.assertEqual(check_password('Administrator', 'test2'), 'Administrator') |
@@ -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() |
@@ -18,8 +18,19 @@ first_lang, second_lang, third_lang, fourth_lang, fifth_lang = choices( | |||
) | |||
class TestTranslate(unittest.TestCase): | |||
guest_sessions_required = [ | |||
"test_guest_request_language_resolution_with_cookie", | |||
"test_guest_request_language_resolution_with_request_header" | |||
] | |||
def setUp(self): | |||
if self._testMethodName in self.guest_sessions_required: | |||
frappe.set_user("Guest") | |||
def tearDown(self): | |||
frappe.form_dict.pop("_lang", None) | |||
if self._testMethodName in self.guest_sessions_required: | |||
frappe.set_user("Administrator") | |||
def test_extract_message_from_file(self): | |||
data = frappe.translate.get_messages_from_file(translation_string_file) | |||
@@ -52,21 +63,45 @@ class TestTranslate(unittest.TestCase): | |||
Case 2: frappe.form_dict._lang is not set, but preferred_language cookie is | |||
""" | |||
with patch.object(frappe.translate, "get_preferred_language_cookie", return_value=second_lang): | |||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)]) | |||
return_val = get_language() | |||
self.assertNotIn(return_val, [second_lang, get_parent_language(second_lang)]) | |||
def test_guest_request_language_resolution_with_cookie(self): | |||
"""Test for frappe.translate.get_language | |||
Case 3: frappe.form_dict._lang is not set, but preferred_language cookie is [Guest User] | |||
""" | |||
with patch.object(frappe.translate, "get_preferred_language_cookie", return_value=second_lang): | |||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)]) | |||
return_val = get_language() | |||
self.assertIn(return_val, [second_lang, get_parent_language(second_lang)]) | |||
def test_request_language_resolution_with_request_header(self): | |||
def test_guest_request_language_resolution_with_request_header(self): | |||
"""Test for frappe.translate.get_language | |||
Case 3: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is | |||
Case 4: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is [Guest User] | |||
""" | |||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)]) | |||
return_val = get_language() | |||
self.assertIn(return_val, [third_lang, get_parent_language(third_lang)]) | |||
def test_request_language_resolution_with_request_header(self): | |||
"""Test for frappe.translate.get_language | |||
Case 5: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is | |||
""" | |||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)]) | |||
return_val = get_language() | |||
self.assertNotIn(return_val, [third_lang, get_parent_language(third_lang)]) | |||
expected_output = [ | |||
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2), | |||
@@ -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) |
@@ -27,11 +27,12 @@ def get_language(lang_list: List = None) -> str: | |||
Order of priority for setting language: | |||
1. Form Dict => _lang | |||
2. Cookie => preferred_language | |||
3. Request Header => Accept-Language | |||
2. Cookie => preferred_language (Non authorized user) | |||
3. Request Header => Accept-Language (Non authorized user) | |||
4. User document => language | |||
5. System Settings => language | |||
""" | |||
is_logged_in = frappe.session.user != "Guest" | |||
# fetch language from form_dict | |||
if frappe.form_dict._lang: | |||
@@ -41,6 +42,10 @@ def get_language(lang_list: List = None) -> str: | |||
if language: | |||
return language | |||
# use language set in User or System Settings if user is logged in | |||
if is_logged_in: | |||
return frappe.local.lang | |||
lang_set = set(lang_list or get_all_languages() or []) | |||
# fetch language from cookie | |||
@@ -20,7 +20,6 @@ from frappe.utils.commands import log | |||
default_timeout = 300 | |||
queue_timeout = { | |||
'background': 2500, | |||
'long': 1500, | |||
'default': 300, | |||
'short': 300 | |||
@@ -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 |
@@ -57,7 +57,7 @@ def update_add_node(doc, parent, parent_field): | |||
# get the last sibling of the parent | |||
if parent: | |||
left, right = frappe.db.sql("select lft, rgt from `tab{0}` where name=%s" | |||
left, right = frappe.db.sql("select lft, rgt from `tab{0}` where name=%s for update" | |||
.format(doctype), parent)[0] | |||
validate_loop(doc.doctype, doc.name, left, right) | |||
else: # root | |||
@@ -89,7 +89,7 @@ def update_move_node(doc, parent_field): | |||
if parent: | |||
new_parent = frappe.db.sql("""select lft, rgt from `tab{0}` | |||
where name = %s""".format(doc.doctype), parent, as_dict=1)[0] | |||
where name = %s for update""".format(doc.doctype), parent, as_dict=1)[0] | |||
validate_loop(doc.doctype, doc.name, new_parent.lft, new_parent.rgt) | |||
@@ -108,7 +108,7 @@ def update_move_node(doc, parent_field): | |||
if parent: | |||
new_parent = frappe.db.sql("""select lft, rgt from `tab%s` | |||
where name = %s""" % (doc.doctype, '%s'), parent, as_dict=1)[0] | |||
where name = %s for update""" % (doc.doctype, '%s'), parent, as_dict=1)[0] | |||
# set parent lft, rgt | |||
@@ -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): | |||
@@ -488,11 +488,12 @@ def set_content_type(response, data, path): | |||
return data | |||
def add_preload_headers(response): | |||
from bs4 import BeautifulSoup | |||
from bs4 import BeautifulSoup, SoupStrainer | |||
try: | |||
preload = [] | |||
soup = BeautifulSoup(response.data, "lxml") | |||
strainer = SoupStrainer(re.compile("script|link")) | |||
soup = BeautifulSoup(response.data, "lxml", parse_only=strainer) | |||
for elem in soup.find_all('script', src=re.compile(".*")): | |||
preload.append(("script", elem.get("src"))) | |||
@@ -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" | |||
} |
@@ -27,6 +27,7 @@ | |||
"bootstrap": "4.5.0", | |||
"cliui": "^7.0.4", | |||
"cookie": "^0.4.0", | |||
"cropperjs": "^1.5.12", | |||
"cssnano": "^5.0.0", | |||
"driver.js": "^0.9.8", | |||
"express": "^4.17.1", | |||
@@ -52,7 +53,7 @@ | |||
"qz-tray": "^2.0.8", | |||
"redis": "^3.1.1", | |||
"showdown": "^1.9.1", | |||
"snyk": "^1.518.0", | |||
"snyk": "^1.667.0", | |||
"socket.io": "^2.4.0", | |||
"superagent": "^3.8.2", | |||
"touch": "^3.1.0", | |||