Merge branch 'develop' of github.com:frappe/frappe into at/safeqb

This commit is contained in:
Gavin D'souza 2021-10-11 12:37:30 +05:30
commit 4e7be5b3ec
54 changed files with 381 additions and 131 deletions

View file

@ -59,4 +59,4 @@ cd ../..
bench start &
bench --site test_site reinstall --yes
if [ "$TYPE" == "server" ]; then bench --site test_site_producer reinstall --yes; fi
bench build --app frappe
CI=Yes bench build --app frappe

View file

@ -29,7 +29,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
python-version: '3.9'
- name: Check if build should be run
id: check-build

View file

@ -18,7 +18,7 @@ jobs:
node-version: 14
- uses: actions/setup-python@v2
with:
python-version: '3.7'
python-version: '3.9'
- name: Set up bench and build assets
run: |
npm install -g yarn

View file

@ -21,7 +21,7 @@ jobs:
python-version: '12.x'
- uses: actions/setup-python@v2
with:
python-version: '3.7'
python-version: '3.9'
- name: Set up bench and build assets
run: |
npm install -g yarn

View file

@ -38,7 +38,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
python-version: '3.9'
- name: Check if build should be run
id: check-build
@ -127,4 +127,5 @@ jobs:
name: MariaDB
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
verbose: true
flags: server

View file

@ -41,7 +41,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
python-version: '3.9'
- name: Check if build should be run
id: check-build
@ -131,3 +131,4 @@ jobs:
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
flags: server

View file

@ -37,7 +37,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
python-version: '3.9'
- name: Check if build should be run
id: check-build
@ -122,12 +122,36 @@ jobs:
DB: mariadb
TYPE: ui
- name: Instrument Source Code
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe
- name: Build
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench build --apps frappe
- name: Site Setup
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard
- name: UI Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --headless --parallel --ci-build-id $GITHUB_RUN_ID
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID
env:
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb
- name: Check If Coverage Report Exists
id: check_coverage
uses: andstor/file-existence-action@v1
with:
files: "/home/runner/frappe-bench/apps/frappe/.cypress-coverage/clover.xml"
- name: Upload Coverage Data
if: ${{ steps.check-build.outputs.build == 'strawberry' && steps.check_coverage.outputs.files_exists == 'true' }}
uses: codecov/codecov-action@v2
with:
name: Cypress
fail_ci_if_error: true
directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/
verbose: true
flags: ui-tests

1
.gitignore vendored
View file

@ -67,6 +67,7 @@ coverage.xml
*.cover
.hypothesis/
.pytest_cache/
.cypress-coverage
# Translations
*.mo

View file

@ -27,7 +27,7 @@
<img src='https://www.codetriage.com/frappe/frappe/badges/users.svg'>
</a>
<a href="https://codecov.io/gh/frappe/frappe">
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/>
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj&flag=server"/>
</a>
</div>
@ -35,25 +35,29 @@
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com)
### Table of Contents
* [Installation](https://frappeframework.com/docs/user/en/installation)
* [Documentation](https://frappeframework.com/docs)
## Table of Contents
* [Installation](#installation)
* [Contributing](#contributing)
* [Resources](#resources)
* [License](#license)
### Installation
## Installation
* [Install via Docker](https://github.com/frappe/frappe_docker)
* [Install via Frappe Bench](https://github.com/frappe/bench)
* [Offical Documentation](https://frappeframework.com/docs/user/en/installation)
## Contributing
1. [Code of Conduct](CODE_OF_CONDUCT.md)
1. [Contribution Guidelines](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
1. [Security Policy](SECURITY.md)
1. [Translations](https://translate.erpnext.com)
### Website
## Resources
For details and documentation, see the website
[https://frappeframework.com](https://frappeframework.com)
1. [frappeframework.com](https://frappeframework.com) - Official documentation of the Frappe Framework.
1. [frappe.school](https://frappe.school) - Pick from the various courses by the maintainers or from the community.
### License
## License
This repository has been released under the [MIT License](LICENSE).

View file

@ -4,10 +4,28 @@ codecov:
coverage:
status:
project:
default:
default: false
server:
target: auto
threshold: 0.5%
flags:
- server
ui-tests:
target: auto
threshold: 0.5%
flags:
- ui-tests
comment:
layout: "diff"
layout: "diff, flags"
require_changes: true
flags:
server:
paths:
- ".*\\.py"
carryforward: true
ui-tests:
paths:
- ".*\\.js"
carryforward: true

View file

@ -54,13 +54,12 @@ context('Dashboard links', () => {
cur_frm.dashboard.data.reports = [
{
'label': 'Reports',
'items': ['Permitted Documents For User']
'items': ['Website Analytics']
}
];
cur_frm.dashboard.render_report_links();
cy.get('[data-report="Permitted Documents For User"]').contains('Permitted Documents For User').click();
cy.findByText('Permitted Documents For User');
cy.findByPlaceholderText('User').should("have.value", "Administrator");
cy.get('[data-report="Website Analytics"]').contains('Website Analytics').click();
cy.findByText('Website Analytics');
});
});
});

View file

@ -44,13 +44,14 @@ context('Timeline', () => {
cy.get('.timeline-content').should('contain', 'Testing Timeline 123');
//Deleting the added comment
cy.get('.actions > .btn > .icon').first().click();
cy.get('.more-actions > .action-btn').click();
cy.get('.more-actions .dropdown-item').contains('Delete').click();
cy.findByRole('button', {name: 'Yes'}).click();
cy.click_modal_primary_button('Yes');
//Deleting the added ToDo
cy.get('.menu-btn-group button').eq(1).click();
cy.get('.menu-btn-group [data-label="Delete"]').click();
cy.get('.menu-btn-group [data-original-title="Menu"]').click();
cy.get('.menu-btn-group .dropdown-item').contains('Delete').click();
cy.findByRole('button', {name: 'Yes'}).click();
});

View file

@ -11,7 +11,7 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = () => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};
module.exports = (on, config) => {
require('@cypress/code-coverage/task')(on, config);
return config;
};

View file

@ -353,5 +353,5 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
});
Cypress.Commands.add('click_timeline_action_btn', (btn_name) => {
cy.get('.timeline-content > .timeline-message-box > .justify-between > .actions > .btn').contains(btn_name).click();
cy.get('.timeline-message-box .custom-actions > .btn').contains(btn_name).click();
});

View file

@ -15,6 +15,7 @@
// Import commands.js using ES2015 syntax:
import './commands';
import '@cypress/code-coverage/support';
// Alternatively you can use CommonJS syntax:

View file

@ -104,6 +104,9 @@ async function execute() {
log_error("There were some problems during build");
log();
log(chalk.dim(e.stack));
if (process.env.CI) {
process.kill(process.pid);
}
return;
}
@ -528,4 +531,4 @@ function log_rebuilt_assets(prev_assets, new_assets) {
log(" " + filename);
}
log();
}
}

View file

@ -246,7 +246,7 @@ def bundle(mode, apps=None, hard_link=False, make_copy=False, restore=False, ver
check_node_executable()
frappe_app_path = frappe.get_app_path("frappe", "..")
frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env(), raise_err=True)
def watch(apps=None):

View file

@ -679,9 +679,10 @@ def run_parallel_tests(context, app, build_number, total_builds, with_coverage=F
@click.argument('app')
@click.option('--headless', is_flag=True, help="Run UI Test in headless mode")
@click.option('--parallel', is_flag=True, help="Run UI Test in parallel mode")
@click.option('--with-coverage', is_flag=True, help="Generate coverage report")
@click.option('--ci-build-id')
@pass_context
def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=False, ci_build_id=None):
"Run UI tests"
site = get_site(context)
app_base_path = os.path.abspath(os.path.join(frappe.get_app_path(app), '..'))
@ -691,6 +692,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
# override baseUrl using env variable
site_env = f'CYPRESS_baseUrl={site_url}'
password_env = f'CYPRESS_adminPassword={admin_password}' if admin_password else ''
coverage_env = f'CYPRESS_coverage={str(with_coverage).lower()}'
os.chdir(app_base_path)
@ -698,22 +700,23 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
cypress_path = f"{node_bin}/cypress"
plugin_path = f"{node_bin}/../cypress-file-upload"
testing_library_path = f"{node_bin}/../@testing-library"
coverage_plugin_path = f"{node_bin}/../@cypress/code-coverage"
# check if cypress in path...if not, install it.
if not (
os.path.exists(cypress_path)
and os.path.exists(plugin_path)
and os.path.exists(testing_library_path)
and os.path.exists(coverage_plugin_path)
and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6
):
# install cypress
click.secho("Installing Cypress...", fg="yellow")
frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 --no-lockfile")
frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile")
# run for headless mode
run_or_open = 'run --browser firefox --record' if headless else 'open'
command = '{site_env} {password_env} {cypress} {run_or_open}'
formatted_command = command.format(site_env=site_env, password_env=password_env, cypress=cypress_path, run_or_open=run_or_open)
formatted_command = f'{site_env} {password_env} {coverage_env} {cypress_path} {run_or_open}'
if parallel:
formatted_command += ' --parallel'

View file

@ -813,7 +813,7 @@ def extract_images_from_doc(doc, fieldname):
doc.set(fieldname, content)
def extract_images_from_html(doc, content):
def extract_images_from_html(doc, content, is_private=False):
frappe.flags.has_dataurl = False
def _save_file(match):
@ -846,7 +846,8 @@ def extract_images_from_html(doc, content):
"attached_to_doctype": doctype,
"attached_to_name": name,
"content": content,
"decode": False
"decode": False,
"is_private": is_private
})
_file.save(ignore_permissions=True)
file_url = _file.file_url

View file

@ -94,7 +94,7 @@ class ServerScript(Document):
Args:
doc (Document): Executes script with for a certain document's events
"""
safe_exec(self.script, _locals={"doc": doc})
safe_exec(self.script, _locals={"doc": doc}, restrict_commit_rollback=True)
def execute_scheduled_method(self):
"""Specific to Scheduled Jobs via Server Scripts

View file

@ -59,6 +59,26 @@ conditions = '1 = 1'
reference_doctype = 'Note',
script = '''
frappe.method_that_doesnt_exist("do some magic")
'''
),
dict(
name='test_todo_commit',
script_type = 'DocType Event',
doctype_event = 'Before Save',
reference_doctype = 'ToDo',
disabled = 1,
script = '''
frappe.db.commit()
'''
),
dict(
name='test_cache_methods',
script_type = 'DocType Event',
doctype_event = 'Before Save',
reference_doctype = 'ToDo',
disabled = 1,
script = '''
frappe.cache().set_value('test_key', doc.name)
'''
)
]
@ -119,3 +139,24 @@ class TestServerScript(unittest.TestCase):
self.assertTrue("invalid python code" in str(se.exception).lower(),
msg="Python code validation not working")
def test_commit_in_doctype_event(self):
server_script = frappe.get_doc('Server Script', 'test_todo_commit')
server_script.disabled = 0
server_script.save()
self.assertRaises(AttributeError, frappe.get_doc(dict(doctype='ToDo', description='test me')).insert)
server_script.disabled = 1
server_script.save()
def test_cache_methods_in_server_script(self):
server_script = frappe.get_doc('Server Script', 'test_cache_methods')
server_script.disabled = 0
server_script.save()
todo = frappe.get_doc(dict(doctype='ToDo', description='test me')).insert()
self.assertEqual(todo.name, frappe.cache().get_value('test_key'))
server_script.disabled = 1
server_script.save()

View file

@ -325,15 +325,15 @@ frappe.PermissionEngine = class PermissionEngine {
.attr("data-doctype", d.parent)
.attr("data-role", d.role)
.attr("data-permlevel", d.permlevel)
.click(function () {
.on("click", () => {
return frappe.call({
module: "frappe.core",
page: "permission_manager",
method: "remove",
args: {
doctype: $(this).attr("data-doctype"),
role: $(this).attr("data-role"),
permlevel: $(this).attr("data-permlevel")
doctype: d.parent,
role: d.role,
permlevel: d.permlevel
},
callback: (r) => {
if (r.exc) {

View file

@ -20,6 +20,7 @@ from frappe.query_builder.functions import Count
from frappe.query_builder.functions import Min, Max, Avg, Sum
from frappe.query_builder.utils import Column
from .query import Query
from pypika.terms import PseudoColumn
class Database(object):
@ -109,15 +110,14 @@ class Database(object):
{"name": "a%", "owner":"test@example.com"})
"""
query = str(query)
if frappe.flags.in_safe_exec:
if not query.strip().lower().startswith('select'):
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')
if frappe.flags.in_safe_exec and not query.strip().lower().startswith('select'):
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')
if not run:
return query
if re.search(r'ifnull\(', query, flags=re.IGNORECASE):
# replaces ifnull in query with coalesce
query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE)
@ -527,11 +527,21 @@ class Database(object):
return self.get_single_value(*args, **kwargs)
def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, update=None, for_update=False):
field_objects = []
for field in fields:
if "(" in field or " as " in field:
field_objects.append(PseudoColumn(field))
else:
field_objects.append(field)
criterion = self.query.build_conditions(table=doctype, filters=filters, orderby=order_by, for_update=for_update)
if isinstance(fields, (list, tuple)):
query = self.query.build_conditions(table=doctype, filters=filters, orderby=order_by, for_update=for_update).select(*fields)
query = criterion.select(*field_objects)
else:
if fields=="*":
query = self.query.build_conditions(table=doctype, filters=filters, orderby=order_by, for_update=for_update).select(fields)
query = criterion.select(fields)
as_dict = True
r = self.sql(query, as_dict=as_dict, debug=debug, update=update)

View file

@ -66,7 +66,7 @@ def add_comment(reference_doctype, reference_name, content, comment_email, comme
comment_type='Comment',
comment_by=comment_by
))
doc.content = extract_images_from_html(doc, content)
doc.content = extract_images_from_html(doc, content, is_private=True)
doc.insert(ignore_permissions=True)
follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user)

View file

@ -274,4 +274,7 @@ class TestNotification(unittest.TestCase):
self.assertTrue('test2@example.com' in recipients)
self.assertTrue('test1@example.com' in recipients)
@classmethod
def tearDownClass(cls):
frappe.delete_doc_if_exists("Notification", "ToDo Status Update")
frappe.delete_doc_if_exists("Notification", "Contact Status Update")

View file

@ -29,6 +29,10 @@ def _new_site(
):
"""Install a new Frappe site"""
from frappe.commands.scheduler import _is_scheduler_enabled
from frappe.utils import get_site_path, scheduler, touch_file
if not force and os.path.exists(site):
print("Site {0} already exists".format(site))
sys.exit(1)
@ -37,14 +41,11 @@ def _new_site(
print("--no-mariadb-socket requires db_type to be set to mariadb.")
sys.exit(1)
if not db_name:
import hashlib
db_name = "_" + hashlib.sha1(site.encode()).hexdigest()[:16]
frappe.init(site=site)
from frappe.commands.scheduler import _is_scheduler_enabled
from frappe.utils import get_site_path, scheduler, touch_file
if not db_name:
import hashlib
db_name = "_" + hashlib.sha1(os.path.realpath(frappe.get_site_path()).encode()).hexdigest()[:16]
try:
# enable scheduler post install?
@ -455,9 +456,21 @@ def convert_archive_content(sql_file_path):
if frappe.conf.db_type == "mariadb":
# ever since mariaDB 10.6, row_format COMPRESSED has been deprecated and removed
# this step is added to ease restoring sites depending on older mariaDB servers
contents = open(sql_file_path).read()
with open(sql_file_path, "w") as f:
f.write(contents.replace("ROW_FORMAT=COMPRESSED", "ROW_FORMAT=DYNAMIC"))
from frappe.utils import random_string
from pathlib import Path
old_sql_file_path = Path(f"{sql_file_path}_{random_string(10)}")
sql_file_path = Path(sql_file_path)
os.rename(sql_file_path, old_sql_file_path)
sql_file_path.unlink(missing_ok=True)
sql_file_path.touch()
with open(old_sql_file_path) as r, open(sql_file_path, "a") as w:
for line in r:
w.write(line.replace("ROW_FORMAT=COMPRESSED", "ROW_FORMAT=DYNAMIC"))
old_sql_file_path.unlink(missing_ok=True)
def extract_sql_gzip(sql_gz_path):

View file

@ -267,7 +267,12 @@ class BaseDocument(object):
if isinstance(d[fieldname], list) and df.fieldtype not in table_fields:
frappe.throw(_('Value for {0} cannot be a list').format(_(df.label)))
if convert_dates_to_str and isinstance(d[fieldname], (datetime.datetime, datetime.time, datetime.timedelta)):
if convert_dates_to_str and isinstance(d[fieldname], (
datetime.datetime,
datetime.date,
datetime.time,
datetime.timedelta
)):
d[fieldname] = str(d[fieldname])
if d[fieldname] == None and ignore_nulls:

View file

@ -41,10 +41,11 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-09-17 11:30:16.781655",
"modified": "2021-10-07 11:23:13.799402",
"modified_by": "Administrator",
"module": "Printing",
"name": "Network Printer Settings",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@ -58,6 +59,15 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1
}
],
"sort_field": "modified",

View file

@ -177,7 +177,7 @@ frappe.ui.form.PrintView = class {
);
}
if (this.print_settings.enable_print_server) {
if (cint(this.print_settings.enable_print_server)) {
this.page.add_menu_item(__('Select Network Printer'), () =>
this.network_printer_setting_dialog()
);
@ -464,7 +464,7 @@ frappe.ui.form.PrintView = class {
printit() {
let me = this;
if (me.print_settings.enable_print_server) {
if (cint(me.print_settings.enable_print_server)) {
if (localStorage.getItem('network_printer')) {
me.print_by_server();
} else {

View file

@ -97,9 +97,13 @@ class BaseTimeline {
}
timeline_item.append(`<div class="timeline-content ${item.is_card ? 'frappe-card' : ''}">`);
timeline_item.find('.timeline-content').append(item.content);
let timeline_content = timeline_item.find('.timeline-content');
timeline_content.append(item.content);
if (!item.hide_timestamp && !item.is_card) {
timeline_item.find('.timeline-content').append(`<span> - ${comment_when(item.creation)}</span>`);
timeline_content.append(`<span> - ${comment_when(item.creation)}</span>`);
}
if (item.id) {
timeline_content.attr("id", item.id);
}
return timeline_item;
}

View file

@ -96,6 +96,7 @@ class FormTimeline extends BaseTimeline {
render_timeline_items() {
super.render_timeline_items();
this.set_document_info();
frappe.utils.bind_actions_with_object(this.timeline_items_wrapper, this);
}
set_document_info() {
@ -179,6 +180,7 @@ class FormTimeline extends BaseTimeline {
is_card: true,
content: this.get_communication_timeline_content(communication),
doctype: "Communication",
id: `communication-${communication.name}`,
name: communication.name
});
});
@ -246,6 +248,7 @@ class FormTimeline extends BaseTimeline {
creation: comment.creation,
is_card: true,
doctype: "Comment",
id: `comment-${comment.name}`,
name: comment.name,
content: this.get_comment_timeline_content(comment),
};
@ -394,7 +397,7 @@ class FormTimeline extends BaseTimeline {
}
setup_reply(communication_box, communication_doc) {
let actions = communication_box.find('.actions');
let actions = communication_box.find('.custom-actions');
let reply = $(`<a class="action-btn reply">${frappe.utils.icon('reply', 'md')}</a>`).click(() => {
this.compose_mail(communication_doc);
});
@ -446,14 +449,16 @@ class FormTimeline extends BaseTimeline {
let edit_wrapper = $(`<div class="comment-edit-box">`).hide();
let edit_box = this.make_editable(edit_wrapper);
let content_wrapper = comment_wrapper.find('.content');
let delete_button = $();
let more_actions_wrapper = comment_wrapper.find('.more-actions');
if (frappe.model.can_delete("Comment")) {
delete_button = $(`
<button class="btn btn-link action-btn">
${frappe.utils.icon('close', 'sm')}
</button>
const delete_option = $(`
<li>
<a class="dropdown-item">
${__("Delete")}
</a>
</li>
`).click(() => this.delete_comment(doc.name));
more_actions_wrapper.find('.dropdown-menu').append(delete_option);
}
let dismiss_button = $(`
@ -493,15 +498,14 @@ class FormTimeline extends BaseTimeline {
edit_button.toggle_edit_mode = () => {
edit_button.edit_mode = !edit_button.edit_mode;
edit_button.text(edit_button.edit_mode ? __('Save') : __('Edit'));
delete_button.toggle(!edit_button.edit_mode);
more_actions_wrapper.toggle(!edit_button.edit_mode);
dismiss_button.toggle(edit_button.edit_mode);
edit_wrapper.toggle(edit_button.edit_mode);
content_wrapper.toggle(!edit_button.edit_mode);
};
comment_wrapper.find('.actions').append(edit_button);
comment_wrapper.find('.actions').append(dismiss_button);
comment_wrapper.find('.actions').append(delete_button);
let actions_wrapper = comment_wrapper.find('.custom-actions');
actions_wrapper.append(edit_button);
actions_wrapper.append(dismiss_button);
}
make_editable(container) {
@ -559,6 +563,14 @@ class FormTimeline extends BaseTimeline {
});
});
}
copy_link(ev) {
let doc_link = frappe.urllib.get_full_url(
frappe.utils.get_form_link(this.frm.doctype, this.frm.docname)
);
let element_id = $(ev.currentTarget).closest(".timeline-content").attr("id");
frappe.utils.copy_to_clipboard(`${doc_link}#${element_id}`);
}
}
export default FormTimeline;

View file

@ -480,7 +480,11 @@ frappe.ui.form.Form = class FrappeForm {
this.layout.show_empty_form_message();
}
this.scroll_to_element();
frappe.after_ajax(() => {
$(document).ready(() => {
this.scroll_to_element();
});
});
}
set_first_tab_as_active() {
@ -598,6 +602,8 @@ frappe.ui.form.Form = class FrappeForm {
this.validate_form_action(save_action, resolve);
var after_save = function(r) {
// to remove hash from URL to avoid scroll after save
history.replaceState(null, null, ' ');
if(!r.exc) {
if (["Save", "Update", "Amend"].indexOf(save_action)!==-1) {
frappe.utils.play_sound("click");
@ -1195,6 +1201,8 @@ frappe.ui.form.Form = class FrappeForm {
if (selector.length) {
frappe.utils.scroll_to(selector);
}
} else if (window.location.hash && $(window.location.hash).length) {
frappe.utils.scroll_to(window.location.hash, true, 200, null, null, true);
}
}

View file

@ -773,16 +773,18 @@ export default class Grid {
}
setup_user_defined_columns() {
let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView');
if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) {
this.user_defined_columns = user_settings[this.doctype].map(row => {
let column = frappe.meta.get_docfield(this.doctype, row.fieldname);
if (column) {
column.in_list_view = 1;
column.columns = row.columns;
return column;
}
});
if (this.frm) {
let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView');
if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) {
this.user_defined_columns = user_settings[this.doctype].map(row => {
let column = frappe.meta.get_docfield(this.doctype, row.fieldname);
if (column) {
column.in_list_view = 1;
column.columns = row.columns;
return column;
}
});
}
}
}

View file

@ -497,7 +497,7 @@ export default class GridRow {
}
update_user_settings_for_grid() {
if (!this.selected_columns_for_grid) {
if (!this.selected_columns_for_grid || !this.frm) {
return;
}

View file

@ -70,6 +70,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog {
this.dialog = new frappe.ui.Dialog({
title: title,
fields: this.fields,
size: this.size,
primary_action_label: this.primary_action_label || __("Get Items"),
secondary_action_label: __("Make {0}", [__(this.doctype)]),
primary_action: () => {
@ -135,7 +136,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog {
this.get_child_result().then(r => {
this.child_results = r.message || [];
this.render_child_datatable();
this.$wrapper.addClass('hidden');
this.$child_wrapper.removeClass('hidden');
this.dialog.fields_dict.more_btn.$wrapper.hide();

View file

@ -193,7 +193,7 @@ frappe.ui.form.ScriptManager = class ScriptManager {
function setup_add_fetch(df) {
if ((['Data', 'Read Only', 'Text', 'Small Text', 'Currency', 'Check',
'Text Editor', 'Code', 'Link', 'Float', 'Int', 'Date', 'Select'].includes(df.fieldtype) || df.read_only==1)
'Text Editor', 'Code', 'Link', 'Float', 'Int', 'Date', 'Select', 'Duration'].includes(df.fieldtype) || df.read_only==1)
&& df.fetch_from && df.fetch_from.indexOf(".")!=-1) {
var parts = df.fetch_from.split(".");
me.frm.add_fetch(parts[0], parts[1], df.fieldname);

View file

@ -63,6 +63,20 @@
</svg>
</a>
{% } %}
<div class="custom-actions"></div>
<div class="more-actions">
<a type="button" class="action-btn"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<svg class="icon icon-sm">
<use xlink:href="#icon-dot-horizontal"></use>
</svg>
</a>
<ul class="dropdown-menu small">
<li>
<a class="dropdown-item" data-action="copy_link">{{ __('Copy Link') }}</a>
</li>
</ul>
</div>
</span>
</span>
<div class="content">

View file

@ -114,14 +114,14 @@ export default class ListSettings {
<div class="row">
<div class="col-md-1">
<i class="fa fa-bars text-muted sortable-handle ${show_sortable_handle}" aria-hidden="true"></i>
${frappe.utils.icon("drag", "xs", "", "", "sortable-handle " + show_sortable_handle)}
</div>
<div class="col-md-10" style="padding-left:0px;">
${me.fields[idx].label}
</div>
<div class="col-md-1 ${can_remove}">
<a class="text-muted remove-field" data-fieldname="${me.fields[idx].fieldname}">
<i class="fa fa-trash-o" aria-hidden="true"></i>
${frappe.utils.icon("delete", "xs")}
</a>
</div>
</div>

View file

@ -907,7 +907,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
return this.settings.get_form_link(doc);
}
const docname = doc.name.match(/[%'"\s]/)
const docname = doc.name.match(/[%'"#\s]/)
? encodeURIComponent(doc.name)
: doc.name;

View file

@ -56,10 +56,6 @@ $('body').on('click', 'a', function(e) {
return override(e.currentTarget.hash);
}
if (frappe.router.is_app_route(e.currentTarget.pathname)) {
// target has "/app, this is a v2 style route.
return override(e.currentTarget.pathname + e.currentTarget.hash);
}
});
frappe.router = {
@ -263,7 +259,9 @@ frappe.router = {
return new Promise(resolve => {
route = this.get_route_from_arguments(route);
route = this.convert_from_standard_route(route);
const sub_path = this.make_url(route);
let sub_path = this.make_url(route);
// replace each # occurrences in the URL with encoded character except for last
// sub_path = sub_path.replace(/[#](?=.*[#])/g, "%23");
this.push_state(sub_path);
setTimeout(() => {
@ -347,7 +345,7 @@ frappe.router = {
return null;
} else {
a = String(a);
if (a && a.match(/[%'"\s\t]/)) {
if (a && a.match(/[%'"#\s\t]/)) {
// if special chars, then encode
a = encodeURIComponent(a);
}
@ -374,7 +372,7 @@ frappe.router = {
// return clean sub_path from hash or url
// supports both v1 and v2 routing
if (!route) {
route = window.location.pathname + window.location.hash + window.location.search;
route = window.location.pathname;
if (route.includes('app#')) {
// to support v1
route = window.location.hash;

View file

@ -78,6 +78,8 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
this.$wrapper
.on("hide.bs.modal", function() {
me.display = false;
me.is_minimized = false;
me.hide_scrollbar(false);
if(frappe.ui.open_dialogs[frappe.ui.open_dialogs.length-1]===me) {
frappe.ui.open_dialogs.pop();
@ -96,6 +98,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
window.cur_dialog = me;
frappe.ui.open_dialogs.push(me);
me.focus_on_first_input();
me.hide_scrollbar(true);
me.on_page_show && me.on_page_show();
$(document).trigger('frappe.ui.Dialog:shown');
$(document).off('focusin.modal');
@ -233,7 +236,11 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
this.get_minimize_btn().html(frappe.utils.icon(icon));
this.on_minimize_toggle && this.on_minimize_toggle(this.is_minimized);
this.header.find('.modal-title').toggleClass('cursor-pointer');
$("body").css("overflow", this.is_minimized ? "auto" : "hidden");
this.hide_scrollbar(!this.is_minimized);
}
hide_scrollbar(bool) {
$("body").css("overflow", bool ? "hidden" : "auto");
}
add_custom_action(label, action, css_class=null) {

View file

@ -242,18 +242,21 @@ Object.defineProperties(window, {
get: function() {
console.warn('Please use `frappe.datetime` instead of `dateutil`. It will be deprecated soon.');
return frappe.datetime;
}
},
configurable: true
},
'date': {
get: function() {
console.warn('Please use `frappe.datetime` instead of `date`. It will be deprecated soon.');
return frappe.datetime;
}
},
configurable: true
},
'get_today': {
get: function() {
console.warn('Please use `frappe.datetime.get_today` instead of `get_today`. It will be deprecated soon.');
return frappe.datetime.get_today;
}
},
configurable: true
}
});

View file

@ -25,11 +25,11 @@ function prettyDate(date, mini) {
if (day_diff < 7) {
return __("{0} d", [day_diff]);
} else if (day_diff < 31) {
return __("{0} w", [Math.ceil(day_diff / 7)]);
return __("{0} w", [Math.floor(day_diff / 7)]);
} else if (day_diff < 365) {
return __("{0} M", [Math.ceil(day_diff / 30)]);
return __("{0} M", [Math.floor(day_diff / 30)]);
} else {
return __("{0} y", [Math.ceil(day_diff / 365)]);
return __("{0} y", [Math.floor(day_diff / 365)]);
}
}
} else {
@ -54,15 +54,15 @@ function prettyDate(date, mini) {
} else if (day_diff < 14) {
return __("1 week ago");
} else if (day_diff < 31) {
return __("{0} weeks ago", [Math.ceil(day_diff / 7)]);
return __("{0} weeks ago", [Math.floor(day_diff / 7)]);
} else if (day_diff < 62) {
return __("1 month ago");
} else if (day_diff < 365) {
return __("{0} months ago", [Math.ceil(day_diff / 30)]);
return __("{0} months ago", [Math.floor(day_diff / 30)]);
} else if (day_diff < 730) {
return __("1 year ago");
} else {
return __("{0} years ago", [Math.ceil(day_diff / 365)]);
return __("{0} years ago", [Math.floor(day_diff / 365)]);
}
}
}

View file

@ -268,7 +268,8 @@ Object.assign(frappe.utils, {
</a></p>');
return content.html();
},
scroll_to: function(element, animate=true, additional_offset, element_to_be_scrolled, callback) {
scroll_to: function(element, animate=true, additional_offset,
element_to_be_scrolled, callback, highlight_element=false) {
if (frappe.flags.disable_auto_scroll) return;
element_to_be_scrolled = element_to_be_scrolled || $("html, body");
@ -291,11 +292,20 @@ Object.assign(frappe.utils, {
}
if (animate) {
element_to_be_scrolled.animate({ scrollTop: scroll_top }).promise().then(callback);
element_to_be_scrolled.animate({
scrollTop: scroll_top
}).promise().then(() => {
if (highlight_element) {
$(element).addClass('highlight');
document.addEventListener("click", function() {
$(element).removeClass('highlight');
}, {once: true});
}
callback && callback();
});
} else {
element_to_be_scrolled.scrollTop(scroll_top);
}
},
get_scroll_position: function(element, additional_offset) {
let header_offset = $(".navbar").height() + $(".page-head:visible").height();
@ -1123,7 +1133,7 @@ Object.assign(frappe.utils, {
}
},
icon(icon_name, size="sm", icon_class="", icon_style="") {
icon(icon_name, size="sm", icon_class="", icon_style="", svg_class="") {
let size_class = "";
if (typeof size == "object") {
@ -1131,7 +1141,7 @@ Object.assign(frappe.utils, {
} else {
size_class = `icon-${size}`;
}
return `<svg class="icon ${size_class}" style="${icon_style}">
return `<svg class="icon ${svg_class} ${size_class}" style="${icon_style}">
<use class="${icon_class}" href="#icon-${icon_name}"></use>
</svg>`;
},

View file

@ -209,6 +209,8 @@
--highlight-color: var(--gray-50);
--yellow-highlight-color: var(--yellow-50);
--highlight-shadow: 1px 1px 10px var(--blue-50), 0px 0px 4px var(--blue-600);
// Border Sizes
--border-radius-sm: 4px;
--border-radius: 6px;

View file

@ -169,7 +169,7 @@ body.modal-open[style^="padding-right"] {
border-radius: var(--border-radius-md);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
box-shadow: -10px 10px rgba(0, 0, 0, 0.100661);
box-shadow: var(--shadow-lg);
}
@include media-breakpoint-down(sm) {

View file

@ -75,6 +75,8 @@
--highlight-color: var(--gray-700);
--yellow-highlight-color: var(--yellow-700);
--highlight-shadow: 1px 1px 10px var(--blue-900), 0px 0px 4px var(--blue-500);
// input
--input-disabled-bg: none;

View file

@ -164,12 +164,11 @@ body {
.drag-handle {
cursor: all-scroll;
cursor: -webkit-grabbing;
cursor: grabbing;
&:active {
cursor: all-scroll;
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
}
@ -813,7 +812,7 @@ body {
.drag-handle {
cursor: all-scroll;
cursor: -webkit-grabbing;
cursor: grabbing;
display: none;
}
@ -966,7 +965,7 @@ body {
.drag-handle {
cursor: all-scroll;
cursor: -webkit-grabbing;
cursor: grabbing;
}
}
}

View file

@ -561,6 +561,19 @@ details > summary:focus {
display: none;
}
.highlight {
transition: 0.5s ease background-color;
box-shadow: var(--highlight-shadow) !important;
}
.dropdown-menu.small {
font-size: var(--text-sm);
min-width: 140px;
.dropdown-item {
padding: var(--padding-xs);
}
}
// REDESIGN TODO: Handling of broken images?
// img.no-image:before {
// .img-background();

View file

@ -228,6 +228,11 @@ input.list-check-all, input.list-row-checkbox {
z-index: 500;
top: 0;
}
.sortable-handle {
cursor: all-scroll;
cursor: grabbing;
}
}
.list-items {

View file

@ -117,7 +117,7 @@ $threshold: 34;
.actions {
display: flex;
> * {
> *:not(.indicator-pill) {
color: var(--text-muted);
}
}

View file

@ -22,6 +22,8 @@ class TestDB(unittest.TestCase):
self.assertNotEqual(frappe.db.get_value("User", {"name": ["!=", "Guest"]}), "Guest")
self.assertEqual(frappe.db.get_value("User", {"name": ["<", "Adn"]}), "Administrator")
self.assertEqual(frappe.db.get_value("User", {"name": ["<=", "Administrator"]}), "Administrator")
self.assertEqual(frappe.db.get_value("User", {}, ["Max(name)"]), frappe.db.sql("SELECT Max(name) FROM tabUser")[0][0])
self.assertEqual(frappe.db.get_value("User", {}, "Min(name)"), frappe.db.sql("SELECT Min(name) FROM tabUser")[0][0])
self.assertEqual(frappe.db.sql("""SELECT name FROM `tabUser` WHERE name > 's' ORDER BY MODIFIED DESC""")[0][0],
frappe.db.get_value("User", {"name": [">", "s"]}))

View file

@ -34,7 +34,7 @@ class NamespaceDict(frappe._dict):
return ret
def safe_exec(script, _globals=None, _locals=None):
def safe_exec(script, _globals=None, _locals=None, restrict_commit_rollback=False):
# server scripts can be disabled via site_config.json
# they are enabled by default
if 'server_script_enabled' in frappe.conf:
@ -50,6 +50,10 @@ def safe_exec(script, _globals=None, _locals=None):
if _globals:
exec_globals.update(_globals)
if restrict_commit_rollback:
exec_globals.frappe.db.pop('commit', None)
exec_globals.frappe.db.pop('rollback', None)
# execute script compiled by RestrictedPython
frappe.flags.in_safe_exec = True
exec(compile_restricted(script), exec_globals, _locals) # pylint: disable=exec-used
@ -78,7 +82,8 @@ def get_safe_globals():
# make available limited methods of frappe
json=NamespaceDict(
loads=json.loads,
dumps=json.dumps),
dumps=json.dumps
),
dict=dict,
log=frappe.log,
_dict=frappe._dict,
@ -156,15 +161,20 @@ def get_safe_globals():
set_value=frappe.db.set_value,
get_single_value=frappe.db.get_single_value,
get_default=frappe.db.get_default,
exists=frappe.db.exists,
count=frappe.db.count,
min=frappe.db.min,
max=frappe.db.max,
avg=frappe.db.avg,
sum=frappe.db.sum,
escape=frappe.db.escape,
sql=frappe.db.sql
sql=frappe.db.sql,
commit=frappe.db.commit,
rollback=frappe.db.rollback,
)
out.frappe.cache = cache
if frappe.response:
out.frappe.response = frappe.response
@ -182,6 +192,21 @@ def get_safe_globals():
return out
def cache():
return NamespaceDict(
get_value = frappe.cache().get_value,
set_value = frappe.cache().set_value,
hset = frappe.cache().hset,
hget = frappe.cache().hget
)
def read_sql(query, *args, **kwargs):
'''a wrapper for frappe.db.sql to allow reads'''
if query.strip().split(None, 1)[0].lower() == 'select':
return frappe.db.sql(query, *args, **kwargs)
else:
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')
def run_script(script):
'''run another server script'''
return frappe.get_doc('Server Script', script).execute_method()

View file

@ -4,7 +4,8 @@
"build": "node esbuild",
"production": "node esbuild --production",
"watch": "node esbuild --watch",
"snyk-protect": "snyk protect"
"snyk-protect": "snyk protect",
"coverage:report": "npx nyc report --reporter=clover"
},
"repository": {
"type": "git",
@ -74,5 +75,8 @@
"rtlcss": "^3.2.1",
"yargs": "^16.2.0"
},
"snyk": true
"snyk": true,
"nyc": {
"report-dir": ".cypress-coverage"
}
}