diff --git a/cypress/fixtures/sample_image.jpg b/cypress/fixtures/sample_image.jpg new file mode 100644 index 0000000000..6322b65e33 Binary files /dev/null and b/cypress/fixtures/sample_image.jpg differ diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index 2f457983de..e1e232c058 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -54,4 +54,24 @@ context('FileUploader', () => { .should('have.property', 'file_url', 'https://github.com'); cy.get('.modal:visible').should('not.exist'); }); + + it('should allow cropping and optimization for valid images', () => { + open_upload_dialog(); + + cy.get_open_dialog().find('.file-upload-area').attachFile('sample_image.jpg', { + subjectType: 'drag-n-drop', + }); + + cy.get_open_dialog().find('.file-name').should('contain', 'sample_image.jpg'); + cy.get_open_dialog().find('.btn-crop').first().click(); + cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').should('contain', 'Crop'); + cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').click(); + cy.get_open_dialog().find('.optimize-checkbox').first().should('contain', 'Optimize'); + cy.get_open_dialog().find('.optimize-checkbox').first().click(); + + cy.intercept('POST', '/api/method/upload_file').as('upload_file'); + cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.wait('@upload_file').its('response.statusCode').should('eq', 200); + cy.get('.modal:visible').should('not.exist'); + }); }); diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index 84616cfbe6..c7bbe29e5a 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -1,3 +1,5 @@ +import custom_submittable_doctype from '../fixtures/custom_submittable_doctype'; + context('Timeline', () => { before(() => { cy.visit('/login'); @@ -50,4 +52,43 @@ context('Timeline', () => { 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'); + }); }); \ No newline at end of file diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 8fc6877d4f..f2395ae490 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -9,8 +9,8 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.utils import get_bench_path, update_progress_bar, cint - +from frappe.utils import update_progress_bar, cint +from frappe.coverage import CodeCoverage DATA_IMPORT_DEPRECATION = click.style( "[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n" @@ -530,52 +530,33 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None, skip_test_records=False, skip_before_tests=False, failfast=False): - "Run tests" - import frappe.test_runner - tests = test - - site = get_site(context) - - allow_tests = frappe.get_conf(site).allow_tests - - if not (allow_tests or os.environ.get('CI')): - click.secho('Testing is disabled for the site!', bold=True) - click.secho('You can enable tests by entering following command:') - click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green') - return + with CodeCoverage(coverage, app): + import frappe.test_runner + tests = test + site = get_site(context) - frappe.init(site=site) + allow_tests = frappe.get_conf(site).allow_tests - frappe.flags.skip_before_tests = skip_before_tests - frappe.flags.skip_test_records = skip_test_records + if not (allow_tests or os.environ.get('CI')): + click.secho('Testing is disabled for the site!', bold=True) + click.secho('You can enable tests by entering following command:') + click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green') + return - if coverage: - from coverage import Coverage - from frappe.coverage import STANDARD_INCLUSIONS, STANDARD_EXCLUSIONS, FRAPPE_EXCLUSIONS + frappe.init(site=site) - # Generate coverage report only for app that is being tested - source_path = os.path.join(get_bench_path(), 'apps', app or 'frappe') - omit = STANDARD_EXCLUSIONS[:] + frappe.flags.skip_before_tests = skip_before_tests + frappe.flags.skip_test_records = skip_test_records - if not app or app == 'frappe': - omit.extend(FRAPPE_EXCLUSIONS) + ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, + force=context.force, profile=profile, junit_xml_output=junit_xml_output, + ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast) - cov = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS) - cov.start() + if len(ret.failures) == 0 and len(ret.errors) == 0: + ret = 0 - ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, - force=context.force, profile=profile, junit_xml_output=junit_xml_output, - ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast) - - if coverage: - cov.stop() - cov.save() - - if len(ret.failures) == 0 and len(ret.errors) == 0: - ret = 0 - - if os.environ.get('CI'): - sys.exit(ret) + if os.environ.get('CI'): + sys.exit(ret) @click.command('run-parallel-tests') @click.option('--app', help="For App", default='frappe') @@ -585,13 +566,14 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal @click.option('--use-orchestrator', is_flag=True, help="Use orchestrator to run parallel tests") @pass_context def run_parallel_tests(context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False): - site = get_site(context) - if use_orchestrator: - from frappe.parallel_test_runner import ParallelTestWithOrchestrator - ParallelTestWithOrchestrator(app, site=site, with_coverage=with_coverage) - else: - from frappe.parallel_test_runner import ParallelTestRunner - ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds, with_coverage=with_coverage) + with CodeCoverage(with_coverage, app): + site = get_site(context) + if use_orchestrator: + from frappe.parallel_test_runner import ParallelTestWithOrchestrator + ParallelTestWithOrchestrator(app, site=site) + else: + from frappe.parallel_test_runner import ParallelTestRunner + ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds) @click.command('run-ui-tests') @click.argument('app') diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 1e1a01a685..9aaaf5a1ac 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -348,6 +348,7 @@ class TestDocType(unittest.TestCase): dump_docs = json.dumps(docs.get('docs')) cancel_all_linked_docs(dump_docs) data_link_doc.cancel() + data_doc.name = '{}-CANC-0'.format(data_doc.name) data_doc.load_from_db() self.assertEqual(data_link_doc.docstatus, 2) self.assertEqual(data_doc.docstatus, 2) @@ -371,7 +372,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - link_doc.insert() + link_doc.insert(ignore_if_duplicate=True) #create first parent doctype test_doc_1 = new_doctype('Test Doctype 1') @@ -386,7 +387,7 @@ class TestDocType(unittest.TestCase): for data in test_doc_1.get('permissions'): data.submit = 1 data.cancel = 1 - test_doc_1.insert() + test_doc_1.insert(ignore_if_duplicate=True) #crete second parent doctype doc = new_doctype('Test Doctype 2') @@ -401,7 +402,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - doc.insert() + doc.insert(ignore_if_duplicate=True) # create doctype data data_link_doc_1 = frappe.new_doc('Test Linked Doctype 1') @@ -432,6 +433,7 @@ class TestDocType(unittest.TestCase): # checking that doc for Test Doctype 2 is not canceled self.assertRaises(frappe.LinkExistsError, data_link_doc_1.cancel) + data_doc_2.name = '{}-CANC-0'.format(data_doc_2.name) data_doc.load_from_db() data_doc_2.load_from_db() self.assertEqual(data_link_doc_1.docstatus, 2) diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 6d77cb91ad..bc0cc17553 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -23,6 +23,25 @@ frappe.ui.form.on("File", "refresh", function(frm) { wrapper.empty(); } + var is_raster_image = (/\.(gif|jpg|jpeg|tiff|png)$/i).test(frm.doc.file_url); + var is_optimizable = !frm.doc.is_folder && is_raster_image && frm.doc.file_size > 0; + + if (is_optimizable) { + frm.add_custom_button(__("Optimize"), function() { + frappe.show_alert(__("Optimizing image...")); + frappe.call({ + method: "frappe.core.doctype.file.file.optimize_saved_image", + args: { + doc_name: frm.doc.name, + }, + callback: function() { + frappe.show_alert(__("Image optimized")); + frappe.set_route("List", "File"); + } + }); + }); + } + if(frm.doc.file_name && frm.doc.file_name.split('.').splice(-1)[0]==='zip') { frm.add_custom_button(__('Unzip'), function() { frappe.call({ diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 95c33879e6..e79b2bd761 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -28,7 +28,7 @@ import frappe from frappe import _, conf from frappe.model.document import Document from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip -from frappe.utils.image import strip_exif_data +from frappe.utils.image import strip_exif_data, optimize_image class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -879,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] @@ -887,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 @@ -899,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 @@ -932,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): diff --git a/frappe/coverage.py b/frappe/coverage.py index a59c24a714..33f945be40 100644 --- a/frappe/coverage.py +++ b/frappe/coverage.py @@ -33,3 +33,29 @@ FRAPPE_EXCLUSIONS = [ "*/doctype/*/*_dashboard.py", "*/patches/*", ] + +class CodeCoverage(): + def __init__(self, with_coverage, app): + self.with_coverage = with_coverage + self.app = app or 'frappe' + + def __enter__(self): + if self.with_coverage: + import os + from coverage import Coverage + from frappe.utils import get_bench_path + + # Generate coverage report only for app that is being tested + source_path = os.path.join(get_bench_path(), 'apps', self.app) + omit = STANDARD_EXCLUSIONS[:] + + if self.app == 'frappe': + omit.extend(FRAPPE_EXCLUSIONS) + + self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS) + self.coverage.start() + + def __exit__(self, exc_type, exc_value, traceback): + if self.with_coverage: + self.coverage.stop() + self.coverage.save() \ No newline at end of file diff --git a/frappe/handler.py b/frappe/handler.py index 8d0c18a00b..2e9fb7b454 100755 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -10,6 +10,8 @@ from frappe.utils import cint from frappe import _, is_whitelisted from frappe.utils.response import build_response from frappe.utils.csvutils import build_csv_response +from frappe.utils.image import optimize_image +from mimetypes import guess_type from frappe.core.doctype.server_script.server_script_utils import run_server_script_api @@ -53,7 +55,7 @@ def execute_cmd(cmd, from_async=False): try: method = get_attr(cmd) except Exception as e: - frappe.throw(_('Invalid Method')) + frappe.throw(_('Failed to get method for command {0} with {1}').format(cmd, e)) if from_async: method = method.queue @@ -145,6 +147,7 @@ def upload_file(): folder = frappe.form_dict.folder or 'Home' method = frappe.form_dict.method filename = frappe.form_dict.file_name + optimize = frappe.form_dict.optimize content = None if 'file' in files: @@ -152,12 +155,23 @@ def upload_file(): content = file.stream.read() filename = file.filename + content_type = guess_type(filename)[0] + if optimize and content_type.startswith("image/"): + args = { + "content": content, + "content_type": content_type + } + if frappe.form_dict.max_width: + args["max_width"] = int(frappe.form_dict.max_width) + if frappe.form_dict.max_height: + args["max_height"] = int(frappe.form_dict.max_height) + content = optimize_image(**args) + frappe.local.uploaded_file = content frappe.local.uploaded_filename = filename if not file_url and (frappe.session.user == "Guest" or (user and not user.has_desk_access())): - import mimetypes - filetype = mimetypes.guess_type(filename)[0] + filetype = guess_type(filename)[0] if filetype not in ALLOWED_MIMETYPES: frappe.throw(_("You can only upload JPG, PNG, PDF, or Microsoft documents.")) diff --git a/frappe/model/document.py b/frappe/model/document.py index 9a1fb775f7..ee12fd89e0 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -5,7 +5,7 @@ import time from frappe import _, msgprint, is_whitelisted from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff from frappe.model.base_document import BaseDocument, get_controller -from frappe.model.naming import set_new_name +from frappe.model.naming import set_new_name, gen_new_name_for_cancelled_doc from werkzeug.exceptions import NotFound, Forbidden import hashlib, json from frappe.model import optional_fields, table_fields @@ -710,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: @@ -921,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__ @@ -1065,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') diff --git a/frappe/model/naming.py b/frappe/model/naming.py index fe136adce8..7705002706 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -1,3 +1,14 @@ +"""utilities to generate a document name based on various rules defined. + +NOTE: +Till version 13, whenever a submittable document is amended it's name is set to orig_name-X, +where X is a counter and it increments when amended again and so on. + +From Version 14, The naming pattern is changed in a way that amended documents will +have the original name `orig_name` instead of `orig_name-X`. To make this happen +the cancelled document naming pattern is changed to 'orig_name-CANC-X'. +""" + # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt @@ -28,7 +39,7 @@ def set_new_name(doc): doc.name = None if getattr(doc, "amended_from", None): - _set_amended_name(doc) + doc.name = _get_amended_name(doc) return elif getattr(doc.meta, "issingle", False): @@ -221,6 +232,18 @@ def revert_series_if_last(key, name, doc=None): * prefix = #### and hashes = 2021 (hash doesn't exist) * will search hash in key then accordingly get prefix = "" """ + if hasattr(doc, 'amended_from'): + # Do not revert the series if the document is amended. + if doc.amended_from: + return + + # Get document name by parsing incase of fist cancelled document + if doc.docstatus == 2 and not doc.amended_from: + if doc.name.endswith('-CANC'): + name, _ = NameParser.parse_docname(doc.name, sep='-CANC') + else: + name, _ = NameParser.parse_docname(doc.name, sep='-CANC-') + if ".#" in key: prefix, hashes = key.rsplit(".", 1) if "#" not in hashes: @@ -303,16 +326,9 @@ def append_number_if_name_exists(doctype, value, fieldname="name", separator="-" return value -def _set_amended_name(doc): - am_id = 1 - am_prefix = doc.amended_from - if frappe.db.get_value(doc.doctype, doc.amended_from, "amended_from"): - am_id = cint(doc.amended_from.split("-")[-1]) + 1 - am_prefix = "-".join(doc.amended_from.split("-")[:-1]) # except the last hyphen - - doc.name = am_prefix + "-" + str(am_id) - return doc.name - +def _get_amended_name(doc): + name, _ = NameParser(doc).parse_amended_from() + return name def _field_autoname(autoname, doc, skip_slicing=None): """ @@ -323,7 +339,6 @@ def _field_autoname(autoname, doc, skip_slicing=None): name = (cstr(doc.get(fieldname)) or "").strip() return name - def _prompt_autoname(autoname, doc): """ Generate a name using Prompt option. This simply means the user will have to set the name manually. @@ -354,3 +369,83 @@ def _format_autoname(autoname, doc): name = re.sub(r"(\{[\w | #]+\})", get_param_value_for_match, autoname_value) return name + +class NameParser: + """Parse document name and return parts of it. + + NOTE: It handles cancellend and amended doc parsing for now. It can be expanded. + """ + def __init__(self, doc): + self.doc = doc + + def parse_amended_from(self): + """ + Cancelled document naming will be in one of these formats + + * original_name-X-CANC - This is introduced to migrate old style naming to new style + * original_name-CANC - This is introduced to migrate old style naming to new style + * original_name-CANC-X - This is the new style naming + + New style naming: In new style naming amended documents will have original name. That says, + when a document gets cancelled we need rename the document by adding `-CANC-X` to the end + so that amended documents can use the original name. + + Old style naming: cancelled documents stay with original name and when amended, amended one + gets a new name as `original_name-X`. To bring new style naming we had to change the existing + cancelled document names and that is done by adding `-CANC` to cancelled documents through patch. + """ + if not getattr(self.doc, 'amended_from', None): + return (None, None) + + # Handle old style cancelled documents (original_name-X-CANC, original_name-CANC) + if self.doc.amended_from.endswith('-CANC'): + name, _ = self.parse_docname(self.doc.amended_from, '-CANC') + amended_from_doc = frappe.get_all( + self.doc.doctype, + filters = {'name': self.doc.amended_from}, + fields = ['amended_from'], + limit=1) + + # Handle format original_name-X-CANC. + if amended_from_doc and amended_from_doc[0].amended_from: + return self.parse_docname(name, '-') + return name, None + + # Handle new style cancelled documents + return self.parse_docname(self.doc.amended_from, '-CANC-') + + @classmethod + def parse_docname(cls, name, sep='-'): + split_list = name.rsplit(sep, 1) + + if len(split_list) == 1: + return (name, None) + return (split_list[0], split_list[1]) + +def get_cancelled_doc_latest_counter(tname, docname): + """Get the latest counter used for cancelled docs of given docname. + """ + name_prefix = f'{docname}-CANC-' + + rows = frappe.db.sql(""" + select + name + from `tab{tname}` + where + name like %(name_prefix)s and docstatus=2 + """.format(tname=tname), {'name_prefix': name_prefix+'%'}, as_dict=1) + + if not rows: + return -1 + return max([int(row.name.replace(name_prefix, '') or -1) for row in rows]) + +def gen_new_name_for_cancelled_doc(doc): + """Generate a new name for cancelled document. + """ + if getattr(doc, "amended_from", None): + name, _ = NameParser(doc).parse_amended_from() + else: + name = doc.name + + counter = get_cancelled_doc_latest_counter(doc.doctype, name) + return f'{name}-CANC-{counter+1}' diff --git a/frappe/parallel_test_runner.py b/frappe/parallel_test_runner.py index 6265498c96..c7f723bbdc 100644 --- a/frappe/parallel_test_runner.py +++ b/frappe/parallel_test_runner.py @@ -15,10 +15,9 @@ if click_ctx: click_ctx.color = True class ParallelTestRunner(): - def __init__(self, app, site, build_number=1, total_builds=1, with_coverage=False): + def __init__(self, app, site, build_number=1, total_builds=1): self.app = app self.site = site - self.with_coverage = with_coverage self.build_number = frappe.utils.cint(build_number) or 1 self.total_builds = frappe.utils.cint(total_builds) self.setup_test_site() @@ -53,12 +52,9 @@ class ParallelTestRunner(): def run_tests(self): self.test_result = ParallelTestResult(stream=sys.stderr, descriptions=True, verbosity=2) - self.start_coverage() - for test_file_info in self.get_test_file_list(): self.run_tests_for_file(test_file_info) - self.save_coverage() self.print_result() def run_tests_for_file(self, file_info): @@ -107,28 +103,6 @@ class ParallelTestRunner(): if os.environ.get('CI'): sys.exit(1) - def start_coverage(self): - if self.with_coverage: - from coverage import Coverage - from frappe.utils import get_bench_path - from frappe.coverage import STANDARD_INCLUSIONS, STANDARD_EXCLUSIONS, FRAPPE_EXCLUSIONS - - # Generate coverage report only for app that is being tested - source_path = os.path.join(get_bench_path(), 'apps', self.app) - omit = STANDARD_EXCLUSIONS[:] - - if self.app == 'frappe': - omit.extend(FRAPPE_EXCLUSIONS) - - self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS) - self.coverage.start() - - def save_coverage(self): - if not self.with_coverage: - return - self.coverage.stop() - self.coverage.save() - def get_test_file_list(self): test_list = get_all_tests(self.app) split_size = frappe.utils.ceil(len(test_list) / self.total_builds) @@ -224,7 +198,7 @@ class ParallelTestWithOrchestrator(ParallelTestRunner): - get-next-test-spec (, ) - test-completed (, ) ''' - 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' diff --git a/frappe/patches.txt b/frappe/patches.txt index 493c4dc9f6..989b13e049 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -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 diff --git a/frappe/patches/v14_0/rename_cancelled_documents.py b/frappe/patches/v14_0/rename_cancelled_documents.py new file mode 100644 index 0000000000..fbe49c2351 --- /dev/null +++ b/frappe/patches/v14_0/rename_cancelled_documents.py @@ -0,0 +1,213 @@ +import functools +import traceback + +import frappe + +def execute(): + """Rename cancelled documents by adding a postfix. + """ + rename_cancelled_docs() + +def get_submittable_doctypes(): + """Returns list of submittable doctypes in the system. + """ + return frappe.db.get_all('DocType', filters={'is_submittable': 1}, pluck='name') + +def get_cancelled_doc_names(doctype): + """Return names of cancelled document names those are in old format. + """ + docs = frappe.db.get_all(doctype, filters={'docstatus': 2}, pluck='name') + return [each for each in docs if not (each.endswith('-CANC') or ('-CANC-' in each))] + +@functools.lru_cache() +def get_linked_doctypes(): + """Returns list of doctypes those are linked with given doctype using 'Link' fieldtype. + """ + filters=[['fieldtype','=', 'Link']] + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as linked_to"], + filters=filters, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as linked_to"], + filters=filters, + as_list=1) + + links_by_doctype = {} + for doctype, fieldname, linked_to in links: + links_by_doctype.setdefault(linked_to, []).append((doctype, fieldname)) + return links_by_doctype + +@functools.lru_cache() +def get_single_doctypes(): + return frappe.get_all("DocType", filters={'issingle': 1}, pluck='name') + +@functools.lru_cache() +def get_dynamic_linked_doctypes(): + filters=[['fieldtype','=', 'Dynamic Link']] + + # find dynamic links of parents + links = frappe.get_all("DocField", + fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters, + as_list=1) + links+= frappe.get_all("Custom Field", + fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters, + as_list=1) + return links + +@functools.lru_cache() +def get_child_tables(): + """ + """ + filters =[['fieldtype', 'in', ('Table', 'Table MultiSelect')]] + links = frappe.get_all("DocField", + fields=["parent as doctype", "options as child_table"], + filters=filters, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as doctype", "options as child_table"], + filters=filters, + as_list=1) + + map = {} + for doctype, child_table in links: + map.setdefault(doctype, []).append(child_table) + return map + +def update_cancelled_document_names(doctype, cancelled_doc_names): + return frappe.db.sql(""" + update + `tab{doctype}` + set + name=CONCAT(name, '-CANC') + where + docstatus=2 + and + name in %(cancelled_doc_names)s; + """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) + +def update_amended_field(doctype, cancelled_doc_names): + return frappe.db.sql(""" + update + `tab{doctype}` + set + amended_from=CONCAT(amended_from, '-CANC') + where + amended_from in %(cancelled_doc_names)s; + """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) + +def update_attachments(doctype, cancelled_doc_names): + frappe.db.sql(""" + update + `tabFile` + set + attached_to_name=CONCAT(attached_to_name, '-CANC') + where + attached_to_doctype=%(dt)s and attached_to_name in %(cancelled_doc_names)s + """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + +def update_versions(doctype, cancelled_doc_names): + frappe.db.sql(""" + UPDATE + `tabVersion` + SET + docname=CONCAT(docname, '-CANC') + WHERE + ref_doctype=%(dt)s AND docname in %(cancelled_doc_names)s + """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + +def update_linked_doctypes(doctype, cancelled_doc_names): + single_doctypes = get_single_doctypes() + + for linked_dt, field in get_linked_doctypes().get(doctype, []): + if linked_dt not in single_doctypes: + frappe.db.sql(""" + update + `tab{linked_dt}` + set + {column}=CONCAT({column}, '-CANC') + where + {column} in %(cancelled_doc_names)s; + """.format(linked_dt=linked_dt, column=field), + {'cancelled_doc_names': cancelled_doc_names}) + else: + doc = frappe.get_single(linked_dt) + if getattr(doc, field) in cancelled_doc_names: + setattr(doc, field, getattr(doc, field)+'-CANC') + doc.flags.ignore_mandatory=True + doc.flags.ignore_validate=True + doc.save(ignore_permissions=True) + +def update_dynamic_linked_doctypes(doctype, cancelled_doc_names): + single_doctypes = get_single_doctypes() + + for linked_dt, fieldname, doctype_fieldname in get_dynamic_linked_doctypes(): + if linked_dt not in single_doctypes: + frappe.db.sql(""" + update + `tab{linked_dt}` + set + {column}=CONCAT({column}, '-CANC') + where + {column} in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; + """.format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname), + {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + else: + doc = frappe.get_single(linked_dt) + if getattr(doc, doctype_fieldname) == doctype and getattr(doc, fieldname) in cancelled_doc_names: + setattr(doc, fieldname, getattr(doc, fieldname)+'-CANC') + doc.flags.ignore_mandatory=True + doc.flags.ignore_validate=True + doc.save(ignore_permissions=True) + +def update_child_tables(doctype, cancelled_doc_names): + child_tables = get_child_tables().get(doctype, []) + single_doctypes = get_single_doctypes() + + for table in child_tables: + if table not in single_doctypes: + frappe.db.sql(""" + update + `tab{table}` + set + parent=CONCAT(parent, '-CANC') + where + parenttype=%(dt)s and parent in %(cancelled_doc_names)s; + """.format(table=table), {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + else: + doc = frappe.get_single(table) + if getattr(doc, 'parenttype')==doctype and getattr(doc, 'parent') in cancelled_doc_names: + setattr(doc, 'parent', getattr(doc, 'parent')+'-CANC') + doc.flags.ignore_mandatory=True + doc.flags.ignore_validate=True + doc.save(ignore_permissions=True) + +def rename_cancelled_docs(): + submittable_doctypes = get_submittable_doctypes() + + for dt in submittable_doctypes: + for retry in range(2): + try: + cancelled_doc_names = tuple(get_cancelled_doc_names(dt)) + if not cancelled_doc_names: + break + update_cancelled_document_names(dt, cancelled_doc_names) + update_amended_field(dt, cancelled_doc_names) + update_child_tables(dt, cancelled_doc_names) + update_linked_doctypes(dt, cancelled_doc_names) + update_dynamic_linked_doctypes(dt, cancelled_doc_names) + update_attachments(dt, cancelled_doc_names) + update_versions(dt, cancelled_doc_names) + print(f"Renaming cancelled records of {dt} doctype") + frappe.db.commit() + break + except Exception: + if retry == 1: + print(f"Failed to rename the cancelled records of {dt} doctype, moving on!") + traceback.print_exc() + frappe.db.rollback() + diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index b7fc6f0a9c..0e8e24b768 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -703,4 +703,7 @@ + + + diff --git a/frappe/public/js/frappe/file_uploader/FilePreview.vue b/frappe/public/js/frappe/file_uploader/FilePreview.vue index cca7dfde2a..43dbacb17d 100644 --- a/frappe/public/js/frappe/file_uploader/FilePreview.vue +++ b/frappe/public/js/frappe/file_uploader/FilePreview.vue @@ -28,6 +28,7 @@ {{ file.file_obj.size | file_size }} +
- +
+ + +
@@ -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; +} diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 06f9275711..90aa545941 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -46,7 +46,7 @@
{{ __('Library') }}
-