Merge branch 'frappe:develop' into primary-navbar-css-fix
This commit is contained in:
commit
349c324802
77 changed files with 1559 additions and 1018 deletions
2
.github/helper/install.sh
vendored
2
.github/helper/install.sh
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/patch-mariadb-tests.yml
vendored
2
.github/workflows/patch-mariadb-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/publish-assets-develop.yml
vendored
2
.github/workflows/publish-assets-develop.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/server-mariadb-tests.yml
vendored
2
.github/workflows/server-mariadb-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/server-postgres-tests.yml
vendored
2
.github/workflows/server-postgres-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/ui-tests.yml
vendored
2
.github/workflows/ui-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
) ;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
min-width: 500px;
|
||||
min-height: 50px;
|
||||
font-size: var(--text-md);
|
||||
z-index: 1019;
|
||||
}
|
||||
|
||||
.filter-area {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ $threshold: 34;
|
|||
|
||||
.actions {
|
||||
display: flex;
|
||||
> * {
|
||||
> *:not(.indicator-pill) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()''')
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'''
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue