Merge branch 'frappe:develop' into primary-navbar-css-fix

This commit is contained in:
Shariq Ansari 2021-10-14 14:56:27 +05:30 committed by GitHub
commit 349c324802
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 1559 additions and 1018 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

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

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

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

@ -57,7 +57,23 @@ context('Discussions', () => {
cy.get('.discussion-on-page:visible .comment-field').should('have.value', '');
};
const single_thread_discussion = () => {
cy.visit('/test-single-thread');
cy.get('.discussions-sidebar').should('have.length', 0);
cy.get('.reply').should('have.length', 0);
cy.get('.discussion-on-page .comment-field')
.type('This comment is being made on a single thread discussion.')
.should('have.value', 'This comment is being made on a single thread discussion.');
cy.get('.discussion-on-page .submit-discussion').click();
cy.wait(3000);
cy.get('.discussion-on-page').children(".reply-card").eq(-1).children(".reply-text")
.should('have.text', 'This comment is being made on a single thread discussion.\n');
};
it('reply through modal', reply_through_modal);
it('reply through comment box', reply_through_comment_box);
it('cancel and clear comment box', cancel_and_clear_comment_box);
it('single thread discussion', single_thread_discussion);
});

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

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

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

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,13 @@ import frappe
import unittest
class TestFeedback(unittest.TestCase):
def tearDown(self):
frappe.form_dict.reference_doctype = None
frappe.form_dict.reference_name = None
frappe.form_dict.rating = None
frappe.form_dict.feedback = None
frappe.local.request_ip = None
def test_feedback_creation_updation(self):
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
test_blog = make_test_blog()
@ -12,7 +19,14 @@ class TestFeedback(unittest.TestCase):
frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"})
from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback
feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback')
frappe.form_dict.reference_doctype = 'Blog Post'
frappe.form_dict.reference_name = test_blog.name
frappe.form_dict.rating = 5
frappe.form_dict.feedback = 'New feedback'
frappe.local.request_ip = '127.0.0.1'
feedback = add_feedback()
self.assertEqual(feedback.feedback, 'New feedback')
self.assertEqual(feedback.rating, 5)

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

@ -113,6 +113,7 @@ class Database(object):
query = str(query)
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)

View file

@ -226,6 +226,7 @@ CREATE TABLE `tabDocType` (
`email_append_to` int(1) NOT NULL DEFAULT 0,
`subject_field` varchar(255) DEFAULT NULL,
`sender_field` varchar(255) DEFAULT NULL,
`migration_hash` varchar(255) DEFAULT NULL,
PRIMARY KEY (`name`),
KEY `parent` (`parent`)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -231,6 +231,7 @@ CREATE TABLE "tabDocType" (
"email_append_to" smallint NOT NULL DEFAULT 0,
"subject_field" varchar(255) DEFAULT NULL,
"sender_field" varchar(255) DEFAULT NULL,
"migration_hash" varchar(255) DEFAULT NULL,
PRIMARY KEY ("name")
) ;

View file

@ -66,7 +66,8 @@ 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)
reference_doc = frappe.get_doc(reference_doctype, reference_name)
doc.content = extract_images_from_html(reference_doc, content, is_private=True)
doc.insert(ignore_permissions=True)
follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user)

View file

@ -127,6 +127,8 @@ def setup_group_by(data):
if frappe.db.has_column(data.aggregate_on_doctype, data.aggregate_on_field):
data.fields.append('{aggregate_function}(`tab{aggregate_on_doctype}`.`{aggregate_on_field}`) AS _aggregate_column'.format(**data))
if data.aggregate_on_field:
data.fields.append(f"`tab{data.aggregate_on_doctype}`.`{data.aggregate_on_field}`")
else:
raise_invalid_field(data.aggregate_on_field)

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

@ -4,6 +4,8 @@
import json
import os
import sys
from collections import OrderedDict
from typing import List, Dict
import frappe
from frappe.defaults import _clear_cache
@ -158,7 +160,7 @@ def install_app(name, verbose=False, set_as_patched=True):
if name != "frappe":
add_module_defs(name)
sync_for(name, force=True, sync_everything=True, verbose=verbose, reset_permissions=True)
sync_for(name, force=True, reset_permissions=True)
add_to_installed_apps(name)
@ -230,9 +232,29 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False)
scheduled_backup(ignore_files=True)
frappe.flags.in_uninstall = True
drop_doctypes = []
modules = frappe.get_all("Module Def", filters={"app_name": app_name}, pluck="name")
drop_doctypes = _delete_modules(modules, dry_run=dry_run)
_delete_doctypes(drop_doctypes, dry_run=dry_run)
if not dry_run:
remove_from_installed_apps(app_name)
frappe.db.commit()
click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green")
frappe.flags.in_uninstall = False
def _delete_modules(modules: List[str], dry_run: bool) -> List[str]:
""" Delete modules belonging to the app and all related doctypes.
Note: All record linked linked to Module Def are also deleted.
Returns: list of deleted doctypes."""
drop_doctypes = []
doctype_link_field_map = _get_module_linked_doctype_field_map()
for module_name in modules:
print(f"Deleting Module '{module_name}'")
@ -242,45 +264,67 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False)
print(f"* removing DocType '{doctype.name}'...")
if not dry_run:
frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True)
if not doctype.issingle:
if doctype.issingle:
frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True)
else:
drop_doctypes.append(doctype.name)
linked_doctypes = frappe.get_all(
"DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=["parent"]
)
ordered_doctypes = ["Workspace", "Report", "Page", "Web Form"]
all_doctypes_with_linked_modules = ordered_doctypes + [
doctype.parent
for doctype in linked_doctypes
if doctype.parent not in ordered_doctypes
]
doctypes_with_linked_modules = [
x for x in all_doctypes_with_linked_modules if frappe.db.exists("DocType", x)
]
for doctype in doctypes_with_linked_modules:
for record in frappe.get_all(doctype, filters={"module": module_name}, pluck="name"):
print(f"* removing {doctype} '{record}'...")
if not dry_run:
frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True)
_delete_linked_documents(module_name, doctype_link_field_map, dry_run=dry_run)
print(f"* removing Module Def '{module_name}'...")
if not dry_run:
frappe.delete_doc("Module Def", module_name, ignore_on_trash=True, force=True)
for doctype in set(drop_doctypes):
return drop_doctypes
def _delete_linked_documents(
module_name: str,
doctype_linkfield_map: Dict[str, str],
dry_run: bool
) -> None:
"""Deleted all records linked with module def"""
for doctype, fieldname in doctype_linkfield_map.items():
for record in frappe.get_all(doctype, filters={fieldname: module_name}, pluck="name"):
print(f"* removing {doctype} '{record}'...")
if not dry_run:
frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True)
def _get_module_linked_doctype_field_map() -> Dict[str, str]:
""" Get all the doctypes which have module linked with them.
returns ordered dictionary with doctype->link field mapping."""
# Hardcoded to change order of deletion
ordered_doctypes = [
("Workspace", "module"),
("Report", "module"),
("Page", "module"),
("Web Form", "module")
]
doctype_to_field_map = OrderedDict(ordered_doctypes)
linked_doctypes = frappe.get_all(
"DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=["parent", "fieldname"]
)
existing_linked_doctypes = [d for d in linked_doctypes if frappe.db.exists("DocType", d.parent)]
for d in existing_linked_doctypes:
# DocType deletion is handled separately in the end
if d.parent not in doctype_to_field_map and d.parent != "DocType":
doctype_to_field_map[d.parent] = d.fieldname
return doctype_to_field_map
def _delete_doctypes(doctypes: List[str], dry_run: bool) -> None:
for doctype in set(doctypes):
print(f"* dropping Table for '{doctype}'...")
if not dry_run:
frappe.delete_doc("DocType", doctype, ignore_on_trash=True)
frappe.db.sql_ddl(f"drop table `tab{doctype}`")
if not dry_run:
remove_from_installed_apps(app_name)
frappe.db.commit()
click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green")
frappe.flags.in_uninstall = False
def post_install(rebuild_website=False):
from frappe.website.utils import clear_website_cache
@ -456,9 +500,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

@ -18,6 +18,7 @@ from frappe.core.doctype.language.language import sync_languages
from frappe.modules.utils import sync_customizations
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
from frappe.search.website_search import build_index_for_all_routes
from frappe.database.schema import add_column
def migrate(verbose=True, skip_failing=False, skip_search_index=False):
@ -26,9 +27,10 @@ def migrate(verbose=True, skip_failing=False, skip_search_index=False):
- run patches
- sync doctypes (schema)
- sync dashboards
- sync jobs
- sync fixtures
- sync desktop icons
- sync web pages (from /www)
- sync customizations
- sync languages
- sync web pages (from /www)
- run after migrate hooks
'''
@ -51,6 +53,7 @@ Otherwise, check the server logs and ensure that all the required services are r
os.remove(touched_tables_file)
try:
add_column(doctype="DocType", column_name="migration_hash", fieldtype="Data")
frappe.flags.touched_tables = set()
frappe.flags.in_migrate = True
@ -65,7 +68,7 @@ Otherwise, check the server logs and ensure that all the required services are r
frappe.modules.patch_handler.run_all(skip_failing)
# sync
frappe.model.sync.sync_all(verbose=verbose)
frappe.model.sync.sync_all()
frappe.translate.clear_cache()
sync_jobs()
sync_fixtures()

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

@ -10,62 +10,67 @@ from frappe.modules.import_file import import_file_by_path
from frappe.modules.patch_handler import block_user
from frappe.utils import update_progress_bar
def sync_all(force=0, verbose=False, reset_permissions=False):
def sync_all(force=0, reset_permissions=False):
block_user(True)
for app in frappe.get_installed_apps():
sync_for(app, force, verbose=verbose, reset_permissions=reset_permissions)
sync_for(app, force, reset_permissions=reset_permissions)
block_user(False)
frappe.clear_cache()
def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_permissions=False):
def sync_for(app_name, force=0, reset_permissions=False):
files = []
if app_name == "frappe":
# these need to go first at time of install
for d in (("core", "docfield"),
("core", "docperm"),
("core", "doctype_action"),
("core", "doctype_link"),
("core", "role"),
("core", "has_role"),
("core", "doctype"),
("core", "user"),
("custom", "custom_field"),
("custom", "property_setter"),
("website", "web_form"),
("website", "web_template"),
("website", "web_form_field"),
("website", "portal_menu_item"),
("data_migration", "data_migration_mapping_detail"),
("data_migration", "data_migration_mapping"),
("data_migration", "data_migration_plan_mapping"),
("data_migration", "data_migration_plan"),
("desk", "number_card"),
("desk", "dashboard_chart"),
("desk", "dashboard"),
("desk", "onboarding_permission"),
("desk", "onboarding_step"),
("desk", "onboarding_step_map"),
("desk", "module_onboarding"),
("desk", "workspace_link"),
("desk", "workspace_chart"),
("desk", "workspace_shortcut"),
("desk", "workspace")):
files.append(os.path.join(frappe.get_app_path("frappe"), d[0],
"doctype", d[1], d[1] + ".json"))
FRAPPE_PATH = frappe.get_app_path("frappe")
for core_module in ["docfield", "docperm", "doctype_action", "doctype_link", "role", "has_role", "doctype"]:
files.append(os.path.join(FRAPPE_PATH, "core", "doctype", core_module, f"{core_module}.json"))
for custom_module in ["custom_field", "property_setter"]:
files.append(os.path.join(FRAPPE_PATH, "custom", "doctype", custom_module, f"{custom_module}.json"))
for website_module in ["web_form", "web_template", "web_form_field", "portal_menu_item"]:
files.append(os.path.join(FRAPPE_PATH, "website", "doctype", website_module, f"{website_module}.json"))
for data_migration_module in [
"data_migration_mapping_detail",
"data_migration_mapping",
"data_migration_plan_mapping",
"data_migration_plan",
]:
files.append(os.path.join(FRAPPE_PATH, "data_migration", "doctype", data_migration_module, f"{data_migration_module}.json"))
for desk_module in [
"number_card",
"dashboard_chart",
"dashboard",
"onboarding_permission",
"onboarding_step",
"onboarding_step_map",
"module_onboarding",
"workspace_link",
"workspace_chart",
"workspace_shortcut",
"workspace",
]:
files.append(os.path.join(FRAPPE_PATH, "desk", "doctype", desk_module, f"{desk_module}.json"))
for module_name in frappe.local.app_modules.get(app_name) or []:
folder = os.path.dirname(frappe.get_module(app_name + "." + module_name).__file__)
get_doc_files(files, folder)
files = get_doc_files(files=files, start_path=folder)
l = len(files)
if l:
for i, doc_path in enumerate(files):
import_file_by_path(doc_path, force=force, ignore_version=True,
reset_permissions=reset_permissions, for_sync=True)
import_file_by_path(doc_path, force=force, ignore_version=True, reset_permissions=reset_permissions)
frappe.db.commit()
@ -75,17 +80,36 @@ def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_pe
# print each progress bar on new line
print()
def get_doc_files(files, start_path):
"""walk and sync all doctypes and pages"""
# load in sequence - warning for devs
document_types = ['doctype', 'page', 'report', 'dashboard_chart_source', 'print_format',
'web_page', 'website_theme', 'web_form', 'web_template',
'notification', 'print_style',
'data_migration_mapping', 'data_migration_plan',
'workspace', 'onboarding_step', 'module_onboarding', 'form_tour',
'client_script', 'server_script', 'custom_field', 'property_setter']
files = files or []
# load in sequence - warning for devs
document_types = [
"doctype",
"page",
"report",
"dashboard_chart_source",
"print_format",
"web_page",
"website_theme",
"web_form",
"web_template",
"notification",
"print_style",
"data_migration_mapping",
"data_migration_plan",
"workspace",
"onboarding_step",
"module_onboarding",
"form_tour",
"client_script",
"server_script",
"custom_field",
"property_setter",
]
for doctype in document_types:
doctype_path = os.path.join(start_path, doctype)
if os.path.exists(doctype_path):
@ -95,3 +119,5 @@ def get_doc_files(files, start_path):
if os.path.exists(doc_path):
if not doc_path in files:
files.append(doc_path)
return files

View file

@ -1,31 +1,53 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import frappe, os, json
from frappe.modules import get_module_path, scrub_dt_dn
from frappe.utils import get_datetime_str
import hashlib
import json
import os
import frappe
from frappe.model.base_document import get_controller
from frappe.modules import get_module_path, scrub_dt_dn
from frappe.query_builder import DocType
from frappe.utils import get_datetime_str, now
def caclulate_hash(path: str) -> str:
"""Calculate md5 hash of the file in binary mode
Args:
path (str): Path to the file to be hashed
Returns:
str: The calculated hash
"""
hash_md5 = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
ignore_values = {
"Report": ["disabled", "prepared_report", "add_total_row"],
"Print Format": ["disabled"],
"Notification": ["enabled"],
"Print Style": ["disabled"],
"Module Onboarding": ['is_complete'],
"Onboarding Step": ['is_complete', 'is_skipped']
"Module Onboarding": ["is_complete"],
"Onboarding Step": ["is_complete", "is_skipped"],
}
ignore_doctypes = [""]
def import_files(module, dt=None, dn=None, force=False, pre_process=None, reset_permissions=False):
if type(module) is list:
out = []
for m in module:
out.append(import_file(m[0], m[1], m[2], force=force, pre_process=pre_process,
reset_permissions=reset_permissions))
out.append(import_file(m[0], m[1], m[2], force=force, pre_process=pre_process, reset_permissions=reset_permissions))
return out
else:
return import_file(module, dt, dn, force=force, pre_process=pre_process,
reset_permissions=reset_permissions)
return import_file(module, dt, dn, force=force, pre_process=pre_process, reset_permissions=reset_permissions)
def import_file(module, dt, dn, force=False, pre_process=None, reset_permissions=False):
"""Sync a file from txt if modifed, return false if not updated"""
@ -33,77 +55,160 @@ def import_file(module, dt, dn, force=False, pre_process=None, reset_permissions
ret = import_file_by_path(path, force, pre_process=pre_process, reset_permissions=reset_permissions)
return ret
def get_file_path(module, dt, dn):
dt, dn = scrub_dt_dn(dt, dn)
path = os.path.join(get_module_path(module),
os.path.join(dt, dn, dn + ".json"))
path = os.path.join(get_module_path(module), os.path.join(dt, dn, f"{dn}.json"))
return path
def import_file_by_path(path, force=False, data_import=False, pre_process=None, ignore_version=None,
reset_permissions=False, for_sync=False):
def import_file_by_path(path: str,force: bool = False,data_import: bool = False,pre_process = None,ignore_version: bool = None,reset_permissions: bool = False):
"""Import file from the given path
Some conditions decide if a file should be imported or not.
Evaluation takes place in the order they are mentioned below.
- Check if `force` is true. Import the file. If not, move ahead.
- Get `db_modified_timestamp`(value of the modified field in the database for the file).
If the return is `none,` this file doesn't exist in the DB, so Import the file. If not, move ahead.
- Check if there is a hash in DB for that file. If there is, Calculate the Hash of the file to import and compare it with the one in DB if they are not equal.
Import the file. If Hash doesn't exist, move ahead.
- Check if `db_modified_timestamp` is older than the timestamp in the file; if it is, we import the file.
If timestamp comparison happens for doctypes, that means the Hash for it doesn't exist.
So, even if the timestamp is newer on DB (When comparing timestamps), we import the file and add the calculated Hash to the DB.
So in the subsequent imports, we can use hashes to compare. As a precautionary measure, the timestamp is updated to the current time as well.
Args:
path (str): Path to the file.
force (bool, optional): Load the file without checking any conditions. Defaults to False.
data_import (bool, optional): [description]. Defaults to False.
pre_process ([type], optional): Any preprocesing that may need to take place on the doc. Defaults to None.
ignore_version (bool, optional): ignore current version. Defaults to None.
reset_permissions (bool, optional): reset permissions for the file. Defaults to False.
Returns:
[bool]: True if import takes place. False if it wasn't imported.
"""
frappe.flags.dt = frappe.flags.dt or []
try:
docs = read_doc_from_file(path)
except IOError:
print (path + " missing")
print(f"{path} missing")
return
calculated_hash = caclulate_hash(path)
if docs:
if not isinstance(docs, list):
docs = [docs]
for doc in docs:
if not force and not is_changed(doc):
return False
original_modified = doc.get("modified")
# modified timestamp in db, none if doctype's first import
db_modified_timestamp = frappe.db.get_value(doc["doctype"], doc["name"], "modified")
is_db_timestamp_latest = db_modified_timestamp and doc.get("modified") <= get_datetime_str(db_modified_timestamp)
import_doc(doc, force=force, data_import=data_import, pre_process=pre_process,
ignore_version=ignore_version, reset_permissions=reset_permissions, path=path)
if not force or db_modified_timestamp:
try:
stored_hash = frappe.db.get_value(doc["doctype"], doc["name"], "migration_hash")
except Exception:
frappe.flags.dt += [doc["doctype"]]
stored_hash = None
if original_modified:
update_modified(original_modified, doc)
# if hash exists and is equal no need to update
if stored_hash and stored_hash == calculated_hash:
return False
# if hash doesn't exist, check if db timestamp is same as json timestamp, add hash if from doctype
if is_db_timestamp_latest and doc["doctype"] != "DocType":
return False
import_doc(
docdict=doc,
force=force,
data_import=data_import,
pre_process=pre_process,
ignore_version=ignore_version,
reset_permissions=reset_permissions,
path=path,
)
if doc["doctype"] == "DocType":
doctype_table = DocType("DocType")
frappe.qb.update(
doctype_table
).set(
doctype_table.migration_hash, calculated_hash
).where(
doctype_table.name == doc["name"]
).run()
new_modified_timestamp = doc.get("modified")
# if db timestamp is newer, hash must have changed, must update db timestamp
if is_db_timestamp_latest and doc["doctype"] == "DocType":
new_modified_timestamp = now()
if new_modified_timestamp:
update_modified(new_modified_timestamp, doc)
return True
def is_changed(doc):
def is_timestamp_changed(doc):
# check if timestamps match
db_modified = frappe.db.get_value(doc['doctype'], doc['name'], 'modified')
if db_modified and doc.get('modified')==get_datetime_str(db_modified):
return False
return True
db_modified = frappe.db.get_value(doc["doctype"], doc["name"], "modified")
return not (db_modified and doc.get("modified") == get_datetime_str(db_modified))
def read_doc_from_file(path):
doc = None
if os.path.exists(path):
with open(path, 'r') as f:
with open(path, "r") as f:
try:
doc = json.loads(f.read())
except ValueError:
print("bad json: {0}".format(path))
raise
else:
raise IOError('%s missing' % path)
raise IOError("%s missing" % path)
return doc
def update_modified(original_modified, doc):
# since there is a new timestamp on the file, update timestamp in
if doc["doctype"] == doc["name"] and doc["name"]!="DocType":
frappe.db.sql("""update tabSingles set value=%s where field="modified" and doctype=%s""",
(original_modified, doc["name"]))
else:
frappe.db.sql("update `tab%s` set modified=%s where name=%s" % (doc['doctype'],
'%s', '%s'), (original_modified, doc['name']))
if doc["doctype"] == doc["name"] and doc["name"] != "DocType":
singles_table = DocType("Singles")
def import_doc(docdict, force=False, data_import=False, pre_process=None,
ignore_version=None, reset_permissions=False, path=None):
frappe.qb.update(
singles_table
).set(
singles_table.value,original_modified
).where(
singles_table.field == "modified"
).where(
singles_table.doctype == doc["name"]
).run()
else:
doctype_table = DocType(doc['doctype'])
frappe.qb.update(doctype_table
).set(
doctype_table.modified, original_modified
).where(
doctype_table.name == doc["name"]
).run()
def import_doc(docdict, force=False, data_import=False, pre_process=None, ignore_version=None, reset_permissions=False, path=None):
frappe.flags.in_import = True
docdict["__islocal"] = 1
controller = get_controller(docdict['doctype'])
if controller and hasattr(controller, 'prepare_for_import') and callable(getattr(controller, 'prepare_for_import')):
controller = get_controller(docdict["doctype"])
if controller and hasattr(controller, "prepare_for_import") and callable(getattr(controller, "prepare_for_import")):
controller.prepare_for_import(docdict)
doc = frappe.get_doc(docdict)
@ -132,15 +237,16 @@ def import_doc(docdict, force=False, data_import=False, pre_process=None,
return doc
def load_code_properties(doc, path):
'''Load code files stored in separate files with extensions'''
"""Load code files stored in separate files with extensions"""
if path:
if hasattr(doc, 'get_code_fields'):
if hasattr(doc, "get_code_fields"):
dirname, filename = os.path.split(path)
for key, extn in doc.get_code_fields().items():
codefile = os.path.join(dirname, filename.split('.')[0]+'.'+extn)
codefile = os.path.join(dirname, filename.split(".")[0] + "." + extn)
if os.path.exists(codefile):
with open(codefile,'r') as txtfile:
with open(codefile, "r") as txtfile:
doc.set(key, txtfile.read())
@ -164,12 +270,13 @@ def delete_old_doc(doc, reset_permissions):
doc.flags.ignore_children_type = ignore
def reset_tree_properties(doc):
# Note on Tree DocTypes:
# The tree structure is maintained in the database via the fields "lft" and
# "rgt". They are automatically set and kept up-to-date. Importing them
# would destroy any existing tree structure.
if getattr(doc.meta, 'is_tree', None) and any([doc.lft, doc.rgt]):
if getattr(doc.meta, "is_tree", None) and any([doc.lft, doc.rgt]):
print('Ignoring values of `lft` and `rgt` for {} "{}"'.format(doc.doctype, doc.name))
doc.lft = None
doc.rgt = None

View file

@ -1,7 +1,7 @@
import frappe
def execute():
frappe.flags.in_patch = True
frappe.reload_doc('core', 'doctype', 'user_permission')
frappe.reload_doc("core", "doctype", "user_permission")
frappe.db.commit()

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

@ -36,7 +36,7 @@ frappe.ui.form.on("Print Format", {
else if (frm.doc.custom_format && !frm.doc.raw_printing) {
frm.set_df_property("html", "reqd", 1);
}
if (frappe.perm.has_perm('DocType', 0, 'read', frm.doc.doc_type)) {
if (frappe.model.can_read(frm.doc.doc_type)) {
frappe.db.get_value('DocType', frm.doc.doc_type, 'default_print_format', (r) => {
if (r.default_print_format != frm.doc.name) {
frm.add_custom_button(__("Set as Default"), function () {

View file

@ -171,13 +171,13 @@ frappe.ui.form.PrintView = class {
});
}
if (frappe.perm.has_perm('Print Format', 0, 'create')) {
if (frappe.model.can_create('Print Format')) {
this.page.add_menu_item(__('Customize'), () =>
this.edit_print_format()
);
}
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()
);

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

@ -26,6 +26,7 @@ export default class ListFilter {
this.$input_area = this.wrapper.find('.input-area');
this.$list_filters = this.wrapper.find('.list-filters');
this.$saved_filters = this.wrapper.find('.saved-filters').hide();
this.$saved_filters_preview = this.wrapper.find('.saved-filters-preview');
this.saved_filters_hidden = true;
this.filter_input = frappe.ui.form.make_control({
@ -57,6 +58,7 @@ export default class ListFilter {
refresh() {
this.get_list_filters().then(() => {
this.filters.length ? this.$saved_filters_preview.show() : this.$saved_filters_preview.hide();
const html = this.filters.map((filter) => this.filter_template(filter));
this.wrapper.find('.filter-pill').remove();
this.$saved_filters.append(html);

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

@ -60,6 +60,7 @@ $('body').on('click', 'a', function(e) {
// target has "/app, this is a v2 style route.
return override(e.currentTarget.pathname + e.currentTarget.hash);
}
});
frappe.router = {
@ -263,7 +264,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 +350,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 +377,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

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

@ -8,6 +8,7 @@
min-width: 500px;
min-height: 50px;
font-size: var(--text-md);
z-index: 1019;
}
.filter-area {

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

@ -1,2 +1,2 @@
from pypika import *
from frappe.query_builder.utils import Column, get_query_builder, patch_query_execute
from frappe.query_builder.utils import Column, DocType, get_query_builder, patch_query_execute

View file

@ -2,6 +2,7 @@ from pypika import MySQLQuery, Order, PostgreSQLQuery, terms
from pypika.queries import Schema, Table
from frappe.utils import get_table_name
from pypika.terms import Function
class Base:
terms = terms
desc = Order.desc

View file

@ -44,14 +44,20 @@ def get_attr(method_string):
methodname = method_string.split('.')[-1]
return getattr(import_module(modulename), methodname)
def DocType(*args, **kwargs):
return frappe.qb.DocType(*args, **kwargs)
def patch_query_execute():
"""Patch the Query Builder with helper execute method
This excludes the use of `frappe.db.sql` method while
executing the query object
"""
def execute_query(query, **kwargs):
return frappe.db.sql(query, **kwargs)
def execute_query(query, *args, **kwargs):
query = str(query)
if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"):
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')
return frappe.db.sql(query, *args, **kwargs)
query_class = get_attr(str(frappe.qb).split("'")[1])
builder_class = get_type_hints(query_class._builder).get('return')

View file

@ -1,32 +1,35 @@
<form class="discussion-form">
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<input type="text" autocomplete="off" class="input-with-feedback form-control topic-title" data-fieldtype="Data"
data-fieldname="feedback_comments" placeholder="{{ _('Type title') }}" spellcheck="false"></input>
</div>
{% if not single_thread %}
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<input type="text" autocomplete="off" class="input-with-feedback form-control topic-title"
data-fieldtype="Data" data-fieldname="feedback_comments" placeholder="{{ _('Type title') }}"
spellcheck="false"></input>
</div>
</div>
</div>
{% endif %}
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<textarea type="text" autocomplete="off" class="input-with-feedback form-control comment-field"
data-fieldtype="Text" data-fieldname="feedback_comments" placeholder="{{ _('Type here. Use markdown to format.') }}"
spellcheck="false"></textarea>
</div>
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<textarea type="text" autocomplete="off" class="input-with-feedback form-control comment-field"
data-fieldtype="Text" data-fieldname="feedback_comments"
placeholder="{{ _('Type here. Use markdown to format.') }}" spellcheck="false"></textarea>
</div>
</div>
</div>
<div class="comment-footer">
<div class="small flex-grow-1">
{{ _("Press Cmd+Enter to post your comment") }}
</div>
<a class="dark-links cancel-comment hide"> {{ _("Cancel") }} </a>
<div class="button is-default submit-discussion pull-right mb-1" data-doctype="{{ doctype | urlencode }}"
data-docname="{{ docname | urlencode }}">
{{ _("Post") }} </div>
<div class="comment-footer">
<div class="small flex-grow-1">
{{ _("Press Cmd+Enter to post your comment") }}
</div>
</form>
<a class="dark-links cancel-comment hide"> {{ _("Cancel") }} </a>
<div class="button is-default submit-discussion pull-right mb-1">
{{ _("Post") }}
</div>
</div>
</form>

View file

@ -87,29 +87,43 @@ const setup_socket_io = () => {
};
const publish_message = (data) => {
const doctype = decodeURIComponent($(".discussions-parent").attr("data-doctype"));
const docname = decodeURIComponent($(".discussions-parent").attr("data-docname"));
const topic = data.topic_info;
const single_thread = $(".is-single-thread").length;
const first_topic = !$(".reply-card").length;
const document_match_found = doctype == topic.reference_doctype && docname == topic.reference_docname;
if ($(`.discussion-on-page[data-topic=${data.topic_info.name}]`).length) {
if ($(`.discussion-on-page[data-topic=${topic.name}]`).length) {
post_message_cleanup();
$('<div class="card-divider-dark mb-8"></div>' + data.template).insertBefore(`.discussion-on-page[data-topic=${data.topic_info.name}] .discussion-form`);
} else if ((decodeURIComponent($(".discussions-parent .discussions-card").attr("data-doctype")) == data.topic_info.reference_doctype
&& decodeURIComponent($(".discussions-parent .discussions-card").attr("data-docname")) == data.topic_info.reference_docname)) {
data.template = style_avatar_frame(data.template);
$('<div class="card-divider-dark mb-8"></div>' + data.template)
.insertBefore(`.discussion-on-page[data-topic=${topic.name}] .discussion-form`);
} else if (!first_topic && !single_thread && document_match_found) {
post_message_cleanup();
data.new_topic_template = style_avatar_frame(data.new_topic_template);
$(data.sidebar).insertAfter(`.discussions-sidebar .form-group`);
$(`#discussion-group`).prepend(data.new_topic_template);
if (data.topic_info.owner == frappe.session.user) {
$(".discussion-on-page").collapse();
if (topic.owner == frappe.session.user) {
$(".discussion-on-page") && $(".discussion-on-page").collapse();
$(".sidebar-topic").first().click();
}
} else if (data.topic_info.owner == frappe.session.user) {
} else if (single_thread && document_match_found) {
post_message_cleanup();
data.template = style_avatar_frame(data.template);
$(data.template).insertBefore(`.discussion-form`);
$(".discussion-on-page").attr("data-topic", topic.name);
} else if (topic.owner == frappe.session.user && document_match_found) {
post_message_cleanup();
window.location.reload();
}
update_reply_count(data.topic_info.name);
update_reply_count(topic.name);
};
const post_message_cleanup = () => {
@ -191,10 +205,10 @@ const submit_discussion = (e) => {
const reply = $(".comment-field:visible").val().trim();
if (reply) {
let doctype = $(e.currentTarget).attr("data-doctype");
let doctype = $(e.currentTarget).closest(".discussions-parent").attr("data-doctype");
doctype = doctype ? decodeURIComponent(doctype) : doctype;
let docname = $(e.currentTarget).attr("data-docname");
let docname = $(e.currentTarget).closest(".discussions-parent").attr("data-docname");
docname = docname ? decodeURIComponent(docname) : docname;
frappe.call({
@ -232,7 +246,8 @@ const get_color_from_palette = (element) => {
const style_avatar_frame = (template) => {
const $template = $(template);
$template.find(".avatar-frame").css(get_color_from_palette($template.find(".avatar-frame")));
$template.find(".avatar-frame").length
&& $template.find(".avatar-frame").css(get_color_from_palette($template.find(".avatar-frame")));
return $template.prop("outerHTML");
};

View file

@ -1,22 +1,26 @@
{% set topics = frappe.get_all("Discussion Topic",
{"reference_doctype": doctype, "reference_docname": docname}, ["name", "title", "owner", "creation"]) %}
{% include "frappe/templates/discussions/topic_modal.html" %}
<div class="discussions-parent">
<div class="discussions-parent {% if single_thread %} is-single-thread {% endif %}"
data-doctype="{{ doctype | urlencode }}" data-docname="{{ docname | urlencode }}">
{% include "frappe/templates/discussions/topic_modal.html" %}
<div class="discussions-header">
<span class="course-home-headings">{{ _(title) }}</span>
{% if topics %}
{% if topics and not single_thread %}
{% include "frappe/templates/discussions/button.html" %}
{% endif %}
</div>
{% if topics %}
<div class="common-card-style thread-card discussions-card" data-doctype="{{ doctype }}"
data-docname="{{ docname }}">
<div class="common-card-style thread-card {% if topics | length and not single_thread %} discussions-card {% endif %} ">
{% if topics and not single_thread %}
<div class="discussions-sidebar">
{% include "frappe/templates/discussions/search.html" %}
{% for topic in topics %}
{% set replies = frappe.get_all("Discussion Reply", {"topic": topic.name})%}
{% include "frappe/templates/discussions/sidebar.html" %}
@ -24,13 +28,17 @@
</div>
<div class="mr-2" id="discussion-group">
{% for topic in topics %}
{% include "frappe/templates/discussions/reply_section.html" %}
{% endfor %}
</div>
</div>
{% else %}
<div id="no-discussions" class="common-card-style thread-card">
<div class="no-discussions">
{% elif single_thread %}
{% set topic = topics[0] if topics | length else None %}
{% include "frappe/templates/discussions/reply_section.html" %}
{% else %}
<div class="no-discussions" id="no-discussions">
<div class="font-weight-bold">No {{ title }}</div>
<div class="small mt-3 mb-3">There are no {{ title | lower }} for this {{ doctype | lower }}, why don't you start
one! </div>

View file

@ -2,10 +2,10 @@
<div class="reply-card">
{% set member = frappe.db.get_value("User", reply.owner, ["name", "full_name", "username"], as_dict=True) %}
<div class="d-flex align-items-center small mb-2">
{% if loop.index == 1 %}
{% if loop.index == 1 or single_thread %}
{{ avatar(reply.owner) }}
{% endif %}
<a class="button-links {% if loop.index == 1 %} ml-2 {% endif %}" {% if get_profile_url %} href="{{ get_profile_url(member.username) }}" {% endif %}>
<a class="button-links {% if loop.index == 1 or single_thread %} ml-2 {% endif %}" {% if get_profile_url %} href="{{ get_profile_url(member.username) }}" {% endif %}>
{{ member.full_name }}
</a>
<div class="ml-3 frappe-timestamp" data-timestamp="{{ reply.creation }}"> {{ frappe.utils.pretty_date(reply.creation) }} </div>

View file

@ -1,16 +1,23 @@
{% for topic in topics %}
{% if topic %}
{% set replies = frappe.get_all("Discussion Reply", {"topic": topic.name},
["reply", "owner", "creation"], order_by="creation")%}
{% if replies %}
<div class="collapse discussion-on-page" id="t{{ topic.name }}" data-topic="{{ topic.name }}"
data-parent="#discussion-group">
{% endif %}
<div class="collapse discussion-on-page" data-parent="#discussion-group"
{% if topic %} id="t{{ topic.name }}" data-topic="{{ topic.name }}" {% endif %}>
{% if not single_thread %}
<div class="button is-default back">
{{ _("Back") }}
</div>
{% endif %}
{% if topic and topic.title %}
<div class="course-home-headings p-0">{{ topic.title }}</div>
{% endif %}
{% for reply in replies %}
{% include "frappe/templates/discussions/reply_card.html" %}
@ -34,5 +41,3 @@
{% endif %}
</div>
{% endif %}
{% endfor %}

View file

@ -3,27 +3,18 @@
from __future__ import unicode_literals
import frappe
from frappe.utils import add_to_date, now
from frappe import _
from frappe.rate_limiter import rate_limit
from frappe.website.doctype.blog_settings.blog_settings import get_feedback_limit
@frappe.whitelist(allow_guest=True)
@rate_limit(key='reference_name', limit=get_feedback_limit, seconds=60*60)
def add_feedback(reference_doctype, reference_name, rating, feedback):
doc = frappe.get_doc(reference_doctype, reference_name)
if doc.disable_feedback == 1:
return
feedback_count = frappe.db.count("Feedback", {
"reference_doctype": reference_doctype,
"reference_name": reference_name,
"ip_address": frappe.local.request_ip,
"creation": (">", add_to_date(now(), hours=-1))
})
if feedback_count > 20:
frappe.msgprint(_('Hourly feedback limit reached'))
return
doc = frappe.new_doc('Feedback')
doc.reference_doctype = reference_doctype
doc.reference_name = reference_name

View file

@ -3,7 +3,7 @@
padding: 1rem;
}
.discussions-parent .form-control {
.thread-card .form-control {
background-color: #FFFFFF;
font-size: inherit;
color: inherit;
@ -246,6 +246,6 @@
}
.card-divider-dark {
border: 1px solid var(--gray-400);
margin-bottom: 16px;
border: 1px solid var(--gray-300);
margin-bottom: 1rem;
}

View file

@ -13,7 +13,7 @@ import glob
# imports - module imports
import frappe
import frappe.recorder
from frappe.installer import add_to_installed_apps
from frappe.installer import add_to_installed_apps, remove_app
from frappe.utils import add_to_date, get_bench_relative_path, now
from frappe.utils.backups import fetch_latest_backups
@ -465,3 +465,50 @@ class TestCommands(BaseTestCommands):
self.execute("bench --site {site} set-admin-password test2")
self.assertEqual(self.returncode, 0)
self.assertEqual(check_password('Administrator', 'test2'), 'Administrator')
class RemoveAppUnitTests(unittest.TestCase):
def test_delete_modules(self):
from frappe.installer import (
_delete_doctypes,
_delete_modules,
_get_module_linked_doctype_field_map,
)
test_module = frappe.new_doc("Module Def")
test_module.update({"module_name": "RemoveThis", "app_name": "frappe"})
test_module.save()
module_def_linked_doctype = frappe.get_doc({
"doctype": "DocType",
"name": "Doctype linked with module def",
"module": "RemoveThis",
"custom": 1,
"fields": [{
"label": "Modulen't",
"fieldname": "notmodule",
"fieldtype": "Link",
"options": "Module Def"
}]
}).insert()
doctype_to_link_field_map = _get_module_linked_doctype_field_map()
self.assertIn("Report", doctype_to_link_field_map)
self.assertIn(module_def_linked_doctype.name, doctype_to_link_field_map)
self.assertEqual(doctype_to_link_field_map[module_def_linked_doctype.name], "notmodule")
self.assertNotIn("DocType", doctype_to_link_field_map)
doctypes_to_delete = _delete_modules([test_module.module_name], dry_run=False)
self.assertEqual(len(doctypes_to_delete), 1)
_delete_doctypes(doctypes_to_delete, dry_run=False)
self.assertFalse(frappe.db.exists("Module Def", test_module.module_name))
self.assertFalse(frappe.db.exists("DocType", module_def_linked_doctype.name))
def test_dry_run(self):
"""Check if dry run in not destructive."""
# nothing to assert, if this fails rest of the test suite will crumble.
remove_app("frappe", dry_run=True, yes=True, no_backup=True)

View file

@ -446,6 +446,25 @@ class TestReportview(unittest.TestCase):
user.remove_roles("Blogger", "Website Manager")
user.add_roles(*user_roles)
def test_reportview_get_aggregation(self):
# test aggregation based on child table field
frappe.local.form_dict = frappe._dict({
"doctype": "DocType",
"fields": """["`tabDocField`.`label` as field_label","`tabDocField`.`name` as field_name"]""",
"filters": "[]",
"order_by": "_aggregate_column desc",
"start": 0,
"page_length": 20,
"view": "Report",
"with_comment_count": 0,
"group_by": "field_label, field_name",
"aggregate_on_field": "columns",
"aggregate_on_doctype": "DocField",
"aggregate_function": "sum"
})
response = execute_cmd("frappe.desk.reportview.get")
self.assertListEqual(response["keys"], ["field_label", "field_name", "_aggregate_column", 'columns'])
def add_child_table_to_blog_post():
child_table = frappe.get_doc({

View file

@ -23,4 +23,12 @@ class TestSafeExec(unittest.TestCase):
safe_exec('''out = frappe.db.sql("select name from tabDocType where name='DocType'")''', None, _locals)
self.assertEqual(_locals['out'][0][0], 'DocType')
self.assertRaises(frappe.PermissionError, safe_exec, 'frappe.db.sql("update tabToDo set description=NULL")')
self.assertRaises(frappe.PermissionError, safe_exec, 'frappe.db.sql("update tabToDo set description=NULL")')
def test_query_builder(self):
_locals = dict(out=None)
safe_exec(script='''out = frappe.qb.from_("User").select(frappe.qb.terms.PseudoColumn("Max(name)")).run()''', _globals=None, _locals=_locals)
self.assertEqual(frappe.db.sql("SELECT Max(name) FROM tabUser"), _locals["out"])
def test_safe_query_builder(self):
self.assertRaises(frappe.PermissionError, safe_exec, '''frappe.qb.from_("User").delete().run()''')

View file

@ -194,16 +194,17 @@ def create_form_tour():
@frappe.whitelist()
def create_data_for_discussions():
web_page = create_web_page()
web_page = create_web_page("Test page for discussions", "test-page-discussions", False)
create_topic_and_reply(web_page)
create_web_page("Test single thread discussion", "test-single-thread", True)
def create_web_page():
web_page = frappe.db.exists("Web Page", {"route": "test-page-discussions"})
def create_web_page(title, route, single_thread):
web_page = frappe.db.exists("Web Page", {"route": route})
if not web_page:
web_page = frappe.get_doc({
"doctype": "Web Page",
"title": "Test page for discussions",
"route": "test-page-discussions",
"title": title,
"route": route,
"published": True
})
web_page.save()
@ -213,7 +214,8 @@ def create_web_page():
"web_template_values": frappe.as_json({
"title": "Discussions",
"cta_title": "New Discussion",
"docname": web_page.name
"docname": web_page.name,
"single_thread": single_thread
})
})
web_page.save()

View file

@ -1,23 +1,28 @@
import os, json, inspect
import inspect
import json
import mimetypes
import RestrictedPython.Guards
from html2text import html2text
from RestrictedPython import compile_restricted, safe_globals
import RestrictedPython.Guards
import frappe
from frappe import _
import frappe.utils
import frappe.utils.data
from frappe.website.utils import (get_shade, get_toc, get_next_link)
from frappe.modules import scrub
from frappe.www.printview import get_visible_columns
import frappe.exceptions
import frappe.integrations.utils
import frappe.utils
import frappe.utils.data
from frappe import _
from frappe.frappeclient import FrappeClient
from frappe.modules import scrub
from frappe.website.utils import get_next_link, get_shade, get_toc
from frappe.www.printview import get_visible_columns
class ServerScriptNotEnabled(frappe.PermissionError):
pass
class NamespaceDict(frappe._dict):
"""Raise AttributeError if function not found in namespace"""
def __getattr__(self, key):
@ -29,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:
@ -45,13 +50,20 @@ 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
frappe.flags.in_safe_exec = False
return exec_globals, _locals
def get_safe_globals():
datautils = frappe._dict()
if frappe.db:
date_format = frappe.db.get_default("date_format") or "yyyy-mm-dd"
time_format = frappe.db.get_default("time_format") or "HH:mm:ss"
@ -69,8 +81,9 @@ def get_safe_globals():
out = NamespaceDict(
# make available limited methods of frappe
json=NamespaceDict(
loads = json.loads,
dumps = json.dumps),
loads=json.loads,
dumps=json.dumps
),
dict=dict,
log=frappe.log,
_dict=frappe._dict,
@ -85,6 +98,7 @@ def get_safe_globals():
bold=frappe.bold,
copy_doc=frappe.copy_doc,
errprint=frappe.errprint,
qb=frappe.qb,
get_meta=frappe.get_meta,
get_doc=frappe.get_doc,
@ -99,9 +113,9 @@ def get_safe_globals():
render_template=frappe.render_template,
msgprint=frappe.msgprint,
throw=frappe.throw,
sendmail = frappe.sendmail,
get_print = frappe.get_print,
attach_print = frappe.attach_print,
sendmail=frappe.sendmail,
get_print=frappe.get_print,
attach_print=frappe.attach_print,
user=user,
get_fullname=frappe.utils.get_fullname,
@ -112,8 +126,8 @@ def get_safe_globals():
user=user,
csrf_token=frappe.local.session.data.csrf_token if getattr(frappe.local, "session", None) else ''
),
make_get_request = frappe.integrations.utils.make_get_request,
make_post_request = frappe.integrations.utils.make_post_request,
make_get_request=frappe.integrations.utils.make_get_request,
make_post_request=frappe.integrations.utils.make_post_request,
socketio_port=frappe.conf.socketio_port,
get_hooks=frappe.get_hooks,
sanitize_html=frappe.utils.sanitize_html,
@ -141,21 +155,26 @@ def get_safe_globals():
out.frappe.date_format = date_format
out.frappe.time_format = time_format
out.frappe.db = NamespaceDict(
get_list = frappe.get_list,
get_all = frappe.get_all,
get_value = frappe.db.get_value,
set_value = frappe.db.set_value,
get_single_value = frappe.db.get_single_value,
get_default = frappe.db.get_default,
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 = read_sql
get_list=frappe.get_list,
get_all=frappe.get_all,
get_value=frappe.db.get_value,
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=read_sql,
commit=frappe.db.commit,
rollback=frappe.db.rollback,
)
out.frappe.cache = cache
if frappe.response:
out.frappe.response = frappe.response
@ -173,12 +192,20 @@ 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:
query = str(query)
if frappe.flags.in_safe_exec and not query.strip().lower().startswith('select'):
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')
return frappe.db.sql(query, *args, **kwargs)
def run_script(script):
'''run another server script'''

View file

@ -130,7 +130,6 @@
},
{
"default": "0",
"description": "Comments on this blog post will be disabled if checked.",
"fieldname": "disable_comments",
"fieldtype": "Check",
"label": "Disable Comments"
@ -195,7 +194,6 @@
},
{
"default": "0",
"description": "Feedback on this blog post will be disabled if checked.",
"fieldname": "disable_feedback",
"fieldtype": "Check",
"label": "Disable Feedback"
@ -208,7 +206,7 @@
"is_published_field": "published",
"links": [],
"max_attachments": 5,
"modified": "2021-06-14 13:50:02.109719",
"modified": "2021-09-13 17:19:35.436045",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",

View file

@ -15,7 +15,9 @@
"subtitle",
"column_break_11",
"cta_label",
"cta_url"
"cta_url",
"section_break_12",
"feedback_limit"
],
"fields": [
{
@ -78,13 +80,24 @@
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_12",
"fieldtype": "Section Break"
},
{
"default": "1",
"description": "Feedback limit per hour",
"fieldname": "feedback_limit",
"fieldtype": "Int",
"label": "Feedback limit"
}
],
"icon": "fa fa-cog",
"idx": 1,
"issingle": 1,
"links": [],
"modified": "2020-06-01 15:57:21.564652",
"modified": "2021-09-30 13:00:18.887103",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Settings",

View file

@ -12,4 +12,7 @@ class BlogSettings(Document):
def on_update(self):
from frappe.website.utils import clear_cache
clear_cache("blog")
clear_cache("writers")
clear_cache("writers")
def get_feedback_limit():
return frappe.db.get_single_value("Blog Settings", "feedback_limit") or 0

View file

@ -8,6 +8,10 @@ class DiscussionReply(Document):
def after_insert(self):
replies = frappe.db.count("Discussion Reply", {"topic": self.topic})
topic_info = frappe.get_all("Discussion Topic",
{"name": self.topic},
["reference_doctype", "reference_docname", "name", "title", "owner", "creation"])
template = frappe.render_template("frappe/templates/discussions/reply_card.html", {
"reply": self,
"topic": {
@ -15,19 +19,16 @@ class DiscussionReply(Document):
},
"loop": {
"index": replies
}
},
"single_thread": True if not topic_info[0].title else False
})
topic_info = frappe.get_all("Discussion Topic",
{"name": self.topic},
["reference_doctype", "reference_docname", "name", "title", "owner", "creation"])
sidebar = frappe.render_template("frappe/templates/discussions/sidebar.html", {
"topic": topic_info[0]
})
new_topic_template = frappe.render_template("frappe/templates/discussions/reply_section.html", {
"topics": topic_info
"topic": topic_info[0]
})
frappe.publish_realtime(

View file

@ -22,10 +22,17 @@
"label": "Web Page",
"options": "Web Page",
"reqd": 1
},
{
"__unsaved": 1,
"fieldname": "single_thread",
"fieldtype": "Check",
"label": "Single Thread",
"reqd": 0
}
],
"idx": 0,
"modified": "2021-10-01 12:15:28.876920",
"modified": "2021-10-01 15:15:57.366552",
"modified_by": "Administrator",
"module": "Website",
"name": "Discussions",
@ -33,4 +40,4 @@
"standard": 1,
"template": "",
"type": "Section"
}
}