diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0783e94457..e976230244 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,8 +54,8 @@ repos: hooks: - id: isort - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.2 + - repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: ['flake8-bugbear',] diff --git a/cypress/integration/permissions.js b/cypress/integration/permissions.js new file mode 100644 index 0000000000..7a13239771 --- /dev/null +++ b/cypress/integration/permissions.js @@ -0,0 +1,57 @@ +context("Permissions API", () => { + before(() => { + cy.visit("/login"); + + cy.login("Administrator"); + cy.call("frappe.tests.ui_test_helpers.add_remove_role", { + action: "remove", + user: "frappe@example.com", + role: "System Manager", + }); + cy.call("logout"); + + cy.login("frappe@example.com"); + cy.visit("/app"); + }); + + it("Checks permissions via `has_perm` for Kanban Board DocType", () => { + cy.visit("/app/kanban-board/view/list"); + cy.window() + .its("frappe") + .then((frappe) => { + frappe.model.with_doctype("Kanban Board", function () { + // needed to make sure doc meta is loaded + expect(frappe.perm.has_perm("Kanban Board", 0, "read")).to.equal(true); + expect(frappe.perm.has_perm("Kanban Board", 0, "write")).to.equal(true); + expect(frappe.perm.has_perm("Kanban Board", 0, "print")).to.equal(false); + }); + }); + }); + + it("Checks permissions via `get_perm` for Kanban Board DocType", () => { + cy.visit("/app/kanban-board/view/list"); + cy.window() + .its("frappe") + .then((frappe) => { + frappe.model.with_doctype("Kanban Board", function () { + // needed to make sure doc meta is loaded + const perms = frappe.perm.get_perm("Kanban Board"); + expect(perms.read).to.equal(true); + expect(perms.write).to.equal(true); + expect(perms.rights_without_if_owner).to.include("read"); + }); + }); + }); + + after(() => { + cy.call("logout"); + + cy.login("Administrator"); + cy.call("frappe.tests.ui_test_helpers.add_remove_role", { + action: "add", + user: "frappe@example.com", + role: "System Manager", + }); + cy.call("logout"); + }); +}); diff --git a/frappe/__init__.py b/frappe/__init__.py index 84a27642a9..2d491ca068 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1432,6 +1432,8 @@ def get_doc_hooks(): @request_cache def _load_app_hooks(app_name: str | None = None): + import types + hooks = {} apps = [app_name] if app_name else get_installed_apps(sort=True) @@ -1447,9 +1449,13 @@ def _load_app_hooks(app_name: str | None = None): if not request: raise SystemExit raise - for key in dir(app_hooks): + + def _is_valid_hook(obj): + return not isinstance(obj, (types.ModuleType, types.FunctionType, type)) + + for key, value in inspect.getmembers(app_hooks, predicate=_is_valid_hook): if not key.startswith("_"): - append_hook(hooks, key, getattr(app_hooks, key)) + append_hook(hooks, key, value) return hooks diff --git a/frappe/app.py b/frappe/app.py index 0d7fdc1fe1..fc679aa44e 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -21,7 +21,7 @@ from frappe import _ from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request from frappe.middlewares import StaticDataMiddleware -from frappe.utils import get_site_name, sanitize_html +from frappe.utils import cint, get_site_name, sanitize_html from frappe.utils.error import make_error_snapshot from frappe.website.serve import get_response @@ -112,7 +112,7 @@ def init_request(request): else: frappe.connect(set_admin_as_user=False) - request.max_content_length = frappe.local.conf.get("max_file_size") or 10 * 1024 * 1024 + request.max_content_length = cint(frappe.local.conf.get("max_file_size")) or 10 * 1024 * 1024 make_form_dict(request) diff --git a/frappe/contacts/doctype/contact/contact.json b/frappe/contacts/doctype/contact/contact.json index d342b2d794..c756a8ecb8 100644 --- a/frappe/contacts/doctype/contact/contact.json +++ b/frappe/contacts/doctype/contact/contact.json @@ -12,6 +12,7 @@ "first_name", "middle_name", "last_name", + "full_name", "email_id", "user", "address", @@ -52,8 +53,7 @@ "in_global_search": 1, "label": "First Name", "oldfieldname": "first_name", - "oldfieldtype": "Data", - "reqd": 1 + "oldfieldtype": "Data" }, { "bold": 1, @@ -243,6 +243,13 @@ "fieldname": "company_name", "fieldtype": "Data", "label": "Company Name" + }, + { + "fieldname": "full_name", + "fieldtype": "Data", + "hidden": 1, + "label": "Full Name", + "read_only": 1 } ], "icon": "fa fa-user", @@ -250,10 +257,12 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-27 14:12:09.906719", + "modified": "2022-10-27 10:40:50.097481", "modified_by": "Administrator", "module": "Contacts", "name": "Contact", + "name_case": "Title Case", + "naming_rule": "By script", "owner": "Administrator", "permissions": [ { @@ -379,6 +388,9 @@ "role": "All" } ], + "show_title_field_in_link": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [], + "title_field": "full_name" } diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index 1ed4307d8d..8540e1cfb8 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -11,10 +11,7 @@ from frappe.utils import cstr, has_gravatar class Contact(Document): def autoname(self): - # concat first and last name - self.name = " ".join( - filter(None, [cstr(self.get(f)).strip() for f in ["first_name", "last_name"]]) - ) + self.name = self._get_full_name() if frappe.db.exists("Contact", self.name): self.name = append_number_if_name_exists("Contact", self.name) @@ -25,6 +22,7 @@ class Contact(Document): break def validate(self): + self.full_name = self._get_full_name() self.set_primary_email() self.set_primary("phone") self.set_primary("mobile_no") @@ -128,6 +126,9 @@ class Contact(Document): if not primary_number_exists: setattr(self, fieldname, "") + def _get_full_name(self) -> str: + return get_full_name(self.first_name, self.middle_name, self.last_name, self.company_name) + def get_default_contact(doctype, name): """Returns default contact for the given doctype, name""" @@ -222,7 +223,7 @@ def contact_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select - `tabContact`.name, `tabContact`.first_name, `tabContact`.last_name + `tabContact`.name, `tabContact`.full_name, `tabContact`.company_name from `tabContact`, `tabDynamic Link` where @@ -233,8 +234,8 @@ def contact_query(doctype, txt, searchfield, start, page_len, filters): `tabContact`.`{key}` like %(txt)s {mcond} order by - if(locate(%(_txt)s, `tabContact`.name), locate(%(_txt)s, `tabContact`.name), 99999), - `tabContact`.idx desc, `tabContact`.name + if(locate(%(_txt)s, `tabContact`.full_name), locate(%(_txt)s, `tabContact`.company_name), 99999), + `tabContact`.idx desc, `tabContact`.full_name limit %(start)s, %(page_len)s """.format( mcond=get_match_cond(doctype), key=searchfield ), @@ -327,3 +328,16 @@ def get_contacts_linked_from(doctype, docname, fields=None): return [] return frappe.get_list("Contact", fields=fields, filters={"name": ("in", contact_names)}) + + +def get_full_name( + first: str | None = None, + middle: str | None = None, + last: str | None = None, + company: str | None = None, +) -> str: + full_name = " ".join(filter(None, [cstr(f).strip() for f in [first, middle, last]])) + if not full_name and company: + full_name = company + + return full_name diff --git a/frappe/contacts/doctype/contact/test_contact.py b/frappe/contacts/doctype/contact/test_contact.py index 6f0fd4ae3f..e91e132258 100644 --- a/frappe/contacts/doctype/contact/test_contact.py +++ b/frappe/contacts/doctype/contact/test_contact.py @@ -1,6 +1,7 @@ # Copyright (c) 2017, Frappe Technologies and Contributors # License: MIT. See LICENSE import frappe +from frappe.contacts.doctype.contact.contact import get_full_name from frappe.tests.utils import FrappeTestCase test_dependencies = ["Contact", "Salutation"] @@ -31,6 +32,18 @@ class TestContact(FrappeTestCase): self.assertEqual(contact.phone, "+91 0000000002") self.assertEqual(contact.mobile_no, "+91 0000000003") + def test_get_full_name(self): + self.assertEqual(get_full_name(first="John"), "John") + self.assertEqual(get_full_name(last="Doe"), "Doe") + self.assertEqual(get_full_name(company="Doe Pvt Ltd"), "Doe Pvt Ltd") + self.assertEqual(get_full_name(first="John", last="Doe"), "John Doe") + self.assertEqual(get_full_name(first="John", middle="Jane"), "John Jane") + self.assertEqual(get_full_name(first="John", last="Doe", company="Doe Pvt Ltd"), "John Doe") + self.assertEqual( + get_full_name(first="John", middle="Jane", last="Doe", company="Doe Pvt Ltd"), + "John Jane Doe", + ) + def create_contact(name, salutation, emails=None, phones=None, save=True): doc = frappe.get_doc( diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py index 3d886cf93b..9cad86ece1 100644 --- a/frappe/core/doctype/comment/comment.py +++ b/frappe/core/doctype/comment/comment.py @@ -44,8 +44,10 @@ class Comment(Document): return frappe.publish_realtime( - f"update_docinfo_for_{self.reference_doctype}_{self.reference_name}", + "docinfo_update", {"doc": self.as_dict(), "key": key, "action": action}, + doctype=self.reference_doctype, + docname=self.reference_name, after_commit=True, ) diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index bd85023025..9944961ca9 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -233,8 +233,10 @@ class Communication(Document, CommunicationEmailMixin): def notify_change(self, action): frappe.publish_realtime( - f"update_docinfo_for_{self.reference_doctype}_{self.reference_name}", + "docinfo_update", {"doc": self.as_dict(), "key": "communications", "action": action}, + doctype=self.reference_doctype, + docname=self.reference_name, after_commit=True, ) diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index ea90b24a6f..20a8e7db9b 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -139,6 +139,7 @@ class Importer: "skipping": True, "data_import": self.data_import.name, }, + user=frappe.session.user, ) continue @@ -166,6 +167,7 @@ class Importer: "row_indexes": row_indexes, "eta": eta, }, + user=frappe.session.user, ) create_import_log( diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 803ad3c140..f03c5506d3 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -70,6 +70,7 @@ "columns", "column_break_22", "description", + "documentation_url", "oldfieldname", "oldfieldtype" ], @@ -541,13 +542,19 @@ "fieldname": "is_virtual", "fieldtype": "Check", "label": "Virtual" + }, + { + "depends_on": "eval:!in_list([\"Tab Break\", \"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)", + "fieldname": "documentation_url", + "fieldtype": "Small Text", + "label": "Documentation URL" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-04-19 12:27:28.641580", + "modified": "2022-11-17 14:14:39.404696", "modified_by": "Administrator", "module": "Core", "name": "DocField", @@ -557,4 +564,4 @@ "sort_field": "modified", "sort_order": "ASC", "states": [] -} +} \ No newline at end of file diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 0e28145c9a..0c21501589 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -101,8 +101,8 @@ class File(Document): if not self.attached_to_doctype: return - if self.attached_to_name and not isinstance(self.attached_to_name, str): - frappe.throw(_("Attached To Name must be a string"), TypeError) + if not self.attached_to_name or not isinstance(self.attached_to_name, (str, int)): + frappe.throw(_("Attached To Name must be a string or an integer"), frappe.ValidationError) if not self.attached_to_field: return diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index ed97c5683d..86bd69eb5f 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -85,7 +85,7 @@ class TestBase64File(FrappeTestCase): "doctype": "File", "file_name": "test_base64.txt", "attached_to_doctype": self.attached_to_doctype, - "attached_to_docname": self.attached_to_docname, + "attached_to_name": self.attached_to_docname, "content": self.test_content, "decode": True, } diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 7020640da4..63c1f40512 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -18,11 +18,11 @@ class UserPermission(Document): def on_update(self): frappe.cache().hdel("user_permissions", self.user) - frappe.publish_realtime("update_user_permissions") + frappe.publish_realtime("update_user_permissions", user=self.user, after_commit=True) - def on_trash(self): # pylint: disable=no-self-use + def on_trash(self): frappe.cache().hdel("user_permissions", self.user) - frappe.publish_realtime("update_user_permissions") + frappe.publish_realtime("update_user_permissions", user=self.user, after_commit=True) def validate_user_permission(self): """checks for duplicate user permission records""" diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 77d40f44d2..271f2b4074 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -155,8 +155,6 @@ def clear_notifications(user=None): else: cache.delete_key("notification_count:" + name) - frappe.publish_realtime("clear_notifications") - def clear_notification_config(user): frappe.cache().hdel("notification_config", user) @@ -164,7 +162,6 @@ def clear_notification_config(user): def delete_notification_count_for(doctype): frappe.cache().delete_key("notification_count:" + doctype) - frappe.publish_realtime("clear_notifications") def clear_doctype_notifications(doc, method=None, *args, **kwargs): diff --git a/frappe/desk/search.py b/frappe/desk/search.py index b820fabd6d..4843219179 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -206,11 +206,16 @@ def search_widget( ) order_by = f"_relevance, {order_by}" - ptype = "select" if frappe.only_has_select_perm(doctype) else "read" ignore_permissions = ( True if doctype == "DocType" - else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype)) + else ( + cint(ignore_user_permissions) + and has_permission( + doctype, + ptype="select" if frappe.only_has_select_perm(doctype) else "read", + ) + ) ) values = frappe.get_list( diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index c69d653c96..1db45604e1 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -486,12 +486,6 @@ class EmailAccount(Document): else: frappe.db.commit() - # notify if user is linked to account - if len(inbound_mails) > 0 and not frappe.local.flags.in_test: - frappe.publish_realtime( - "new_email", {"account": self.email_account_name, "number": len(inbound_mails)} - ) - if exceptions: raise Exception(frappe.as_json(exceptions)) diff --git a/frappe/installer.py b/frappe/installer.py index 2a6c29a17f..0cd9b32063 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -3,8 +3,12 @@ import json import os +import re +import subprocess import sys from collections import OrderedDict +from contextlib import suppress +from shutil import which import click @@ -653,10 +657,22 @@ 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 + # This change was reverted by mariadb in 10.6.6 + # Ref: https://mariadb.com/kb/en/innodb-compressed-row-format/#read-only from pathlib import Path from frappe.utils import random_string + version = _guess_mariadb_version() + if not version or (version <= (10, 6, 0) or version >= (10, 6, 6)): + return + + click.secho( + "MariaDB version being used does not support ROW_FORMAT=COMPRESSED, " + "converting into DYNAMIC format.", + fg="yellow", + ) + old_sql_file_path = Path(f"{sql_file_path}_{random_string(10)}") sql_file_path = Path(sql_file_path) @@ -684,6 +700,20 @@ def extract_sql_gzip(sql_gz_path): return decompressed_file +def _guess_mariadb_version() -> tuple[int] | None: + # Using command-line because we *might* not have a connection yet and this command is required + # in non-interactive mode. + # Use db.sql("select version()") instead if connection is available. + with suppress(Exception): + mysql = which("mysql") + version_output = subprocess.getoutput(f"{mysql} --version") + version_regex = r"(?P\d+\.\d+\.\d+)-MariaDB" + + version = re.search(version_regex, version_output).group("version") + + return tuple(int(v) for v in version.split(".")) + + def extract_files(site_name, file_path): import shutil import subprocess diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 3e6b8ec753..e689f91ddd 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -455,10 +455,10 @@ class DatabaseQuery: ) def check_read_permission(self, doctype): - ptype = "select" if frappe.only_has_select_perm(doctype) else "read" - if not self.flags.ignore_permissions and not frappe.has_permission( - doctype, ptype=ptype, parent_doctype=self.doctype + doctype, + ptype="select" if frappe.only_has_select_perm(doctype) else "read", + parent_doctype=self.doctype, ): frappe.flags.error_message = _("Insufficient Permission for {0}").format(frappe.bold(doctype)) raise frappe.PermissionError(doctype) diff --git a/frappe/patches.txt b/frappe/patches.txt index 278a351093..e8289a2790 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -215,3 +215,4 @@ frappe.patches.v14_0.drop_unused_indexes frappe.patches.v15_0.drop_modified_index frappe.patches.v14_0.add_manage_subscriptions_in_navbar_settings frappe.patches.v14_0.update_attachment_comment +frappe.patches.v15_0.set_contact_full_name diff --git a/frappe/patches/v15_0/set_contact_full_name.py b/frappe/patches/v15_0/set_contact_full_name.py new file mode 100644 index 0000000000..6dc3036f34 --- /dev/null +++ b/frappe/patches/v15_0/set_contact_full_name.py @@ -0,0 +1,25 @@ +import frappe +from frappe.contacts.doctype.contact.contact import get_full_name +from frappe.utils import update_progress_bar + + +def execute(): + """Set full name for all contacts""" + frappe.db.auto_commit_on_many_writes = 1 + + contacts = frappe.get_all( + "Contact", + fields=["name", "first_name", "middle_name", "last_name", "company_name"], + filters={"full_name": ("is", "not set")}, + as_list=True, + ) + total = len(contacts) + for idx, (name, first, middle, last, company) in enumerate(contacts): + update_progress_bar("Setting full name for contacts", idx, total) + frappe.db.set_value( + "Contact", + name, + "full_name", + get_full_name(first, middle, last, company), + update_modified=False, + ) diff --git a/frappe/printing/page/print_format_builder/print_format_builder.js b/frappe/printing/page/print_format_builder/print_format_builder.js index 4154861f84..657904f32e 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.js +++ b/frappe/printing/page/print_format_builder/print_format_builder.js @@ -280,10 +280,7 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder { set_section(f.label); } else if (f.fieldtype === "Column Break") { set_column(); - } else if ( - !in_list(["Section Break", "Column Break", "Tab Break", "Fold"], f.fieldtype) && - f.label - ) { + } else if (!in_list(frappe.model.layout_fields, f.fieldtype)) { if (!column) set_column(); if (f.fieldtype === "Table") { diff --git a/frappe/printing/page/print_format_builder/print_format_builder_field.html b/frappe/printing/page/print_format_builder/print_format_builder_field.html index beb9de2f5a..90eb4f1f0a 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder_field.html +++ b/frappe/printing/page/print_format_builder/print_format_builder_field.html @@ -34,7 +34,7 @@ - {{ __(field.label) }} + {{ __(field.label) || __(field.fieldname) }} ({%= __("Table") %}) diff --git a/frappe/public/icons/timeless/icons.svg b/frappe/public/icons/timeless/icons.svg index af63d0c8ff..663eb6da48 100644 --- a/frappe/public/icons/timeless/icons.svg +++ b/frappe/public/icons/timeless/icons.svg @@ -859,6 +859,12 @@ + + + + + + ').appendTo(this.parent); } else { this.$wrapper = $( - '
\ -
\ -
\ - \ -
\ -
\ -
\ - \ -

\ -
\ -
\ -
' + `
+
+
+ + +
+
+
+ +

+
+
+
` ).appendTo(this.parent); } } @@ -79,7 +80,7 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control if (me.frm) { me.value = frappe.model.get_value(me.doctype, me.docname, me.df.fieldname); } else if (me.doc) { - me.value = me.doc[me.df.fieldname]; + me.value = me.doc[me.df.fieldname] || ""; } if (me.can_write()) { @@ -104,6 +105,7 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control me.set_description(); me.set_label(); + me.set_doc_url(); me.set_mandatory(me.value); me.set_bold(); me.set_required(); @@ -141,6 +143,26 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control (icon ? ' ' : "") + __(this.df.label) || " "; this._label = this.df.label; } + + set_doc_url() { + let unsupported_fieldtypes = frappe.model.no_value_type.filter( + (x) => frappe.model.table_fields.indexOf(x) === -1 + ); + + if ( + !this.df.label || + !this.df?.documentation_url || + in_list(unsupported_fieldtypes, this.df.fieldtype) + ) + return; + + let $help = this.$wrapper.find("span.help"); + $help.empty(); + $(`
+ ${frappe.utils.icon("help", "sm")} + `).appendTo($help); + } + set_description(description) { if (description !== undefined) { this.df.description = description; diff --git a/frappe/public/js/frappe/form/controls/check.js b/frappe/public/js/frappe/form/controls/check.js index 91409ce4e0..46c09b6139 100644 --- a/frappe/public/js/frappe/form/controls/check.js +++ b/frappe/public/js/frappe/form/controls/check.js @@ -8,6 +8,7 @@ frappe.ui.form.ControlCheck = class ControlCheck extends frappe.ui.form.ControlD +

diff --git a/frappe/public/js/frappe/form/controls/signature.js b/frappe/public/js/frappe/form/controls/signature.js index 167695ac10..0cbc1f3c26 100644 --- a/frappe/public/js/frappe/form/controls/signature.js +++ b/frappe/public/js/frappe/form/controls/signature.js @@ -8,6 +8,7 @@ frappe.ui.form.ControlSignature = class ControlSignature extends frappe.ui.form. if (this.df.label) { $(this.wrapper).find("label").text(__(this.df.label)); } + this.set_doc_url(); frappe.require("/assets/frappe/js/lib/jSignature.min.js").then(() => { // make jSignature field diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 4f119f2551..767d71d991 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1750,7 +1750,7 @@ frappe.ui.form.Form = class FrappeForm { if (this.meta.title_field) { return this.doc[this.meta.title_field]; } else { - return this.doc.name; + return String(this.doc.name); } } @@ -1942,19 +1942,26 @@ frappe.ui.form.Form = class FrappeForm { setup_docinfo_change_listener() { let doctype = this.doctype; let docname = this.docname; - let listener_name = `update_docinfo_for_${doctype}_${docname}`; - // to avoid duplicates - frappe.realtime.off(listener_name); - frappe.realtime.on(listener_name, ({ doc, key, action = "update" }) => { - let doc_list = frappe.model.docinfo[doctype][docname][key] || []; - if (action === "add") { - frappe.model.docinfo[doctype][docname][key].push(doc); - } + frappe.socketio.doc_subscribe(doctype, docname); + frappe.realtime.off("docinfo_update"); + frappe.realtime.on("docinfo_update", ({ doc, key, action = "update" }) => { + if ( + !doc.reference_doctype || + !doc.reference_name || + doc.reference_doctype !== doctype || + doc.reference_name !== docname + ) { + return; + } + let doc_list = frappe.model.docinfo[doctype][docname][key] || []; let docindex = doc_list.findIndex((old_doc) => { return old_doc.name === doc.name; }); + if (action === "add") { + frappe.model.docinfo[doctype][docname][key].push(doc); + } if (docindex > -1) { if (action === "update") { frappe.model.docinfo[doctype][docname][key].splice(docindex, 1, doc); diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 2ffba97efa..d0c352d784 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -62,6 +62,7 @@ export default class Grid { make() { let template = ` +

@@ -119,6 +120,7 @@ export default class Grid { this.wrapper = $(template).appendTo(this.parent); $(this.parent).addClass("form-group"); this.set_grid_description(); + this.set_doc_url(); frappe.utils.bind_actions_with_object(this.wrapper, this); @@ -148,6 +150,26 @@ export default class Grid { description_wrapper.hide(); } } + + set_doc_url() { + let unsupported_fieldtypes = frappe.model.no_value_type.filter( + (x) => frappe.model.table_fields.indexOf(x) === -1 + ); + + if ( + !this.df.label || + !this.df?.documentation_url || + in_list(unsupported_fieldtypes, this.df.fieldtype) + ) + return; + + let $help = $(this.parent).find("span.help"); + $help.empty(); + $(` + ${frappe.utils.icon("help", "sm")} + `).appendTo($help); + } + setup_grid_pagination() { this.grid_pagination = new GridPagination({ grid: this, diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index f9f09187bc..96140bbe9a 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -649,13 +649,19 @@ export default class GridRow { this.search_columns = {}; this.grid.setup_visible_columns(); + let fields = + this.grid.user_defined_columns && this.grid.user_defined_columns.length > 0 + ? this.grid.user_defined_columns + : this.docfields; + this.grid.visible_columns.forEach((col, ci) => { // to get update df for the row - let df = this.docfields.find((field) => field.fieldname === col[0].fieldname); + let df = fields.find((field) => field.fieldname === col[0].fieldname); this.set_dependant_property(df); let colsize = col[1]; + let txt = this.doc ? frappe.format(this.doc[df.fieldname], df, null, this.doc) : __(df.label); @@ -1348,7 +1354,12 @@ export default class GridRow { } } refresh_field(fieldname, txt) { - let df = this.docfields.find((col) => { + let fields = + this.grid.user_defined_columns && this.grid.user_defined_columns.length > 0 + ? this.grid.user_defined_columns + : this.docfields; + + let df = fields.find((col) => { return col.fieldname === fieldname; }); diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 52d0026e37..c0bd534140 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1315,7 +1315,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if (this.list_view_settings && this.list_view_settings.disable_auto_refresh) { return; } + frappe.socketio.list_subscribe(this.doctype); frappe.realtime.on("list_update", (data) => { + if (!frappe.get_doc(data?.doctype, data?.name)?.__unsaved) { + frappe.model.remove_from_locals(data.doctype, data.name); + } + if (this.avoid_realtime_update()) { return; } diff --git a/frappe/public/js/frappe/model/perm.js b/frappe/public/js/frappe/model/perm.js index 6931a2e2e7..fdd915ebfc 100644 --- a/frappe/public/js/frappe/model/perm.js +++ b/frappe/public/js/frappe/model/perm.js @@ -37,18 +37,23 @@ $.extend(frappe.perm, { has_perm: (doctype, permlevel, ptype, doc) => { if (!permlevel) permlevel = 0; - if (!frappe.perm.doctype_perm[doctype]) { - frappe.perm.doctype_perm[doctype] = frappe.perm.get_perm(doctype, doc); - } - let perms = frappe.perm.doctype_perm[doctype]; - - if (!perms || !perms[permlevel]) return false; - - return !!perms[permlevel][ptype]; + const perms = frappe.perm.get_perm(doctype, doc); + return !!perms?.[permlevel]?.[ptype]; }, get_perm: (doctype, doc) => { + // if document object is passed, get fresh doc based perms + // (with ownership and user perms applied) else cached doctype perms + + if (doc && !doc.__islocal) { + return frappe.perm._get_perm(doctype, doc); + } + + return (frappe.perm.doctype_perm[doctype] ??= frappe.perm._get_perm(doctype)); + }, + + _get_perm: (doctype, doc) => { let perm = [{ read: 0, permlevel: 0 }]; let meta = frappe.get_doc("DocType", doctype); @@ -61,77 +66,83 @@ $.extend(frappe.perm, { if (!meta) return perm; perm = frappe.perm.get_role_permissions(meta); + const base_perm = perm[0]; if (doc) { // apply user permissions via docinfo (which is processed server-side) let docinfo = frappe.model.get_docinfo(doctype, doc.name); if (docinfo && docinfo.permissions) { Object.keys(docinfo.permissions).forEach((ptype) => { - perm[0][ptype] = docinfo.permissions[ptype]; + base_perm[ptype] = docinfo.permissions[ptype]; }); } // if owner - if (!$.isEmptyObject(perm[0].if_owner)) { - if (doc.owner === user) { - $.extend(perm[0], perm[0].if_owner); - } else { - // not owner, remove permissions - $.each(perm[0].if_owner, (ptype) => { - if (perm[0].if_owner[ptype]) { - perm[0][ptype] = 0; - } - }); + if (doc.owner !== user) { + for (const right of frappe.perm.rights) { + if (base_perm[right] && !base_perm.rights_without_if_owner.has(right)) { + base_perm[right] = 0; + } } } // apply permissions from shared if (docinfo && docinfo.shared) { - for (let i = 0; i < docinfo.shared.length; i++) { - let s = docinfo.shared[i]; - if (s.user === user) { - perm[0]["read"] = perm[0]["read"] || s.read; - perm[0]["write"] = perm[0]["write"] || s.write; - perm[0]["submit"] = perm[0]["submit"] || s.submit; - perm[0]["share"] = perm[0]["share"] || s.share; + for (const s of docinfo.shared) { + if (s.user !== user) continue; - if (s.read) { - // also give print, email permissions if read - // and these permissions exist at level [0] - perm[0].email = - frappe.boot.user.can_email.indexOf(doctype) !== -1 ? 1 : 0; - perm[0].print = - frappe.boot.user.can_print.indexOf(doctype) !== -1 ? 1 : 0; - } + for (const right of ["read", "write", "submit", "share"]) { + if (!base_perm[right]) base_perm[right] = s[right]; + } + + if (s.read) { + // also give print, email permissions if read + // and these permissions exist at level [0] + base_perm.email = + frappe.boot.user.can_email.indexOf(doctype) !== -1 ? 1 : 0; + base_perm.print = + frappe.boot.user.can_print.indexOf(doctype) !== -1 ? 1 : 0; } } } } - if (frappe.model.can_read(doctype) && !perm[0].read) { + if (!base_perm.read && frappe.model.can_read(doctype)) { // read via sharing - perm[0].read = 1; + base_perm.read = 1; } return perm; }, get_role_permissions: (meta) => { + /** Returns a `dict` of evaluated Role Permissions like: + { + "read": 1, + "write": 0, + "rights_without_if_owner": {"read", "write"} // for permlevel 0 + } + */ + let perm = [{ read: 0, permlevel: 0 }]; - // Returns a `dict` of evaluated Role Permissions + (meta.permissions || []).forEach((p) => { - // if user has this role - let permlevel = cint(p.permlevel); - if (!perm[permlevel]) { - perm[permlevel] = {}; - perm[permlevel]["permlevel"] = permlevel; + const permlevel = cint(p.permlevel); + const current_perm = (perm[permlevel] ??= { permlevel }); + + if (permlevel === 0) { + current_perm.rights_without_if_owner ??= new Set(); } + // if user has this role if (frappe.user_roles.includes(p.role)) { frappe.perm.rights.forEach((right) => { - let value = perm[permlevel][right] || p[right] || 0; - if (value) { - perm[permlevel][right] = value; + if (!p[right]) return; + + current_perm[right] = 1; + + if (permlevel === 0 && !p.if_owner) { + current_perm.rights_without_if_owner.add(right); } }); } @@ -169,7 +180,8 @@ $.extend(frappe.perm, { } } - if (perm[0].if_owner && perm[0].read) { + const base_perm = perm[0]; + if (base_perm.read && !base_perm.rights_without_if_owner.has("read")) { match_rules.push({ Owner: frappe.session.user }); } return match_rules; diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index 2005a46cfe..f6301085d7 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -12,6 +12,7 @@ frappe.route_history = []; frappe.view_factory = {}; frappe.view_factories = []; frappe.route_options = null; +frappe.open_in_new_tab = false; frappe.route_hooks = {}; $(window).on("hashchange", function (e) { @@ -347,8 +348,13 @@ frappe.router = { 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); - + if (frappe.open_in_new_tab) { + localStorage["route_options"] = JSON.stringify(frappe.route_options); + window.open(sub_path, "_blank"); + frappe.open_in_new_tab = false; + } else { + this.push_state(sub_path); + } setTimeout(() => { frappe.after_ajax && frappe.after_ajax(() => { @@ -493,6 +499,11 @@ frappe.router = { frappe.route_options = {}; } + if (localStorage.getItem("route_options")) { + frappe.route_options = JSON.parse(localStorage.getItem("route_options")); + localStorage.removeItem("route_options"); + } + let params = new URLSearchParams(query_string); for (const [key, value] of params) { frappe.route_options[key] = value; diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js index 9e290ede0b..1ac875544c 100644 --- a/frappe/public/js/frappe/socketio_client.js +++ b/frappe/public/js/frappe/socketio_client.js @@ -3,6 +3,7 @@ frappe.socketio = { open_tasks: {}, open_docs: [], emit_queue: [], + init: function (port = 3000) { if (frappe.boot.disable_async) { return; @@ -17,14 +18,12 @@ frappe.socketio = { frappe.socketio.socket = io.connect(frappe.socketio.get_host(port), { secure: true, withCredentials: true, + reconnectionAttempts: 3, }); } else if (window.location.protocol == "http:") { frappe.socketio.socket = io.connect(frappe.socketio.get_host(port), { withCredentials: true, - }); - } else if (window.location.protocol == "file:") { - frappe.socketio.socket = io.connect(window.localStorage.server, { - withCredentials: true, + reconnectionAttempts: 3, }); } @@ -130,6 +129,9 @@ frappe.socketio = { task_unsubscribe: function (task_id) { frappe.socketio.socket.emit("task_unsubscribe", task_id); }, + list_subscribe: function (doctype) { + frappe.socketio.socket.emit("list_update", doctype); + }, doc_subscribe: function (doctype, docname) { if (frappe.flags.doc_subscribe) { console.log("throttled"); diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index e381f332a9..2cb4f41038 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -463,7 +463,12 @@ frappe.ui.Page = class Page { `); } - $link = $li.find("a").on("click", click); + $link = $li.find("a").on("click", (e) => { + if (e.ctrlKey || e.metaKey) { + frappe.open_in_new_tab = true; + } + return click(); + }); if (standard) { $li.appendTo(parent); diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index e8eaf25412..4a3c88403f 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -107,6 +107,10 @@ frappe.search.AwesomeBar = class AwesomeBar { if (item.onclick) { item.onclick(item.match); } else { + let event = o.originalEvent; + if (event.ctrlKey || event.metaKey) { + frappe.open_in_new_tab = true; + } frappe.set_route(item.route); } $input.val(""); diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index af7e518678..2e4f09caf8 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -56,6 +56,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { if (this.list_view_settings?.disable_auto_refresh) { return; } + frappe.socketio.list_subscribe(this.doctype); frappe.realtime.on("list_update", (data) => this.on_update(data)); } diff --git a/frappe/public/js/frappe/web_form/webform_script.js b/frappe/public/js/frappe/web_form/webform_script.js index f24e10cff3..8a5d123d2f 100644 --- a/frappe/public/js/frappe/web_form/webform_script.js +++ b/frappe/public/js/frappe/web_form/webform_script.js @@ -55,7 +55,7 @@ frappe.ready(function () { function setup_fields(web_form_doc, doc_data) { web_form_doc.web_form_fields.forEach((df) => { df.is_web_form = true; - df.read_only = !web_form_doc.is_new && !web_form_doc.in_edit_mode; + df.read_only = df.read_only || (!web_form_doc.is_new && !web_form_doc.in_edit_mode); if (df.fieldtype === "Table") { df.get_data = () => { let data = []; diff --git a/frappe/public/js/frappe/widgets/quick_list_widget.js b/frappe/public/js/frappe/widgets/quick_list_widget.js index dbb26cb62c..a406075af2 100644 --- a/frappe/public/js/frappe/widgets/quick_list_widget.js +++ b/frappe/public/js/frappe/widgets/quick_list_widget.js @@ -161,7 +161,10 @@ export default class QuickListWidget extends Widget { $quick_list_item ); - $quick_list_item.click(() => { + $quick_list_item.click((e) => { + if (e.ctrlKey || e.metaKey) { + frappe.open_in_new_tab = true; + } frappe.set_route(`${frappe.utils.get_form_link(this.document_type, doc.name)}`); }); @@ -243,7 +246,14 @@ export default class QuickListWidget extends Widget { } let route = frappe.utils.generate_route({ type: "doctype", name: this.document_type }); this.see_all_button = $(` - ${__("View List")} +
${__("View List")}
`).appendTo(this.footer); + + this.see_all_button.click((e) => { + if (e.ctrlKey || e.metaKey) { + frappe.open_in_new_tab = true; + } + frappe.set_route(route); + }); } } diff --git a/frappe/public/js/frappe/widgets/shortcut_widget.js b/frappe/public/js/frappe/widgets/shortcut_widget.js index 5a4c7fdb88..d82f63a035 100644 --- a/frappe/public/js/frappe/widgets/shortcut_widget.js +++ b/frappe/public/js/frappe/widgets/shortcut_widget.js @@ -24,7 +24,7 @@ export default class ShortcutWidget extends Widget { } setup_events() { - this.widget.click(() => { + this.widget.click((e) => { if (this.in_customize_mode) return; let route = frappe.utils.generate_route({ @@ -40,6 +40,11 @@ export default class ShortcutWidget extends Widget { if (this.type == "DocType" && filters) { frappe.route_options = filters; } + + if (e.ctrlKey || e.metaKey) { + frappe.open_in_new_tab = true; + } + frappe.set_route(route); }); } diff --git a/frappe/public/scss/common/awesomeplete.scss b/frappe/public/scss/common/awesomeplete.scss index 17f33d7e82..e8fb7dc6ad 100644 --- a/frappe/public/scss/common/awesomeplete.scss +++ b/frappe/public/scss/common/awesomeplete.scss @@ -30,7 +30,7 @@ left: 0; margin: 0; padding: var(--padding-xs); - z-index: 1; + z-index: 4; min-width: 250px; &> li { diff --git a/frappe/public/scss/desk/global_search.scss b/frappe/public/scss/desk/global_search.scss index 396b685d30..de5abb67c7 100644 --- a/frappe/public/scss/desk/global_search.scss +++ b/frappe/public/scss/desk/global_search.scss @@ -11,7 +11,7 @@ width: 100%; .modal-actions { - z-index: 4; + z-index: 6; top: 7px; } @@ -31,7 +31,7 @@ .search-icon { position: absolute; top: 15px; - z-index: 4; + z-index: 6; } } .search-results { diff --git a/frappe/public/scss/desk/page.scss b/frappe/public/scss/desk/page.scss index b0a24eed38..fc77cc2b05 100644 --- a/frappe/public/scss/desk/page.scss +++ b/frappe/public/scss/desk/page.scss @@ -84,7 +84,7 @@ } .page-head { - z-index: 4; + z-index: 6; position: sticky; top: var(--navbar-height); background: var(--bg-color); diff --git a/frappe/public/scss/website/web_form.scss b/frappe/public/scss/website/web_form.scss index e5a0093307..8ecf753384 100644 --- a/frappe/public/scss/website/web_form.scss +++ b/frappe/public/scss/website/web_form.scss @@ -109,15 +109,27 @@ .form-column { padding: 0 var(--padding-sm); - .frappe-control[data-fieldtype="Rating"] { - .like-disabled-input { - background-color: unset; - padding-left: 0px; + .frappe-control { + position: relative; - .rating { - cursor: default; + &[data-fieldtype="Rating"] { + .like-disabled-input { + background-color: unset; + padding-left: 0px; + + .rating { + cursor: default; + } } } + + .selected-phone { + top: calc(50% + 4px); + } + + .selected-color { + top: 6px; + } } &:first-child { diff --git a/frappe/realtime.py b/frappe/realtime.py index cac3078ac6..1d75f9d9e6 100644 --- a/frappe/realtime.py +++ b/frappe/realtime.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import os +from contextlib import suppress import redis @@ -22,14 +23,14 @@ def publish_progress(percent, title=None, doctype=None, docname=None, descriptio def publish_realtime( - event=None, - message=None, - room=None, - user=None, - doctype=None, - docname=None, - task_id=None, - after_commit=False, + event: str = None, + message: dict = None, + room: str = None, + user: str = None, + doctype: str = None, + docname: str = None, + task_id: str = None, + after_commit: bool = False, ): """Publish real-time updates @@ -44,29 +45,31 @@ def publish_realtime( message = {} if event is None: - if getattr(frappe.local, "task_id", None): - event = "task_progress" - else: - event = "global" - - if event == "msgprint" and not user: + event = "task_progress" if frappe.local.task_id else "global" + elif event == "msgprint" and not user: user = frappe.session.user + elif event == "list_update": + doctype = doctype or message.get("doctype") + room = get_doctype_room(doctype) + elif event == "docinfo_update": + room = get_doc_room(doctype, docname) + + if not task_id and hasattr(frappe.local, "task_id"): + task_id = frappe.local.task_id if not room: - if not task_id and hasattr(frappe.local, "task_id"): - task_id = frappe.local.task_id - if task_id: - room = get_task_progress_room(task_id) - if not "task_id" in message: - message["task_id"] = task_id - after_commit = False + if "task_id" not in message: + message["task_id"] = task_id + room = get_task_progress_room(task_id) elif user: + # transmit to specific user: System, Website or Guest room = get_user_room(user) elif doctype and docname: room = get_doc_room(doctype, docname) else: + # This will be broadcasted to all Desk users room = get_site_room() if after_commit: @@ -83,13 +86,10 @@ def emit_via_redis(event, message, room): :param event: Event name, like `task_progress` etc. :param message: JSON message object. For async must contain `task_id` :param room: name of the room""" - r = get_redis_server() - try: + with suppress(redis.exceptions.ConnectionError): + r = get_redis_server() r.publish("events", frappe.as_json({"event": event, "message": message, "room": room})) - except redis.exceptions.ConnectionError: - # print(frappe.get_traceback()) - pass def get_redis_server(): @@ -117,27 +117,47 @@ def can_subscribe_doc(doctype, docname): return True +@frappe.whitelist(allow_guest=True) +def can_subscribe_list(doctype): + from frappe.exceptions import PermissionError + + if not frappe.has_permission(user=frappe.session.user, doctype=doctype, ptype="read"): + raise PermissionError() + + return True + + @frappe.whitelist(allow_guest=True) def get_user_info(): from frappe.sessions import Session session = Session(None, resume=True).get_session_data() + return { "user": session.user, + "user_type": session.user_type, } +def get_doctype_room(doctype): + return f"{frappe.local.site}:doctype:{doctype}" + + def get_doc_room(doctype, docname): - return "".join([frappe.local.site, ":doc:", doctype, "/", cstr(docname)]) + return f"{frappe.local.site}:doc:{doctype}/{cstr(docname)}" def get_user_room(user): - return "".join([frappe.local.site, ":user:", user]) + return f"{frappe.local.site}:user:{user}" def get_site_room(): - return "".join([frappe.local.site, ":all"]) + return f"{frappe.local.site}:all" def get_task_progress_room(task_id): - return "".join([frappe.local.site, ":task_progress:", task_id]) + return f"{frappe.local.site}:task_progress:{task_id}" + + +def get_website_room(): + return f"{frappe.local.site}:website" diff --git a/frappe/recorder.py b/frappe/recorder.py index 9f6450b2b1..6df3077fa5 100644 --- a/frappe/recorder.py +++ b/frappe/recorder.py @@ -101,7 +101,9 @@ class Recorder: } frappe.cache().hset(RECORDER_REQUEST_SPARSE_HASH, self.uuid, request_data) frappe.publish_realtime( - event="recorder-dump-event", message=json.dumps(request_data, default=str) + event="recorder-dump-event", + message=json.dumps(request_data, default=str), + user="Administrator", ) self.mark_duplicates() diff --git a/frappe/social/doctype/energy_point_log/energy_point_log.py b/frappe/social/doctype/energy_point_log/energy_point_log.py index e888e9b53f..658d333c44 100644 --- a/frappe/social/doctype/energy_point_log/energy_point_log.py +++ b/frappe/social/doctype/energy_point_log/energy_point_log.py @@ -34,10 +34,11 @@ class EnergyPointLog(Document): def after_insert(self): alert_dict = get_alert_dict(self) if alert_dict: - frappe.publish_realtime("energy_point_alert", message=alert_dict, user=self.user) + frappe.publish_realtime( + "energy_point_alert", message=alert_dict, user=self.user, after_commit=True + ) frappe.cache().hdel("energy_points", self.user) - frappe.publish_realtime("update_points", after_commit=True) if self.type != "Review" and frappe.get_cached_value( "Notification Settings", self.user, "energy_points_system_notifications" diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index 4cd39f4dd5..2179f4cf13 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -67,24 +67,41 @@ class TestWebsite(FrappeTestCase): self.assertEqual(get_home_page(), "login") frappe.set_user("Administrator") + from frappe import get_hooks + + def patched_get_hooks(hook, value): + def wrapper(*args, **kwargs): + return_value = get_hooks(*args, **kwargs) + if args[0] == hook: + return_value = value + return return_value + + return wrapper + # test homepage via hooks clear_website_cache() - set_home_page_hook( - "get_website_user_home_page", "frappe.www._test._test_home_page.get_website_user_home_page" - ) - self.assertEqual(get_home_page(), "_test/_test_folder") + with patch.object( + frappe, + "get_hooks", + patched_get_hooks( + "get_website_user_home_page", ["frappe.www._test._test_home_page.get_website_user_home_page"] + ), + ): + self.assertEqual(get_home_page(), "_test/_test_folder") clear_website_cache() - set_home_page_hook("website_user_home_page", "login") - self.assertEqual(get_home_page(), "login") + with patch.object(frappe, "get_hooks", patched_get_hooks("website_user_home_page", ["login"])): + self.assertEqual(get_home_page(), "login") clear_website_cache() - set_home_page_hook("home_page", "about") - self.assertEqual(get_home_page(), "about") + with patch.object(frappe, "get_hooks", patched_get_hooks("home_page", ["about"])): + self.assertEqual(get_home_page(), "about") clear_website_cache() - set_home_page_hook("role_home_page", {"home-page-test": "home-page-test"}) - self.assertEqual(get_home_page(), "home-page-test") + with patch.object( + frappe, "get_hooks", patched_get_hooks("role_home_page", {"home-page-test": ["home-page-test"]}) + ): + self.assertEqual(get_home_page(), "home-page-test") def test_page_load(self): set_request(method="POST", path="login") @@ -196,24 +213,26 @@ class TestWebsite(FrappeTestCase): frappe.cache().delete_key("app_hooks") def test_custom_page_renderer(self): - import frappe.hooks + from frappe import get_hooks - frappe.hooks.page_renderer = ["frappe.tests.test_website.CustomPageRenderer"] - frappe.cache().delete_key("app_hooks") - set_request(method="GET", path="/custom") - response = get_response() - self.assertEqual(response.status_code, 3984) + def patched_get_hooks(*args, **kwargs): + return_value = get_hooks(*args, **kwargs) + if args and args[0] == "page_renderer": + return_value = ["frappe.tests.test_website.CustomPageRenderer"] + return return_value - set_request(method="GET", path="/new") - content = get_response_content() - self.assertIn("
Custom Page Response
", content) + with patch.object(frappe, "get_hooks", patched_get_hooks): + set_request(method="GET", path="/custom") + response = get_response() + self.assertEqual(response.status_code, 3984) - set_request(method="GET", path="/random") - response = get_response() - self.assertEqual(response.status_code, 404) + set_request(method="GET", path="/new") + content = get_response_content() + self.assertIn("
Custom Page Response
", content) - delattr(frappe.hooks, "page_renderer") - frappe.cache().delete_key("app_hooks") + set_request(method="GET", path="/random") + response = get_response() + self.assertEqual(response.status_code, 404) def test_printview_page(self): frappe.db.value_cache[("DocType", "Language", "name")] = (("Language",),) @@ -333,22 +352,35 @@ class TestWebsite(FrappeTestCase): frappe.render_template(content, context), 'Test' ) + def test_app_include(self): + from frappe import get_hooks -def set_home_page_hook(key, value): - from frappe import hooks + def patched_get_hooks(*args, **kwargs): + return_value = get_hooks(*args, **kwargs) + if isinstance(return_value, dict) and "app_include_js" in return_value: + return_value.app_include_js.append("test_app_include.js") + return_value.app_include_css.append("test_app_include.css") + return return_value - # reset home_page hooks - for hook in ( - "get_website_user_home_page", - "website_user_home_page", - "role_home_page", - "home_page", - ): - if hasattr(hooks, hook): - delattr(hooks, hook) + with patch.object(frappe, "get_hooks", patched_get_hooks): + frappe.set_user("Administrator") + frappe.hooks.app_include_js.append("test_app_include.js") + frappe.hooks.app_include_css.append("test_app_include.css") + frappe.conf.update({"app_include_js": ["test_app_include_via_site_config.js"]}) + frappe.conf.update({"app_include_css": ["test_app_include_via_site_config.css"]}) - setattr(hooks, key, value) - frappe.cache().delete_key("app_hooks") + set_request(method="GET", path="/app") + content = get_response_content("/app") + self.assertIn('', content) + self.assertIn( + '', content + ) + self.assertIn('', content) + self.assertIn( + '', content + ) + delattr(frappe.local, "request") + frappe.set_user("Guest") class CustomPageRenderer: diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index ecb6b4da97..05d3dedad6 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -5,7 +5,14 @@ from frappe.utils import add_to_date, now UI_TEST_USER = "frappe@example.com" -@frappe.whitelist() +def whitelist_for_tests(fn): + if frappe.request and not (frappe.flags.in_test or getattr(frappe.local, "dev_server", 0)): + frappe.throw("Cannot run UI tests. Use a development server with `bench start`") + + return frappe.whitelist()(fn) + + +@whitelist_for_tests def create_if_not_exists(doc): """Create records if they dont exist. Will check for uniqueness by checking if a record exists with these field value pairs @@ -13,9 +20,6 @@ def create_if_not_exists(doc): :param doc: dict of field value pairs. can be a list of dict for multiple records. """ - if not frappe.local.dev_server: - frappe.throw(_("This method can only be accessed in development"), frappe.PermissionError) - doc = frappe.parse_json(doc) if not isinstance(doc, list): @@ -38,7 +42,7 @@ def create_if_not_exists(doc): return names -@frappe.whitelist() +@whitelist_for_tests def create_todo_records(): frappe.db.truncate("ToDo") @@ -72,16 +76,13 @@ def create_todo_records(): ).insert() -@frappe.whitelist() +@whitelist_for_tests def clear_notes(): - if not frappe.local.dev_server: - frappe.throw(_("Not allowed"), frappe.PermissionError) - for note in frappe.get_all("Note", pluck="name"): frappe.delete_doc("Note", note, force=True) -@frappe.whitelist() +@whitelist_for_tests def create_communication_record(): doc = frappe.get_doc( { @@ -95,7 +96,7 @@ def create_communication_record(): return doc -@frappe.whitelist() +@whitelist_for_tests def setup_workflow(): from frappe.workflow.doctype.workflow.test_workflow import create_todo_workflow @@ -104,7 +105,7 @@ def setup_workflow(): frappe.clear_cache() -@frappe.whitelist() +@whitelist_for_tests def create_contact_phone_nos_records(): if frappe.get_all("Contact", {"first_name": "Test Contact"}): return @@ -116,7 +117,7 @@ def create_contact_phone_nos_records(): doc.insert() -@frappe.whitelist() +@whitelist_for_tests def create_doctype(name, fields): fields = frappe.parse_json(fields) if frappe.db.exists("DocType", name): @@ -133,7 +134,7 @@ def create_doctype(name, fields): ).insert() -@frappe.whitelist() +@whitelist_for_tests def create_child_doctype(name, fields): fields = frappe.parse_json(fields) if frappe.db.exists("DocType", name): @@ -151,7 +152,7 @@ def create_child_doctype(name, fields): ).insert() -@frappe.whitelist() +@whitelist_for_tests def create_contact_records(): if frappe.get_all("Contact", {"first_name": "Test Form Contact 1"}): return @@ -161,7 +162,7 @@ def create_contact_records(): insert_contact("Test Form Contact 3", "12345") -@frappe.whitelist() +@whitelist_for_tests def create_multiple_todo_records(): if frappe.get_all("ToDo", {"description": "Multiple ToDo 1"}): return @@ -177,7 +178,7 @@ def insert_contact(first_name, phone_number): doc.insert() -@frappe.whitelist() +@whitelist_for_tests def create_form_tour(): if frappe.db.exists("Form Tour", {"name": "Test Form Tour"}): return @@ -227,7 +228,7 @@ def create_form_tour(): tour.insert() -@frappe.whitelist() +@whitelist_for_tests def create_data_for_discussions(): web_page = create_web_page("Test page for discussions", "test-page-discussions", False) create_topic_and_reply(web_page) @@ -285,7 +286,7 @@ def create_topic_and_reply(web_page): reply.save() -@frappe.whitelist() +@whitelist_for_tests def update_webform_to_multistep(): if not frappe.db.exists("Web Form", "update-profile-duplicate"): doc = frappe.get_doc("Web Form", "edit-profile") @@ -297,7 +298,7 @@ def update_webform_to_multistep(): _doc.save() -@frappe.whitelist() +@whitelist_for_tests def update_child_table(name): doc = frappe.get_doc("DocType", name) if len(doc.fields) == 1: @@ -315,7 +316,7 @@ def update_child_table(name): doc.save() -@frappe.whitelist() +@whitelist_for_tests def insert_doctype_with_child_table_record(name): if frappe.get_all(name, {"title": "Test Grid Search"}): return @@ -361,7 +362,7 @@ def insert_doctype_with_child_table_record(name): doc.insert() -@frappe.whitelist() +@whitelist_for_tests def insert_translations(): translation = [ { @@ -395,7 +396,7 @@ def insert_translations(): frappe.get_doc(doc).insert() -@frappe.whitelist() +@whitelist_for_tests def create_blog_post(): blog_category = frappe.get_doc( @@ -449,7 +450,7 @@ def create_test_user(): user.save() -@frappe.whitelist() +@whitelist_for_tests def setup_tree_doctype(): frappe.delete_doc_if_exists("DocType", "Custom Tree", force=True) @@ -473,7 +474,7 @@ def setup_tree_doctype(): frappe.get_doc({"doctype": "Custom Tree", "tree": "All Trees"}).insert() -@frappe.whitelist() +@whitelist_for_tests def setup_image_doctype(): frappe.delete_doc_if_exists("DocType", "Custom Image", force=True) @@ -492,7 +493,7 @@ def setup_image_doctype(): ).insert() -@frappe.whitelist() +@whitelist_for_tests def setup_inbox(): frappe.db.sql("DELETE FROM `tabUser Email`") @@ -501,7 +502,7 @@ def setup_inbox(): user.save() -@frappe.whitelist() +@whitelist_for_tests def setup_default_view(view, force_reroute=None): frappe.delete_doc_if_exists("Property Setter", "Event-main-default_view") frappe.delete_doc_if_exists("Property Setter", "Event-main-force_re_route_to_default_view") @@ -532,13 +533,13 @@ def setup_default_view(view, force_reroute=None): ).insert() -@frappe.whitelist() +@whitelist_for_tests def create_note(): if not frappe.db.exists("Note", "Routing Test"): frappe.get_doc({"doctype": "Note", "title": "Routing Test"}).insert() -@frappe.whitelist() +@whitelist_for_tests def create_kanban(): if not frappe.db.exists("Custom Field", "Note-kanban"): frappe.get_doc( @@ -578,3 +579,12 @@ def create_kanban(): ], } ).insert() + + +@whitelist_for_tests +def add_remove_role(action, user, role): + user_doc = frappe.get_doc("User", user) + if action == "remove": + user_doc.remove_roles(role) + else: + user_doc.add_roles(role) diff --git a/frappe/website/doctype/discussion_reply/discussion_reply.py b/frappe/website/doctype/discussion_reply/discussion_reply.py index 1ac62d3b7d..17903ace2d 100644 --- a/frappe/website/doctype/discussion_reply/discussion_reply.py +++ b/frappe/website/doctype/discussion_reply/discussion_reply.py @@ -3,12 +3,14 @@ import frappe from frappe.model.document import Document +from frappe.realtime import get_website_room class DiscussionReply(Document): def on_update(self): frappe.publish_realtime( event="update_message", + room=get_website_room(), message={"reply": frappe.utils.md_to_html(self.reply), "reply_name": self.name}, after_commit=True, ) @@ -41,6 +43,7 @@ class DiscussionReply(Document): frappe.publish_realtime( event="publish_message", + room=get_website_room(), message={ "template": template, "topic_info": topic_info[0], @@ -53,10 +56,15 @@ class DiscussionReply(Document): def after_delete(self): frappe.publish_realtime( - event="delete_message", message={"reply_name": self.name}, after_commit=True + event="delete_message", + room=get_website_room(), + message={"reply_name": self.name}, + after_commit=True, ) @frappe.whitelist() def delete_message(reply_name): - frappe.delete_doc("Discussion Reply", reply_name, ignore_permissions=True) + owner = frappe.db.get_value("Discussion Reply", reply_name, "owner") + if owner == frappe.session.user: + frappe.delete_doc("Discussion Reply", reply_name) diff --git a/frappe/website/doctype/web_form/web_form.js b/frappe/website/doctype/web_form/web_form.js index c494781f1f..28d33a5266 100644 --- a/frappe/website/doctype/web_form/web_form.js +++ b/frappe/website/doctype/web_form/web_form.js @@ -79,7 +79,7 @@ frappe.ui.form.on("Web Form", { .get_field("Web Form Field", "fieldtype") .options.split("\n"); - let added_fields = (frm.doc.fields || []).map((d) => d.fieldname); + let added_fields = (frm.doc.web_form_fields || []).map((d) => d.fieldname); get_fields_for_doctype(frm.doc.doc_type).then((fields) => { for (let df of fields) { diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index 03ba7c0880..d102ac2fd8 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -577,6 +577,8 @@ def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=Fals if not allow_read_on_all_link_options: limited_to_user = True + else: + frappe.throw(_("You must be logged in to use this form."), frappe.PermissionError) else: for field in web_form_doc.web_form_fields: @@ -607,4 +609,6 @@ def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=Fals return "\n".join([doc.value for doc in link_options]) else: - raise frappe.PermissionError(f"Not Allowed, {doctype}") + raise frappe.PermissionError( + _("You don't have permission to access the {0} DocType.").format(doctype) + ) diff --git a/frappe/website/doctype/web_form_field/web_form_field.json b/frappe/website/doctype/web_form_field/web_form_field.json index 4fb566be88..90ec99c3b8 100644 --- a/frappe/website/doctype/web_form_field/web_form_field.json +++ b/frappe/website/doctype/web_form_field/web_form_field.json @@ -39,7 +39,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Fieldtype", - "options": "Attach\nAttach Image\nCheck\nCurrency\nColor\nData\nDate\nDatetime\nDuration\nFloat\nHTML\nInt\nLink\nPassword\nRating\nSelect\nSignature\nSmall Text\nText\nText Editor\nTable\nTime\nSection Break\nColumn Break\nPage Break" + "options": "Attach\nAttach Image\nCheck\nCurrency\nColor\nData\nDate\nDatetime\nDuration\nFloat\nHTML\nInt\nLink\nPassword\nPhone\nRating\nSelect\nSignature\nSmall Text\nText\nText Editor\nTable\nTime\nSection Break\nColumn Break\nPage Break" }, { "fieldname": "label", @@ -147,7 +147,7 @@ ], "istable": 1, "links": [], - "modified": "2022-08-22 17:22:39.026893", + "modified": "2022-11-21 17:41:52.139191", "modified_by": "Administrator", "module": "Website", "name": "Web Form Field", diff --git a/frappe/website/page_renderers/not_permitted_page.py b/frappe/website/page_renderers/not_permitted_page.py index bd27150617..68d4efa939 100644 --- a/frappe/website/page_renderers/not_permitted_page.py +++ b/frappe/website/page_renderers/not_permitted_page.py @@ -14,9 +14,10 @@ class NotPermittedPage(TemplatePage): return True def render(self): + action = f"/login?redirect-to={frappe.request.path}" frappe.local.message_title = _("Not Permitted") frappe.local.response["context"] = dict( - indicator_color="red", primary_action="/login", primary_label=_("Login"), fullpage=True + indicator_color="red", primary_action=action, primary_label=_("Login"), fullpage=True ) self.set_standard_path("message") return super().render() diff --git a/frappe/www/app.py b/frappe/www/app.py index ad7c69af6e..5ef59634b8 100644 --- a/frappe/www/app.py +++ b/frappe/www/app.py @@ -44,12 +44,15 @@ def get_context(context): boot_json = CLOSING_SCRIPT_TAG_PATTERN.sub("", boot_json) boot_json = json.dumps(boot_json) + include_js = hooks.get("app_include_js", []) + frappe.conf.get("app_include_js", []) + include_css = hooks.get("app_include_css", []) + frappe.conf.get("app_include_css", []) + context.update( { "no_cache": 1, "build_version": frappe.utils.get_build_version(), - "include_js": hooks["app_include_js"], - "include_css": hooks["app_include_css"], + "include_js": include_js, + "include_css": include_css, "layout_direction": "rtl" if is_rtl() else "ltr", "lang": frappe.local.lang, "sounds": hooks["sounds"], diff --git a/frappe/www/update-password.html b/frappe/www/update-password.html index 04dfa5e097..586554efce 100644 --- a/frappe/www/update-password.html +++ b/frappe/www/update-password.html @@ -161,7 +161,7 @@ frappe.ready(function() { .text("{{ _('Invalid Password') }}"); }, 200: function(r) { - if (r.message && r.message.entropy) { + if (r.message && r.message.score) { var score = r.message.score, feedback = r.message.feedback; diff --git a/pyproject.toml b/pyproject.toml index 6506bfa13c..f5ab8c94b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "premailer~=3.8.0", "psutil~=5.9.1", "psycopg2-binary~=2.9.1", + "pyOpenSSL~=22.1.0", "pycryptodome~=3.10.1", "pyotp~=2.6.0", "python-dateutil~=2.8.1", diff --git a/socketio.js b/socketio.js index 30e931d6bf..242100baae 100644 --- a/socketio.js +++ b/socketio.js @@ -14,47 +14,59 @@ const io = require("socket.io")(conf.socketio_port, { }, }); -// on socket connection -io.on("connection", function (socket) { +io.use((socket, next) => { if (get_hostname(socket.request.headers.host) != get_hostname(socket.request.headers.origin)) { + next(new Error("Invalid origin")); return; } if (!socket.request.headers.cookie) { + next(new Error("No cookie transmitted.")); return; } - const sid = cookie.parse(socket.request.headers.cookie).sid; - if (!sid) { + let cookies = cookie.parse(socket.request.headers.cookie); + + if (!cookies.sid) { + next(new Error("No sid transmitted.")); return; } - socket.user = cookie.parse(socket.request.headers.cookie).user_id; + request + .get(get_url(socket, "/api/method/frappe.realtime.get_user_info")) + .type("form") + .query({ + sid: cookies.sid, + }) + .then((res) => { + socket.user = res.body.message.user; + socket.user_type = res.body.message.user_type; + socket.sid = cookies.sid; + next(); + }) + .catch((e) => { + next(new Error(`Unauthorized: ${e}`)); + }); +}); - let retries = 0; - let join_user_room = () => { - request - .get(get_url(socket, "/api/method/frappe.realtime.get_user_info")) - .type("form") - .query({ - sid: sid, - }) - .then((res) => { - const room = get_user_room(socket, res.body.message.user); - socket.join(room); - socket.join(get_site_room(socket)); - }) - .catch((e) => { - if (e.code === "ECONNREFUSED" && retries < 5) { - // retry after 1s - retries += 1; - return setTimeout(join_user_room, 1000); - } - log(`Unable to join user room. ${e}`); - }); - }; +// on socket connection +io.on("connection", function (socket) { + socket.join(get_user_room(socket, socket.user)); + socket.join(get_website_room(socket)); - join_user_room(); + if (socket.user_type == "System User") { + socket.join(get_site_room(socket)); + } + + socket.on("list_update", function (doctype) { + can_subscribe_list({ + socket, + doctype, + callback: () => { + socket.join(get_doctype_room(socket, doctype)); + }, + }); + }); socket.on("task_subscribe", function (task_id) { var room = get_task_room(socket, task_id); @@ -69,13 +81,11 @@ io.on("connection", function (socket) { socket.on("progress_subscribe", function (task_id) { var room = get_task_room(socket, task_id); socket.join(room); - send_existing_lines(task_id, socket); }); socket.on("doc_subscribe", function (doctype, docname) { can_subscribe_doc({ socket, - sid, doctype, docname, callback: () => { @@ -93,7 +103,6 @@ io.on("connection", function (socket) { socket.on("doc_open", function (doctype, docname) { can_subscribe_doc({ socket, - sid, doctype, docname, callback: () => { @@ -185,18 +194,6 @@ subscriber.on("message", function (_channel, message) { subscriber.subscribe("events"); -function send_existing_lines(task_id, socket) { - var room = get_task_room(socket, task_id); - subscriber.hgetall("task_log:" + task_id, function (_err, lines) { - io.to(room).emit("task_progress", { - task_id: task_id, - message: { - lines: lines, - }, - }); - }); -} - function get_doc_room(socket, doctype, docname) { return get_site_name(socket) + ":doc:" + doctype + "/" + docname; } @@ -210,33 +207,42 @@ function get_typing_room(socket, doctype, docname) { } function get_user_room(socket, user) { - return get_site_name(socket) + ":user:" + user; + return get_site_name(socket) + ":user:" + user || socket.user; } function get_site_room(socket) { return get_site_name(socket) + ":all"; } +function get_website_room(socket) { + return get_site_name(socket) + ":website"; +} + +function get_doctype_room(socket, doctype) { + return get_site_name(socket) + ":doctype:" + doctype; +} + function get_task_room(socket, task_id) { return get_site_name(socket) + ":task_progress:" + task_id; } function get_site_name(socket) { - var hostname_from_host = get_hostname(socket.request.headers.host); - - if (socket.request.headers["x-frappe-site-name"]) { - return get_hostname(socket.request.headers["x-frappe-site-name"]); + if (socket.site_name) { + return socket.site_name; + } else if (socket.request.headers["x-frappe-site-name"]) { + socket.site_name = get_hostname(socket.request.headers["x-frappe-site-name"]); } else if ( - ["localhost", "127.0.0.1"].indexOf(hostname_from_host) !== -1 && - conf.default_site + conf.default_site && + ["localhost", "127.0.0.1"].indexOf(get_hostname(socket.request.headers.host)) !== -1 ) { // from currentsite.txt since host is localhost - return conf.default_site; + socket.site_name = conf.default_site; } else if (socket.request.headers.origin) { - return get_hostname(socket.request.headers.origin); + socket.site_name = get_hostname(socket.request.headers.origin); } else { - return get_hostname(socket.request.headers.host); + socket.site_name = get_hostname(socket.request.headers.host); } + return socket.site_name; } function get_hostname(url) { @@ -261,7 +267,7 @@ function can_subscribe_doc(args) { .get(get_url(args.socket, "/api/method/frappe.realtime.can_subscribe_doc")) .type("form") .query({ - sid: args.sid, + sid: args.socket.sid, doctype: args.doctype, docname: args.docname, }) @@ -280,6 +286,30 @@ function can_subscribe_doc(args) { }); } +function can_subscribe_list(args) { + if (!args) return; + if (!args.doctype) return; + request + .get(get_url(args.socket, "/api/method/frappe.realtime.can_subscribe_list")) + .type("form") + .query({ + sid: args.socket.sid, + doctype: args.doctype, + }) + .end(function (err, res) { + if (!res || res.status == 403 || err) { + if (err) { + log(err); + } + return false; + } else if (res.status == 200) { + args?.callback(err, res); + return true; + } + log("ERROR (can_subscribe_list): ", err, res); + }); +} + function send_users(args, action) { if (!(args && args.doctype && args.docname)) { return;