Merge branch 'develop' of https://github.com/frappe/frappe into frappe-scanner
This commit is contained in:
commit
e742719c56
33 changed files with 822 additions and 131 deletions
BIN
cypress/fixtures/sample_image.jpg
Normal file
BIN
cypress/fixtures/sample_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
|
|
@ -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
|
||||
with CodeCoverage(coverage, app):
|
||||
import frappe.test_runner
|
||||
tests = test
|
||||
site = get_site(context)
|
||||
|
||||
site = get_site(context)
|
||||
allow_tests = frappe.get_conf(site).allow_tests
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
frappe.init(site=site)
|
||||
|
||||
frappe.init(site=site)
|
||||
frappe.flags.skip_before_tests = skip_before_tests
|
||||
frappe.flags.skip_test_records = skip_test_records
|
||||
|
||||
frappe.flags.skip_before_tests = skip_before_tests
|
||||
frappe.flags.skip_test_records = skip_test_records
|
||||
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:
|
||||
from coverage import Coverage
|
||||
from frappe.coverage import STANDARD_INCLUSIONS, STANDARD_EXCLUSIONS, FRAPPE_EXCLUSIONS
|
||||
if len(ret.failures) == 0 and len(ret.errors) == 0:
|
||||
ret = 0
|
||||
|
||||
# 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[:]
|
||||
|
||||
if not app or app == 'frappe':
|
||||
omit.extend(FRAPPE_EXCLUSIONS)
|
||||
|
||||
cov = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
|
||||
cov.start()
|
||||
|
||||
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
|
||||
|
|
|
|||
213
frappe/patches/v14_0/rename_cancelled_documents.py
Normal file
213
frappe/patches/v14_0/rename_cancelled_documents.py
Normal 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()
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
|
|
|
|||
80
frappe/public/js/frappe/file_uploader/ImageCropper.vue
Normal file
80
frappe/public/js/frappe/file_uploader/ImageCropper.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
BIN
frappe/tests/data/sample_image_for_optimization.jpg
Normal file
BIN
frappe/tests/data/sample_image_for_optimization.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue