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/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/build.py b/frappe/build.py index e66da4bd79..b74afa5d06 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -4,7 +4,6 @@ import os import re import shutil import subprocess -from distutils.spawn import find_executable from subprocess import getoutput from tempfile import mkdtemp, mktemp from urllib.parse import urlparse @@ -280,7 +279,7 @@ def check_node_executable(): warn = "⚠️ " if node_version.major < 14: click.echo(f"{warn} Please update your node version to 14") - if not find_executable("yarn"): + if not shutil.which("yarn"): click.echo(f"{warn} Please install yarn using below command and try again.\nnpm install -g yarn") click.echo() diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index e555f63f41..69f6f43ff3 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -2,7 +2,7 @@ import json import os import subprocess import sys -from distutils.spawn import find_executable +from shutil import which import click @@ -12,6 +12,7 @@ from frappe.coverage import CodeCoverage from frappe.exceptions import SiteNotSpecifiedError from frappe.utils import cint, update_progress_bar +find_executable = which # backwards compatibility DATA_IMPORT_DEPRECATION = ( "[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n" "Use `data-import` command instead to import data via 'Data Import'." @@ -525,7 +526,7 @@ def postgres(context): def _mariadb(): from frappe.database.mariadb.database import MariaDBDatabase - mysql = find_executable("mysql") + mysql = which("mysql") command = [ mysql, "--port", @@ -544,7 +545,7 @@ def _mariadb(): def _psql(): - psql = find_executable("psql") + psql = which("psql") subprocess.run([psql, "-d", frappe.conf.db_name]) 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 1518c72f95..0c21501589 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -78,21 +78,38 @@ class File(Document): self.validate_duplicate_entry() def validate(self): + if self.is_folder: + return + # Ensure correct formatting and type self.file_url = unquote(self.file_url) if self.file_url else "" + self.validate_attachment_references() + # when dict is passed to get_doc for creation of new_doc, is_new returns None # this case is handled inside handle_is_private_changed if not self.is_new() and self.has_value_changed("is_private"): self.handle_is_private_changed() - if not self.is_folder: - self.validate_file_path() - self.validate_file_url() - self.validate_file_on_disk() + self.validate_file_path() + self.validate_file_url() + self.validate_file_on_disk() self.file_size = frappe.form_dict.file_size or self.file_size + def validate_attachment_references(self): + if not self.attached_to_doctype: + return + + 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 + + if not frappe.get_meta(self.attached_to_doctype).has_field(self.attached_to_field): + frappe.throw(_("The fieldname you've specified in Attached To Field is invalid")) + def after_rename(self, *args, **kwargs): for successor in self.get_successors(): setup_folder_path(successor, self.name) 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/database/db_manager.py b/frappe/database/db_manager.py index 3dddb7f862..5840158fa1 100644 --- a/frappe/database/db_manager.py +++ b/frappe/database/db_manager.py @@ -51,12 +51,12 @@ class DbManager: @staticmethod def restore_database(target, source, user, password): import os - from distutils.spawn import find_executable + from shutil import which from frappe.utils import make_esc esc = make_esc("$ ") - pv = find_executable("pv") + pv = which("pv") if pv: pipe = f"{pv} {source} |" 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/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/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/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/test_zbg_job_sanity_test.py b/frappe/tests/test_zbg_job_sanity_test.py deleted file mode 100644 index 19dc168c04..0000000000 --- a/frappe/tests/test_zbg_job_sanity_test.py +++ /dev/null @@ -1,27 +0,0 @@ -""" smoak tests to check that all registered background jobs execute without error. - -Note: Filename is intentional to run this test roughly at end. Don't change.""" - -import time - -import frappe -from frappe.core.doctype.rq_job.rq_job import RQJob, remove_failed_jobs -from frappe.tests.utils import FrappeTestCase, timeout - - -class TestScheduledJobSanity(FrappeTestCase): - def setUp(self): - remove_failed_jobs() - - @timeout(90) - def test_bg_jobs_run(self): - """Enqueue all scheduled jobs, wait for finish and verify that none failed.""" - for scheduled_job_type in frappe.get_all("Scheduled Job Type", pluck="name"): - frappe.get_doc("Scheduled Job Type", scheduled_job_type).enqueue(force=True) - - while RQJob.get_list({"filters": [["RQ Job", "status", "in", ("queued", "started")]]}): - time.sleep(0.5) - - # Check no failed, if failed print full details - failed_jobs = RQJob.get_list({"filters": [["RQ Job", "status", "=", "failed"]]}) - self.assertEqual(len(failed_jobs), 0, "Jobs failed: " + str(failed_jobs)) diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index 9d32be229e..aabcd28c8b 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( diff --git a/frappe/utils/html_utils.py b/frappe/utils/html_utils.py index fa84170330..9885a8a8a9 100644 --- a/frappe/utils/html_utils.py +++ b/frappe/utils/html_utils.py @@ -277,6 +277,7 @@ acceptable_elements = [ "li", "m", "map", + "mark", "menu", "meter", "multicol", 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/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/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;