@@ -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'); | |||
}); | |||
}); |
@@ -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'); | |||
}); | |||
}); |
@@ -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') | |||
@@ -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) | |||
@@ -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 | |||
@@ -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): | |||
@@ -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() |
@@ -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.")) | |||
@@ -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') | |||
@@ -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() | |||
@@ -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> |
@@ -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, | |||
@@ -770,32 +770,36 @@ frappe.ui.form.Form = class FrappeForm { | |||
} | |||
_cancel(btn, callback, on_error, skip_confirm) { | |||
const me = this; | |||
const cancel_doc = () => { | |||
frappe.validated = true; | |||
me.script_manager.trigger("before_cancel").then(() => { | |||
this.script_manager.trigger("before_cancel").then(() => { | |||
if (!frappe.validated) { | |||
return me.handle_save_fail(btn, on_error); | |||
return this.handle_save_fail(btn, on_error); | |||
} | |||
var after_cancel = function(r) { | |||
const original_name = this.docname; | |||
const after_cancel = (r) => { | |||
if (r.exc) { | |||
me.handle_save_fail(btn, on_error); | |||
this.handle_save_fail(btn, on_error); | |||
} else { | |||
frappe.utils.play_sound("cancel"); | |||
me.refresh(); | |||
callback && callback(); | |||
me.script_manager.trigger("after_cancel"); | |||
this.script_manager.trigger("after_cancel"); | |||
frappe.run_serially([ | |||
() => this.rename_notify(this.doctype, original_name, r.docs[0].name), | |||
() => frappe.router.clear_re_route(this.doctype, original_name), | |||
() => this.refresh(), | |||
]); | |||
} | |||
}; | |||
frappe.ui.form.save(me, "cancel", after_cancel, btn); | |||
frappe.ui.form.save(this, "cancel", after_cancel, btn); | |||
}); | |||
} | |||
if (skip_confirm) { | |||
cancel_doc(); | |||
} else { | |||
frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, me.handle_save_fail(btn, on_error)); | |||
frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, this.handle_save_fail(btn, on_error)); | |||
} | |||
}; | |||
@@ -817,7 +821,7 @@ frappe.ui.form.Form = class FrappeForm { | |||
'docname': this.doc.name | |||
}).then(is_amended => { | |||
if (is_amended) { | |||
frappe.throw(__('This document is already amended, you cannot ammend it again')); | |||
frappe.throw(__('This document is already amended, you cannot amend it again')); | |||
} | |||
this.validate_form_action("Amend"); | |||
var me = this; | |||
@@ -83,7 +83,7 @@ frappe.ui.form.setup_user_image_event = function(frm) { | |||
if(!field.$input) { | |||
field.make_input(); | |||
} | |||
field.$input.trigger('click'); | |||
field.$input.trigger('attach_doc_image'); | |||
} else { | |||
/// on remove event for a sidebar image wrapper remove attach file. | |||
frm.attachments.remove_attachment_by_filename(frm.doc[frm.meta.image_field], function() { | |||
@@ -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,4 +1,5 @@ | |||
@import "../common/form.scss"; | |||
@import '~cropperjs/dist/cropper.min'; | |||
.form-section, .form-dashboard-section { | |||
margin: 0px; | |||
@@ -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() |
@@ -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) |
@@ -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,7 +11,7 @@ from frappe import _ | |||
from frappe import conf | |||
from copy import copy | |||
from urllib.parse import unquote | |||
from frappe.utils.image import optimize_image | |||
class MaxFileSizeReachedError(frappe.ValidationError): | |||
pass | |||
@@ -386,6 +386,15 @@ def extract_images_from_html(doc, content): | |||
data = match.group(1) | |||
data = data.split("data:")[1] | |||
headers, content = data.split(",") | |||
mtype = headers.split(";")[0] | |||
if isinstance(content, str): | |||
content = content.encode("utf-8") | |||
if b"," in content: | |||
content = content.split(b",")[1] | |||
content = base64.b64decode(content) | |||
content = optimize_image(content, mtype) | |||
if "filename=" in headers: | |||
filename = headers.split("filename=")[-1] | |||
@@ -394,7 +403,6 @@ def extract_images_from_html(doc, content): | |||
if not isinstance(filename, str): | |||
filename = str(filename, 'utf-8') | |||
else: | |||
mtype = headers.split(";")[0] | |||
filename = get_random_filename(content_type=mtype) | |||
doctype = doc.parenttype if doc.parent else doc.doctype | |||
@@ -405,7 +413,7 @@ def extract_images_from_html(doc, content): | |||
name = doc.reference_name | |||
# TODO fix this | |||
file_url = save_file(filename, content, doctype, name, decode=True).get("file_url") | |||
file_url = save_file(filename, content, doctype, name, decode=False).get("file_url") | |||
if not frappe.flags.has_dataurl: | |||
frappe.flags.has_dataurl = True | |||
@@ -1,6 +1,8 @@ | |||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | |||
# MIT License. See license.txt | |||
import os | |||
from PIL import Image | |||
import io | |||
def resize_images(path, maxdim=700): | |||
from PIL import Image | |||
@@ -26,9 +28,6 @@ def strip_exif_data(content, content_type): | |||
Bytes: Stripped image content | |||
""" | |||
from PIL import Image | |||
import io | |||
original_image = Image.open(io.BytesIO(content)) | |||
output = io.BytesIO() | |||
@@ -38,4 +37,19 @@ def strip_exif_data(content, content_type): | |||
content = output.getvalue() | |||
return content | |||
return content | |||
def optimize_image(content, content_type, max_width=1920, max_height=1080, optimize=True, quality=85): | |||
if content_type == 'image/svg+xml': | |||
return content | |||
image = Image.open(io.BytesIO(content)) | |||
image_format = content_type.split('/')[1] | |||
size = max_width, max_height | |||
image.thumbnail(size, Image.LANCZOS) | |||
output = io.BytesIO() | |||
image.save(output, format=image_format, optimize=optimize, quality=quality, save_all=True if image_format=='gif' else None) | |||
optimized_content = output.getvalue() | |||
return optimized_content if len(optimized_content) < len(content) else content |
@@ -179,7 +179,7 @@ def prepare_header_footer(soup): | |||
"html_id": html_id, | |||
"css": css, | |||
"lang": frappe.local.lang, | |||
"layout_direction": "rtl" if is_rtl else "ltr" | |||
"layout_direction": "rtl" if is_rtl() else "ltr" | |||
}) | |||
# create temp file | |||
@@ -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", | |||
@@ -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" | |||