Browse Source

Merge branch 'develop' of https://github.com/frappe/frappe into frappe-scanner

version-14
Saqib Ansari 3 years ago
parent
commit
e742719c56
33 changed files with 822 additions and 131 deletions
  1. BIN
      cypress/fixtures/sample_image.jpg
  2. +20
    -0
      cypress/integration/file_uploader.js
  3. +41
    -0
      cypress/integration/timeline.js
  4. +30
    -48
      frappe/commands/utils.py
  5. +5
    -3
      frappe/core/doctype/doctype/test_doctype.py
  6. +19
    -0
      frappe/core/doctype/file/file.js
  7. +27
    -3
      frappe/core/doctype/file/file.py
  8. +26
    -0
      frappe/coverage.py
  9. +17
    -3
      frappe/handler.py
  10. +10
    -4
      frappe/model/document.py
  11. +107
    -12
      frappe/model/naming.py
  12. +3
    -29
      frappe/parallel_test_runner.py
  13. +1
    -0
      frappe/patches.txt
  14. +213
    -0
      frappe/patches/v14_0/rename_cancelled_documents.py
  15. +3
    -0
      frappe/public/icons/timeless/symbol-defs.svg
  16. +37
    -2
      frappe/public/js/frappe/file_uploader/FilePreview.vue
  17. +42
    -3
      frappe/public/js/frappe/file_uploader/FileUploader.vue
  18. +80
    -0
      frappe/public/js/frappe/file_uploader/ImageCropper.vue
  19. +20
    -0
      frappe/public/js/frappe/file_uploader/index.js
  20. +12
    -2
      frappe/public/js/frappe/form/controls/attach.js
  21. +14
    -10
      frappe/public/js/frappe/form/form.js
  22. +1
    -1
      frappe/public/js/frappe/form/sidebar/user_image.js
  23. +6
    -0
      frappe/public/js/frappe/router.js
  24. +1
    -0
      frappe/public/scss/desk/form.scss
  25. BIN
      frappe/tests/data/sample_image_for_optimization.jpg
  26. +34
    -0
      frappe/tests/test_naming.py
  27. +16
    -2
      frappe/tests/test_utils.py
  28. +1
    -1
      frappe/utils/data.py
  29. +11
    -3
      frappe/utils/file_manager.py
  30. +18
    -4
      frappe/utils/image.py
  31. +1
    -1
      frappe/utils/pdf.py
  32. +1
    -0
      package.json
  33. +5
    -0
      yarn.lock

BIN
cypress/fixtures/sample_image.jpg View File

Before After
Width: 1920  |  Height: 1281  |  Size: 244 KiB

+ 20
- 0
cypress/integration/file_uploader.js View File

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

+ 41
- 0
cypress/integration/timeline.js View File

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

+ 30
- 48
frappe/commands/utils.py View File

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


+ 5
- 3
frappe/core/doctype/doctype/test_doctype.py View File

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


+ 19
- 0
frappe/core/doctype/file/file.js View File

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


+ 27
- 3
frappe/core/doctype/file/file.py View File

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


+ 26
- 0
frappe/coverage.py View File

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

+ 17
- 3
frappe/handler.py View File

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



+ 10
- 4
frappe/model/document.py View File

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



+ 107
- 12
frappe/model/naming.py View File

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

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

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

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

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

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

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

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

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


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

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

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

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


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

return name

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 3
- 29
frappe/parallel_test_runner.py View File

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

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

self.start_coverage()

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

self.save_coverage()
self.print_result()

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

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

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

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

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

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

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

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

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


+ 1
- 0
frappe/patches.txt View File

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

+ 213
- 0
frappe/patches/v14_0/rename_cancelled_documents.py View File

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


+ 3
- 0
frappe/public/icons/timeless/symbol-defs.svg View File

@@ -703,4 +703,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>

+ 37
- 2
frappe/public/js/frappe/file_uploader/FilePreview.vue View File

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

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

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

.muted:hover {
opacity: 1;
}

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

+ 42
- 3
frappe/public/js/frappe/file_uploader/FileUploader.vue View File

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

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

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

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

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


+ 80
- 0
frappe/public/js/frappe/file_uploader/ImageCropper.vue View File

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

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

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

+ 20
- 0
frappe/public/js/frappe/file_uploader/index.js View File

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

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

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

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

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

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

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


+ 12
- 2
frappe/public/js/frappe/form/controls/attach.js View File

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


+ 14
- 10
frappe/public/js/frappe/form/form.js View File

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

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

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

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

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


+ 1
- 1
frappe/public/js/frappe/form/sidebar/user_image.js View File

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


+ 6
- 0
frappe/public/js/frappe/router.js View File

@@ -232,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]);


+ 1
- 0
frappe/public/scss/desk/form.scss View File

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

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


BIN
frappe/tests/data/sample_image_for_optimization.jpg View File

Before After
Width: 1920  |  Height: 1281  |  Size: 244 KiB

+ 34
- 0
frappe/tests/test_naming.py View File

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

+ 16
- 2
frappe/tests/test_utils.py View File

@@ -9,8 +9,9 @@ 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):
@@ -190,6 +191,19 @@ class TestImage(unittest.TestCase):
self.assertEqual(new_image._getexif(), None)
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):
@@ -215,4 +229,4 @@ class TestPythonExpressions(unittest.TestCase):
"oops = forgot_equals",
]
for expr in invalid_expressions:
self.assertRaises(frappe.ValidationError, validate_python_code, expr)
self.assertRaises(frappe.ValidationError, validate_python_code, expr)

+ 1
- 1
frappe/utils/data.py View File

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


+ 11
- 3
frappe/utils/file_manager.py View File

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

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

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

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

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

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



+ 18
- 4
frappe/utils/image.py View File

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

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

from PIL import Image
import io

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

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

content = output.getvalue()

return content
return content

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

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

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

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

+ 1
- 1
frappe/utils/pdf.py View File

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


+ 1
- 0
package.json View File

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


+ 5
- 0
yarn.lock View File

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

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

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


Loading…
Cancel
Save