diff --git a/README.md b/README.md index e7e27a0da0..53980bbef9 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ It takes care of installation, setup, upgrades, monitoring, maintenance and supp ### Docker Prerequisites: docker, docker-compose, git. Refer [Docker Documentation](https://docs.docker.com) for more details on Docker setup. -Run following commands: +Run the following commands: ``` git clone https://github.com/frappe/frappe_docker diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 6319e8ae80..d83c304c71 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -6,7 +6,8 @@ const jump_to_field = (field_label) => { .wait(500) .type("{enter}") .wait(200) - .type("{enter}") + .findByRole("button", { name: "Go" }) + .click() .wait(1000); }; @@ -99,9 +100,7 @@ context("Form", () => { cy.new_form("User"); jump_to_field("Location"); // this is in collapsed section - cy.wait(500); type_value("Bermuda"); - cy.wait(500); cy.get_field("location").should("have.value", "Bermuda"); }); diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js index 57c7f3023d..97b6cac8e3 100644 --- a/cypress/integration/list_view_settings.js +++ b/cypress/integration/list_view_settings.js @@ -2,18 +2,24 @@ context("List View Settings", () => { beforeEach(() => { cy.login(); cy.visit("/desk/website"); + cy.visit("/desk/List/DocType/List"); + cy.wait(300); + cy.clear_filters(); + cy.wait(300); + cy.get(".menu-btn-group button").click({ force: true }); + cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click(); + cy.get(".modal-dialog").should("contain", "DocType List View Settings"); + cy.findByLabelText("Disable Count").uncheck({ force: true }); + cy.findByLabelText("Disable Comment Count").uncheck({ force: true }); + cy.findByLabelText("Disable Sidebar Stats").uncheck({ force: true }); + cy.findByRole("button", { name: "Save" }).click(); + cy.reload({ force: true }); }); it("Default settings", () => { - cy.visit("/desk/List/DocType/List"); - cy.clear_filters(); cy.get(".list-count").should("contain", "20 of"); cy.get(".list-stats").should("contain", "Tags"); }); it("disable count and sidebar stats then verify", () => { - cy.wait(300); - cy.visit("/desk/List/DocType/List"); - cy.clear_filters(); - cy.wait(300); cy.get(".list-count").should("contain", "20 of"); cy.get(".frappe-list svg.es-icon.es-line").should("be.visible"); cy.get(".menu-btn-group button").click(); @@ -29,7 +35,7 @@ context("List View Settings", () => { cy.get(".list-count").should("be.empty"); cy.get(".list-sidebar .list-tags").should("not.exist"); - cy.get("[href='#es-line-chat-alt']").should("not.be.visible"); + cy.get("[href='#es-line-chat-alt']").should("not.exist"); cy.get(".menu-btn-group button").click({ force: true }); cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click(); diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index dc242bcff6..02f790fa9c 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -505,7 +505,10 @@ def get_permission_query_conditions_for_communication(user): return None else: accounts = frappe.get_all( - "User Email", filters={"parent": user}, fields=["email_account"], distinct=True, order_by="idx" + "User Email", + filters={"parent": user}, + fields=["email_account"], + distinct=True, ) if not accounts: diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 92df52d0aa..4e8cb320c4 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -32,6 +32,7 @@ "fetch_from", "fetch_if_empty", "visibility_section", + "button_color", "hidden", "show_on_timeline", "bold", @@ -617,6 +618,13 @@ "fieldname": "mask", "fieldtype": "Check", "label": "Mask" + }, + { + "depends_on": "eval:doc.fieldtype===\"Button\"", + "fieldname": "button_color", + "fieldtype": "Select", + "label": "Button Color", + "options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger" } ], "grid_page_length": 50, diff --git a/frappe/core/doctype/docfield/docfield.py b/frappe/core/doctype/docfield/docfield.py index 3f6d642e55..6f90b81ce9 100644 --- a/frappe/core/doctype/docfield/docfield.py +++ b/frappe/core/doctype/docfield/docfield.py @@ -18,6 +18,7 @@ class DocField(Document): allow_in_quick_entry: DF.Check allow_on_submit: DF.Check bold: DF.Check + button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"] collapsible: DF.Check collapsible_depends_on: DF.Code | None columns: DF.Int diff --git a/frappe/core/doctype/domain_settings/domain_settings.py b/frappe/core/doctype/domain_settings/domain_settings.py index e2464854e4..175ccc5a5c 100644 --- a/frappe/core/doctype/domain_settings/domain_settings.py +++ b/frappe/core/doctype/domain_settings/domain_settings.py @@ -78,7 +78,10 @@ def get_active_domains(): def _get_active_domains(): domains = frappe.get_all( - "Has Domain", filters={"parent": "Domain Settings"}, fields=["domain"], distinct=True + "Has Domain", + filters={"parent": "Domain Settings"}, + fields=["domain"], + distinct=True, ) active_domains = [row.get("domain") for row in domains] diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index 72bd7e6988..7360ca94d2 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -158,10 +158,7 @@ class TestServerScript(IntegrationTestCase): def test_permission_query(self): sql = frappe.db.get_list("ToDo", run=False) - if frappe.conf.db_type != "postgres": - self.assertTrue("where (1 = 1)" in sql.lower()) - else: - self.assertTrue("where (1 = '1')" in sql.lower()) + self.assertTrue("where (1 = 1)" in sql.lower()) self.assertTrue(isinstance(frappe.db.get_list("ToDo"), list)) def test_attribute_error(self): diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 210737e4d5..bacce751ac 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -1018,7 +1018,10 @@ def ask_pass_update(): from frappe.utils import set_default password_list = frappe.get_all( - "User Email", filters={"awaiting_password": 1, "used_oauth": 0}, pluck="parent", distinct=True + "User Email", + filters={"awaiting_password": 1, "used_oauth": 0}, + pluck="parent", + distinct=True, ) set_default("email_user_password", ",".join(password_list)) diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 0a97a65c4b..cd0cc57f56 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -19,6 +19,7 @@ "link_filters", "column_break_6", "fieldtype", + "button_color", "precision", "hide_seconds", "hide_days", @@ -467,6 +468,13 @@ "fieldname": "placeholder", "fieldtype": "Data", "label": "Placeholder" + }, + { + "depends_on": "eval:doc.fieldtype===\"Button\"", + "fieldname": "button_color", + "fieldtype": "Select", + "label": "Button Color", + "options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger" } ], "grid_page_length": 50, @@ -474,7 +482,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-10-10 11:10:23.862393", + "modified": "2025-11-12 01:14:24.753774", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index f75dfbd58f..e90a6caf53 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -25,6 +25,7 @@ class CustomField(Document): allow_in_quick_entry: DF.Check allow_on_submit: DF.Check bold: DF.Check + button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"] collapsible: DF.Check collapsible_depends_on: DF.Code | None columns: DF.Int diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 1fa7a1c4aa..295182f6f2 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -806,6 +806,7 @@ docfield_properties = { "is_virtual": "Check", "link_filters": "JSON", "placeholder": "Data", + "button_color": "Select", } doctype_link_properties = { diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 69b22eaf60..499e9f4b18 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -54,6 +54,7 @@ "column_break_33", "read_only_depends_on", "display", + "button_color", "in_filter", "hide_seconds", "hide_days", @@ -485,6 +486,13 @@ "fieldname": "placeholder", "fieldtype": "Data", "label": "Placeholder" + }, + { + "depends_on": "eval:doc.fieldtype===\"Button\"", + "fieldname": "button_color", + "fieldtype": "Select", + "label": "Button Color", + "options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger" } ], "grid_page_length": 50, @@ -492,7 +500,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-14 13:56:58.033573", + "modified": "2025-11-12 01:13:53.053888", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.py b/frappe/custom/doctype/customize_form_field/customize_form_field.py index 34328ae585..6d30862c4b 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.py +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.py @@ -17,6 +17,7 @@ class CustomizeFormField(Document): allow_in_quick_entry: DF.Check allow_on_submit: DF.Check bold: DF.Check + button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"] collapsible: DF.Check collapsible_depends_on: DF.Code | None columns: DF.Int diff --git a/frappe/database/operator_map.py b/frappe/database/operator_map.py index ffdee044b6..529d91bbac 100644 --- a/frappe/database/operator_map.py +++ b/frappe/database/operator_map.py @@ -24,6 +24,17 @@ def like(key: Field, value: str) -> frappe.qb: return key.like(value) +def ilike(key: Field, value: str) -> frappe.qb: + """Wrapper method for `ILIKE` + Args: + key (str): field + value (str): criterion + Return: + frappe.qb: `frappe.qb` object with `ILIKE` + """ + return key.ilike(value) + + def func_in(key: Field, value: list | tuple) -> frappe.qb: """Wrapper method for `IN`. @@ -136,6 +147,7 @@ OPERATOR_MAP: dict[str, Callable] = { "in": func_in, "not in": func_not_in, "like": like, + "ilike": ilike, "not like": not_like, "regex": func_regex, "between": func_between, diff --git a/frappe/database/query.py b/frappe/database/query.py index 4657754522..9b71d307a3 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -1,5 +1,6 @@ import datetime import re +import warnings from functools import lru_cache from typing import TYPE_CHECKING, Any @@ -227,6 +228,7 @@ class Engine: if self.apply_permissions: self.check_read_permission() + is_select = False if update: self.query = qb.update(self.table, immutable=False) elif into: @@ -236,6 +238,7 @@ class Engine: else: self.query = qb.from_(self.table, immutable=False) self.apply_fields(fields) + is_select = True self.apply_filters(filters) self.apply_or_filters(or_filters) @@ -260,7 +263,19 @@ class Engine: self.apply_group_by(group_by) if order_by: - self.apply_order_by(order_by) + if not ( + self.is_postgres and is_select and (distinct or group_by) + ): # ignore in Postgres since order by fields need to appear in select distinct + self.apply_order_by(order_by) + else: + warnings.warn( + ( + "ORDER BY fields have been ignored because PostgreSQL requires them to " + "appear in the SELECT list when using DISTINCT or GROUP BY." + ), + UserWarning, + stacklevel=2, + ) if self.apply_permissions: self.add_permission_conditions() @@ -512,7 +527,12 @@ class Engine: ) return operator_fn(_field, nodes or ("",)) - operator_fn = OPERATOR_MAP[_operator.casefold()] + if ( + self.is_postgres and _operator.casefold() == "like" + ): # use `ILIKE` to support case insensitive search in postgres + operator_fn = OPERATOR_MAP["ilike"] + else: + operator_fn = OPERATOR_MAP[_operator.casefold()] if _value is None and isinstance(_field, Field): if operator_fn == builtin_operator.ne: filter_field_name = ( @@ -1425,7 +1445,7 @@ class Engine: if fieldtype == "Time": return "'00:00:00'" - if fieldtype in ("Float", "Int", "Currency", "Percent"): + if fieldtype in ("Float", "Int", "Currency", "Percent", "Check"): return "0" try: diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index fe2113302e..0716e49961 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -305,7 +305,12 @@ def get_references_across_doctypes_by_dynamic_link_field( for doctype, fieldname, doctype_fieldname in links: try: filters = [[doctype_fieldname, "in", to_doctypes]] if to_doctypes else [] - for linked_to in frappe.get_all(doctype, pluck=doctype_fieldname, filters=filters, distinct=1): + for linked_to in frappe.get_all( + doctype, + pluck=doctype_fieldname, + filters=filters, + distinct=1, + ): if linked_to: links_by_doctype[linked_to].append( {"doctype": doctype, "fieldname": fieldname, "doctype_fieldname": doctype_fieldname} diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 880e1fafb8..20f2ffd135 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -285,7 +285,12 @@ class DesktopIconGrid { prepare() { this.total_pages = 1; - this.icons_data = this.icons_data.sort((a, b) => a.name.localeCompare(b.name)); + this.icons_data = this.icons_data.sort((a, b) => { + if (a.idx === b.idx) { + return a.label.localeCompare(b.label); // sort by label if idx is the same + } + return a.idx - b.idx; // sort by idx + }); this.icons_data_by_page = this.icons_data || this.split_data(this.icons_data, this.page_size.total()); } @@ -465,7 +470,7 @@ class DesktopIconGrid { if (evt.to.parentElement == evt.from.parentElement) { let reordered_icons = me.sortable.toArray(); let filters = { - parent_icon: me.parent_icon?.icon_data.label || null, + parent_icon: me.parent_icon?.icon_data.label || "" || null, }; me.reorder_icons(reordered_icons, filters); me.parent_icon?.render_folder_thumbnail(); @@ -495,11 +500,12 @@ class DesktopIconGrid { } reorder_icons(reordered_icons, filters) { reordered_icons.forEach((d, idx) => { - let icon = get_desktop_icon_by_label(d, filters); + let icon = get_desktop_icon_by_label(d); if (icon) { icon.idx = idx; } }); + frappe.boot.desktop_icons.sort((a, b) => a.idx - b.idx); } add_to_main_screen(title) { let icon = get_desktop_icon_by_label(title); @@ -509,6 +515,7 @@ class DesktopIconGrid { class DesktopIcon { constructor(icon, in_folder) { this.icon_data = icon; + this.icon_data.label = __(this.icon_data.label); this.icon_title = this.icon_data.label; this.icon_subtitle = ""; this.icon_type = this.icon_data.icon_type; diff --git a/frappe/desk/search.py b/frappe/desk/search.py index dc23bf5778..4c9a92e698 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -195,15 +195,9 @@ def search_widget( _relevance_expr = {"DIV": [1, {"NULLIF": [{"LOCATE": [_txt, "name"]}, 0]}]} # For MariaDB, wrap in IFNULL for sorting to push nulls to end - if frappe.db.db_type in ("mariadb", "sqlite"): - _relevance = {"IFNULL": [_relevance_expr, -9999], "as": "_relevance"} - formatted_fields.append(_relevance) - order_by = f"_relevance desc, {order_by}" - elif frappe.db.db_type == "postgres": - _relevance = {**_relevance_expr, "as": "_relevance"} - formatted_fields.append(_relevance) - # Since we are sorting by alias postgres needs to know number of column we are sorting - order_by = f"{len(formatted_fields)} desc nulls last, {order_by}" + _relevance = {"IFNULL": [_relevance_expr, -9999], "as": "_relevance"} + formatted_fields.append(_relevance) + order_by = f"_relevance desc, {order_by}" values = frappe.get_list( doctype, diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index 238557a4c9..50fe5b4174 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -57,8 +57,8 @@ def _get_children(doctype, parent="", ignore_permissions=False, include_disabled ) if frappe.db.has_column(doctype, "disabled") and not include_disabled: - qb = qb.where(Field("disabled").eq(False)) - + # used 0 instead of `false` since type of check in postgres is smallint + qb = qb.where(Field("disabled").eq(0)) # Order by name and execute return qb.orderby("name").run(as_dict=True) diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 2f87fc166c..e19afb56bf 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -95,7 +95,7 @@ class SMTPServer: return self._session except smtplib.SMTPAuthenticationError: - self.throw_invalid_credentials_exception() + self.throw_invalid_credentials_exception(email_account=self.email_account) except OSError as e: # Invalid mail server -- due to refusing connection @@ -128,10 +128,17 @@ class SMTPServer: self._session.quit() @classmethod - def throw_invalid_credentials_exception(cls): + def throw_invalid_credentials_exception(cls, email_account=None): original_exception = get_traceback() or "\n" + error_message = ( + _("Please check your email login credentials.") + " " + original_exception.splitlines()[-1] + ) + error_title = _("Invalid Credentials") + if email_account: + error_title = _("Invalid Credentials for Email Account: {0}").format(email_account) + frappe.throw( - _("Please check your email login credentials.") + " " + original_exception.splitlines()[-1], - title=_("Invalid Credentials"), + error_message, + title=error_title, exc=InvalidEmailCredentials, ) diff --git a/frappe/email/test_smtp.py b/frappe/email/test_smtp.py index 01c55372e1..d0b9507e01 100644 --- a/frappe/email/test_smtp.py +++ b/frappe/email/test_smtp.py @@ -18,7 +18,8 @@ class TestSMTP(IntegrationTestCase): def test_get_email_account(self): existing_email_accounts = frappe.get_all( - "Email Account", fields=["name", "enable_outgoing", "default_outgoing", "append_to", "use_imap"] + "Email Account", + fields=["name", "enable_outgoing", "default_outgoing", "append_to", "use_imap"], ) unset_details = {"enable_outgoing": 0, "default_outgoing": 0, "append_to": None, "use_imap": 0} for email_account in existing_email_accounts: diff --git a/frappe/hooks.py b/frappe/hooks.py index d7495427e7..b1c7017864 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -64,7 +64,7 @@ website_route_rules = [ ] website_redirects = [ - {"source": r"/app/(.*)", "target": r"/desk/\1"}, + {"source": r"/app/(.*)", "target": r"/desk/\1", "forward_query_parameters": True}, {"source": "/apps", "target": "/desk"}, {"source": "/app", "target": "/desk"}, ] diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index d132672124..1cafc08944 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -691,6 +691,14 @@ class BaseDocument: None, ) + def _handle_hash_conflict(self): + """Regenerate hash name in case of collisions""" + self.flags.retry_count = (self.flags.retry_count or 0) + 1 + if self.flags.retry_count >= 5: + raise + self.name = None + return self.db_insert() + def db_insert(self, ignore_if_duplicate=False): """INSERT the document (with valid columns) in the database. @@ -704,10 +712,17 @@ class BaseDocument: set_new_name(self) conflict_handler = "" + returning = "" # On postgres we can't implcitly ignore PK collision # So instruct pg to ignore `name` field conflicts - if ignore_if_duplicate and frappe.db.db_type == "postgres": + if ( + (ignore_if_duplicate or self.meta.autoname == "hash") + and frappe.db.db_type == "postgres" + and (self.flags.retry_count or 0) < 5 + ): conflict_handler = "on conflict (name) do nothing" + if self.meta.autoname == "hash": + returning = "RETURNING name" if not self.creation: self.creation = self.modified = now() @@ -722,26 +737,26 @@ class BaseDocument: columns = list(d) try: - frappe.db.sql( + name = frappe.db.sql( """INSERT INTO `tab{doctype}` ({columns}) - VALUES ({values}) {conflict_handler}""".format( + VALUES ({values}) {conflict_handler} {returning}""".format( doctype=self.doctype, columns=", ".join("`" + c + "`" for c in columns), values=", ".join(["%s"] * len(columns)), conflict_handler=conflict_handler, + returning=returning, ), list(d.values()), ) + if ( + frappe.db.db_type == "postgres" and self.meta.autoname == "hash" and not name + ): # To avoid a transaction block, we regen in try (pg specific) + return self._handle_hash_conflict() except Exception as e: if frappe.db.is_primary_key_violation(e): if self.meta.autoname == "hash": # hash collision? try again - self.flags.retry_count = (self.flags.retry_count or 0) + 1 - if self.flags.retry_count > 5: - raise - self.name = None - self.db_insert() - return + return self._handle_hash_conflict() if not ignore_if_duplicate: frappe.msgprint( diff --git a/frappe/model/qb_query.py b/frappe/model/qb_query.py index 882fcc531f..526529cfa6 100644 --- a/frappe/model/qb_query.py +++ b/frappe/model/qb_query.py @@ -113,9 +113,14 @@ class DatabaseQuery: # if `filters` is a list of strings, its probably fields filters, fields = fields, filters + # Set fields to the requested field or `name` if none specified + if not fields: + fields = [pluck or "name"] + # Handle virtual doctypes before any other processing if is_virtual_doctype(self.doctype): return self._handle_virtual_doctype( + fields, filters, or_filters, start, @@ -162,10 +167,6 @@ class DatabaseQuery: if limit is None: limit = page_length - # Set fields to the requested field or `name` if none specified - if not fields: - fields = [pluck or "name"] - # Check if table exists before running query from frappe.model.meta import get_table_columns @@ -285,6 +286,7 @@ class DatabaseQuery: def _handle_virtual_doctype( self, + fields: list[str] | tuple[str, ...] | str | None, filters: dict[str, FilterValue] | FilterValue | list[list | FilterValue] | None, or_filters: dict[str, FilterValue] | FilterValue | list[list | FilterValue] | None, start: int | None, @@ -331,6 +333,7 @@ class DatabaseQuery: _page_length = page_length or limit or limit_page_length or 20 kwargs = { + "fields": fields, "filters": filters, "or_filters": or_filters, "start": start or offset or limit_start or 0, diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index 36804beb11..f6e31a2da3 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -88,9 +88,6 @@ function make_dialog(frm) { let fieldname = props.field.df.fieldname; let field_option = props.field.df.options; let filters = frm.filter_group.get_filters().map((filter) => { - // last element is a boolean which hides the filter hence not required to store in meta - filter.pop(); - // filter_group component requires options and frm.set_query requires fieldname so storing both filter[0] = field_option; return filter; diff --git a/frappe/public/js/form_builder/components/controls/ButtonControl.vue b/frappe/public/js/form_builder/components/controls/ButtonControl.vue index 6642d5dc49..460ead3055 100644 --- a/frappe/public/js/form_builder/components/controls/ButtonControl.vue +++ b/frappe/public/js/form_builder/components/controls/ButtonControl.vue @@ -1,6 +1,22 @@ @@ -10,7 +26,7 @@ const props = defineProps(["df", "value"]); - + diff --git a/frappe/public/js/frappe/form/controls/button.js b/frappe/public/js/frappe/form/controls/button.js index 0d5a4013a6..41fd49b5af 100644 --- a/frappe/public/js/frappe/form/controls/button.js +++ b/frappe/public/js/frappe/form/controls/button.js @@ -5,7 +5,21 @@ frappe.ui.form.ControlButton = class ControlButton extends frappe.ui.form.Contro } make_input() { var me = this; - const btn_type = this.df.primary ? "btn-primary" : "btn-default"; + let btn_type = "btn-default"; + if (this.df.button_color) { + const color_map = { + Default: "btn-default", + Primary: "btn-primary", + Info: "btn-info", + Success: "btn-success", + Warning: "btn-warning", + Danger: "btn-danger", + }; + btn_type = color_map[this.df.button_color] || "btn-default"; + } else if (this.df.primary) { + btn_type = "btn-primary"; + } + const btn_size = this.df.btn_size ? `btn-${this.df.btn_size}` : "btn-xs"; this.$input = $( ` fields_dict[fieldname].set_value(value)); + out.promise = out.promise.then(() => { + // Set match type for fields that support it + if (fields_dict[fieldname].df) { + fields_dict[fieldname].df.match_type = condition; + } + return fields_dict[fieldname].set_value(value); + }); } else { // filter out non standard filters out.non_standard_filters.push(filter); @@ -855,7 +861,7 @@ class FilterArea { fields.push({ fieldtype: "Data", label: "ID", - condition: "like", + condition: "=", fieldname: "name", onchange: () => this.debounced_refresh_list_view(), }); @@ -910,7 +916,7 @@ class FilterArea { ].includes(fieldtype) ) { fieldtype = "Data"; - condition = "like"; + condition = "="; } if (df.fieldtype == "Select" && df.options) { options = df.options.split("\n"); @@ -942,23 +948,109 @@ class FilterArea { fields.map((df) => { this.list_view.page.add_field(df, this.standard_filters_wrapper); + + const input_fieldtypes = [ + "Data", + "Text", + "Small Text", + "Long Text", + "Code", + "Phone", + "Read Only", + "Barcode", + ]; + + if (input_fieldtypes.includes(df.fieldtype)) { + df.match_type = df.condition || "="; + this.filter_field_with_match_type(df); + } }); } + filter_field_with_match_type(df) { + setTimeout(() => { + const field = this.list_view.page.fields_dict[df.fieldname]; + if (!field || !field.$wrapper) return; + + const $input = field.$wrapper.find("input").first(); + if (!$input.length || $input.closest(".input-group").length) return; + + const getSymbol = (match_type) => (match_type === "=" ? "=" : "≈"); + + $input.wrap(''); + const $inputGroup = $input.parent(); + + const $dropdown = $(` + + + ${getSymbol(df.match_type || "=")} + + + + ${__("Equals")} + ${__("Like")} + + + `); + + $inputGroup.append($dropdown); + + $dropdown.find(".dropdown-item").on("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + $dropdown.find("button").dropdown("toggle"); + + const new_type = $(e.currentTarget).data("match-type"); + const current_type = field.df.match_type || "="; + + if (new_type === current_type) return; + + field.df.match_type = new_type; + $dropdown.find("button").html(`${getSymbol(new_type)}`); + + let value = field.get_value?.(); + if (new_type === "=" && value) { + field.set_value(value.replace(/^%+|%+$/g, "")); + } + + this.debounced_refresh_list_view(); + }); + }, 100); + } get_standard_filters() { const filters = []; const fields_dict = this.list_view.page.fields_dict; + for (let key in fields_dict) { let field = fields_dict[key]; let value = field.get_value(); if (value) { - if (field.df.condition === "like" && !value.includes("%")) { - value = "%" + value + "%"; + let match_type = field.df.match_type || "="; + let condition; + + if (match_type === "like") { + condition = "like"; + if (typeof value === "string" && !value.includes("%")) { + value = "%" + value + "%"; + } + } else if (match_type === "=") { + condition = "="; + if (typeof value === "string") { + value = value.replace(/^%+|%+$/g, ""); + } + } else { + // For special conditions like "descendants of (inclusive)" + condition = field.df.condition || match_type; } + filters.push([ field.df.doctype || this.list_view.doctype, field.df.fieldname, - field.df.condition || "=", + condition, value, ]); } diff --git a/frappe/public/js/frappe/list/list_sidebar.html b/frappe/public/js/frappe/list/list_sidebar.html index 358c110a7c..96593dcfaf 100644 --- a/frappe/public/js/frappe/list/list_sidebar.html +++ b/frappe/public/js/frappe/list/list_sidebar.html @@ -33,13 +33,14 @@ - {{ __("Filter By") }} + {{ __("Filter By") }} + diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js index a0431999e1..40f183450f 100644 --- a/frappe/public/js/frappe/ui/filters/filter.js +++ b/frappe/public/js/frappe/ui/filters/filter.js @@ -333,7 +333,6 @@ frappe.ui.Filter = class { this.field.df.fieldname, this.get_condition(), this.get_selected_value(), - this.hidden, ]; } diff --git a/frappe/public/js/frappe/ui/menu.js b/frappe/public/js/frappe/ui/menu.js index 330095832f..2eb0eaa2d2 100644 --- a/frappe/public/js/frappe/ui/menu.js +++ b/frappe/public/js/frappe/ui/menu.js @@ -31,37 +31,46 @@ frappe.ui.menu = class ContextMenu { } add_menu_item(item) { const me = this; - let item_wrapper = $(` - - - ${ - item.icon - ? frappe.utils.icon(item.icon) - : `` - } - - ${item.label} - - ${item.items && item.items.length ? frappe.utils.icon("chevron-right") : ""} - - - - `); - if (!item.url) { - item_wrapper.on("click", function () { - item.onClick && item.onClick(); - if (!(item.items && item.items.length)) { - me.opts.onItemClick && me.opts.onItemClick(me.opts.parent); - me.hide(); - } - }); - } else if (item.items) { - $(); + let item_wrapper = $( + `` + ); + if (item?.is_divider) { + item_wrapper = $( + `` + ); } else { - $(item_wrapper).find("a").attr("href", item.url); + item_wrapper = $(` + + + ${ + item.icon + ? frappe.utils.icon(item.icon) + : `` + } + + ${item.label} + + ${item.items && item.items.length ? frappe.utils.icon("chevron-right") : ""} + + + + `); + if (!item.url) { + item_wrapper.on("click", function () { + item.onClick && item.onClick(); + if (!(item.items && item.items.length)) { + me.opts.onItemClick && me.opts.onItemClick(me.opts.parent); + me.hide(); + } + }); + } else if (item.items) { + $(); + } else { + $(item_wrapper).find("a").attr("href", item.url); + } } item_wrapper.appendTo(this.template); if (item.items) { diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.html b/frappe/public/js/frappe/ui/sidebar/sidebar.html index 518d459909..11ad9a19bf 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.html +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.html @@ -40,9 +40,40 @@ - {%= frappe.utils.icon("arrow-left-to-line" , "sm", "", "", "text-ink-gray-7 current-color", true)%} + {%= frappe.utils.icon("panel-right-open" , "sm", "", "", "text-ink-gray-7 current-color", true)%} {%= __("Collapse") %} + + + {{ avatar }} + + {{frappe.session.user_fullname}} + {{frappe.session.user_email}} + + + + {% for item in navbar_settings.settings_dropdown %} + {% var condition = item.condition ? eval(item.condition) : true %} + {% if (condition && !item.hidden) { %} + {% if (item.route) { %} + + {%= __(item.item_label) %} + + {% } else if (item.action) { %} + + {%= __(item.item_label) %} + + {% } else { %} + + {% } %} + {% } %} + {% endfor %} + + diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.js b/frappe/public/js/frappe/ui/sidebar/sidebar.js index 293aeb5698..a101dfddf4 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.js @@ -26,6 +26,7 @@ frappe.ui.Sidebar = class Sidebar { prepare() { try { + this.add_standard_items(); this.sidebar_data = frappe.boot.workspace_sidebar_item[this.workspace_title]; this.workspace_sidebar_items = this.sidebar_data.items; if (this.edit_mode) { @@ -135,6 +136,8 @@ frappe.ui.Sidebar = class Sidebar { this.wrapper = $( frappe.render_template("sidebar", { expanded: this.sidebar_expanded, + avatar: frappe.avatar(frappe.session.user, "avatar-medium"), + navbar_settings: frappe.boot.navbar_settings, }) ).prependTo("body"); this.$sidebar = this.wrapper.find(".sidebar-items"); @@ -213,7 +216,6 @@ frappe.ui.Sidebar = class Sidebar { } create_sidebar(items) { this.empty(); - this.add_standard_items(items); if (items && items.length > 0) { items.forEach((w) => { if (!w.display_depends_on || frappe.utils.eval(w.display_depends_on)) { @@ -238,7 +240,7 @@ frappe.ui.Sidebar = class Sidebar { this.standard_items = []; if (!frappe.is_mobile()) { this.standard_items.push({ - label: "Search", + label: __("Search"), icon: "search", type: "Button", id: "navbar-modal-search", @@ -249,7 +251,7 @@ frappe.ui.Sidebar = class Sidebar { }); } this.standard_items.push({ - label: "Notification", + label: __("Notification"), icon: "bell", type: "Button", class: "sidebar-notification hidden", @@ -318,24 +320,26 @@ frappe.ui.Sidebar = class Sidebar { if (this.sidebar_expanded) { this.wrapper.addClass("expanded"); // this.sidebar_expanded = false - direction = "left"; + direction = "right"; $('[data-toggle="tooltip"]').tooltip("dispose"); + this.wrapper.find(".avatar-name-email").show(); } else { this.wrapper.removeClass("expanded"); // this.sidebar_expanded = true - direction = "right"; + direction = "left"; $('[data-toggle="tooltip"]').tooltip({ boundary: "window", container: "body", trigger: "hover", }); + this.wrapper.find(".avatar-name-email").hide(); } localStorage.setItem("sidebar-expanded", this.sidebar_expanded); this.wrapper .find(".body-sidebar .collapse-sidebar-link") .find("use") - .attr("href", `#icon-arrow-${direction}-to-line`); + .attr("href", `#icon-panel-${direction}-open`); this.sidebar_header.toggle_width(this.sidebar_expanded); $(document).trigger("sidebar-expand", { sidebar_expand: this.sidebar_expanded, @@ -403,9 +407,11 @@ frappe.ui.Sidebar = class Sidebar { if (sidebars.length == 0) { let module_name = router.meta?.module; if (module_name) { - frappe.app.sidebar.setup( - this.sidebar_module_map[module_name][0] || module_name - ); + let sidebar_title = + (this.sidebar_module_map[module_name] && + this.sidebar_module_map[module_name][0]) || + module_name; + frappe.app.sidebar.setup(sidebar_title); } } else { if (this.sidebar_title && sidebars.includes(this.workspace_title)) { diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js index 84f0b5d3ed..8efc2f62bb 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js @@ -12,7 +12,7 @@ frappe.ui.SidebarHeader = class SidebarHeader { label: "Workspaces", icon: "wallpaper", condition: function () { - return me.sibling_workspaces.length > 0; + return me.sibling_workspaces && me.sibling_workspaces.length > 0; }, items: this.sibling_workspaces, }, @@ -42,6 +42,14 @@ frappe.ui.SidebarHeader = class SidebarHeader { }, }, ]; + if (frappe.boot.desk_settings.notifications) { + this.dropdown_items.push({ + name: "help", + label: "Help", + icon: "info", + items: this.get_help_siblings(), + }); + } this.make(); this.setup_app_switcher(); this.populate_dropdown_menu(); @@ -67,6 +75,51 @@ frappe.ui.SidebarHeader = class SidebarHeader { return sibling_workspaces; } } + + get_help_siblings() { + const navbar_settings = frappe.boot.navbar_settings; + let help_dropdown_items = []; + + let custom_help_links = this.get_custom_help_links(); + + help_dropdown_items = custom_help_links.concat(help_dropdown_items); + + navbar_settings.help_dropdown.forEach((element) => { + let dropdown_children = { + name: element.name, + label: element.item_label, + }; + if (element.item_type === "Route") { + dropdown_children.url = element.route; + } + if (element.item_type === "Action") { + dropdown_children.onClick = function () { + frappe.utils.eval(element.action); + }; + } + help_dropdown_items.push(dropdown_children); + }); + + return help_dropdown_items; + } + + get_custom_help_links() { + let route = frappe.get_route_str(); + let breadcrumbs = route.split("/"); + + let links = []; + for (let i = 0; i < breadcrumbs.length; i++) { + let r = route.split("/", i + 1); + let key = r.join("/"); + let help_links = frappe.help.help_links[key] || []; + links = $.merge(links, help_links); + } + if (links.length) { + links.push({ is_divider: true }); + } + return links; + } + make() { $(".sidebar-header").remove(); $(".sidebar-header-menu").remove(); @@ -99,8 +152,14 @@ frappe.ui.SidebarHeader = class SidebarHeader { false, `var(${this.header_bg_color})` ); + } else { + this.header_icon = this.get_default_icon(); + this.header_icon = ``; } } + get_default_icon() { + return frappe.boot.app_data[0].app_logo_url; + } get_desktop_icon_by_label(title, filters) { if (!filters) { return frappe.boot.desktop_icons.find((f) => f.label === title && f.hidden != 1); diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index 5c8235249f..bb88ec5f4a 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -4,7 +4,10 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { this.item = opts.item; this.container = opts.container; this.nested_items = opts.item.nested_items || []; - this.workspace_title = $(".body-sidebar").attr("data-title").toLowerCase(); + this.workspace_title = + ($(".body-sidebar").attr("data-title") && + $(".body-sidebar").attr("data-title").toLowerCase()) || + frappe.app.sidebar.sidebar_title; this.prepare(opts); this.make(); } diff --git a/frappe/public/js/frappe/ui/tag_editor.js b/frappe/public/js/frappe/ui/tag_editor.js index 059d8bb48e..7fb42caa51 100644 --- a/frappe/public/js/frappe/ui/tag_editor.js +++ b/frappe/public/js/frappe/ui/tag_editor.js @@ -99,6 +99,21 @@ frappe.ui.TagEditor = class TagEditor { $input.trigger("input"); } }); + $input.on("enter-pressed-in-addtag", function (e) { + var value = e.target.value; + frappe.call({ + method: "frappe.desk.doctype.tag.tag.get_tags", + args: { + doctype: me.frm.doctype, + txt: value.toLowerCase(), + }, + callback: function (r) { + // Updates input to suggestion value (if any) on + if (r.message.length) $input.val(r.message[0]); + $input.trigger("input-selected"); + }, + }); + }); } get_args(tag) { return { diff --git a/frappe/public/js/frappe/ui/tags.js b/frappe/public/js/frappe/ui/tags.js index 7def9272b6..6795f04cc3 100644 --- a/frappe/public/js/frappe/ui/tags.js +++ b/frappe/public/js/frappe/ui/tags.js @@ -38,10 +38,19 @@ frappe.ui.Tags = class { }; this.$input.keypress((e) => { - if (e.which == 13 || e.keyCode == 13) select_tag(); + if (e.which == 13 || e.keyCode == 13) { + // Triggers event when is pressed + this.$input.trigger("enter-pressed-in-addtag"); + } }); this.$input.focusout(select_tag); + this.$input.on("input-selected", () => { + // Adds tag if a input is selected + select_tag(); + this.deactivate(); + }); + this.$input.on("blur", () => { this.deactivate(); }); diff --git a/frappe/public/js/frappe/ui/toolbar/navbar.html b/frappe/public/js/frappe/ui/toolbar/navbar.html index 057dae067f..9879e4d17d 100644 --- a/frappe/public/js/frappe/ui/toolbar/navbar.html +++ b/frappe/public/js/frappe/ui/toolbar/navbar.html @@ -24,51 +24,6 @@ {% endif %} - - - - - - - - - - - - {{ __("Help") }} - - - - - - - {% for item in navbar_settings.help_dropdown %} - {% if (!item.hidden) { %} - {% if (item.route) { %} - - {%= __(item.item_label) %} - - {% } else if (item.action) { %} - - {%= __(item.item_label) %} - - {% } else { %} - - {% } %} - {% } %} - {% endfor %} - - ", { - href: url, - class: "dropdown-item", - text: __(link.label), - target: "_blank", - }).appendTo($help_links); - } - - $(".dropdown-help .dropdown-menu").on("click", "a", show_results); - }); - - var $result_modal = frappe.get_modal("", ""); - $result_modal.addClass("help-modal"); - - $(document).on("click", ".help-modal a", show_results); - - function show_results(e) { - //edit links - var href = e.target.href; - if (href.indexOf("blob") > 0) { - window.open(href, "_blank"); - } - var path = $(e.target).attr("data-path"); - if (path) { - e.preventDefault(); - } - } - } - add_back_button() { if (!frappe.is_mobile()) return; this.navbar = $(".navbar-brand"); diff --git a/frappe/public/scss/desk/avatar.scss b/frappe/public/scss/desk/avatar.scss index 93a04a591b..4126815e89 100644 --- a/frappe/public/scss/desk/avatar.scss +++ b/frappe/public/scss/desk/avatar.scss @@ -99,9 +99,8 @@ } .avatar-medium { - width: 28px; - height: 28px; - + width: 22px; + height: 22px; .standard-image { @include get_textstyle("base", "regular"); } diff --git a/frappe/public/scss/desk/filters.scss b/frappe/public/scss/desk/filters.scss index 27f21a087f..7552eed401 100644 --- a/frappe/public/scss/desk/filters.scss +++ b/frappe/public/scss/desk/filters.scss @@ -105,3 +105,23 @@ } } } + +.input-group > .form-control:not(:last-child) { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + margin-right: 0px !important; +} + +.match-type-dropdown-btn { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + height: var(--btn-height); + border-left: 1px solid var(--btn-group-border-color); + + &:focus { + box-shadow: none !important; + } +} +.match-type-dropdown-menu { + min-width: 100px; +} diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index e2d997655c..a001aae643 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -73,11 +73,8 @@ } .body-sidebar-bottom { - overflow: hidden; padding: 7px; width: 100%; - // position: relative; - // top:10px; } .divider { @@ -141,7 +138,7 @@ flex: 0 0 auto; } span { - margin-left: 8px; + margin-left: 10px; @include truncate(); } @include transition(all, 0.3s, cubic-bezier(0.4, 0, 0.2, 1)); @@ -157,12 +154,14 @@ } .standard-items-sections { + margin-top: 14px; .navbar-search-bar { background-color: var(--control-bg-on-gray); border-radius: var(--border-radius); padding-right: 7px; .standard-sidebar-item a:hover { background-color: var(--control-bg-on-gray); + border-radius: var(--border-radius); } } .dropdown-notifications { diff --git a/frappe/tests/classes/integration_test_case.py b/frappe/tests/classes/integration_test_case.py index ac3809b137..0a39226c05 100644 --- a/frappe/tests/classes/integration_test_case.py +++ b/frappe/tests/classes/integration_test_case.py @@ -1,5 +1,7 @@ import copy +import faulthandler import logging +import sys from contextlib import AbstractContextManager, contextmanager from types import MappingProxyType @@ -12,6 +14,8 @@ from .unit_test_case import UnitTestCase logger = logging.Logger(__file__) +STUCK_TEST_THRESHOLD = 5 * 60 + class IntegrationTestCase(UnitTestCase): """Integration test class for Frappe tests. @@ -74,6 +78,8 @@ class IntegrationTestCase(UnitTestCase): super().tearDownClass() def setUp(self) -> None: + faulthandler.dump_traceback_later(STUCK_TEST_THRESHOLD, file=sys.__stderr__) + self.addCleanup(faulthandler.cancel_dump_traceback_later) super().setUp() # Add any per-test setup code here diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 0f9ae4d5c4..14ece934d9 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -403,8 +403,8 @@ class TestDB(IntegrationTestCase): random_field, ) self.assertEqual( - next(iter(frappe.get_all("ToDo", fields=[{"COUNT": random_field}], limit=1)[0])), - "COUNT" if frappe.conf.db_type == "postgres" else f"COUNT(`{random_field}`)", + next(iter(frappe.get_all("ToDo", fields=[{"COUNT": random_field}], limit=1, order_by=None)[0])), + "count" if frappe.conf.db_type == "postgres" else f"COUNT(`{random_field}`)", ) # Testing update diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index c9cfa96fbc..d6d444eb34 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -848,11 +848,7 @@ class TestDBQuery(IntegrationTestCase): limit=1, as_list=True, ) - if frappe.conf.db_type == "mariadb": - self.assertTrue(len(doctypes[0]) == 2) - else: - self.assertTrue(len(doctypes[0]) == 3) - self.assertTrue(isinstance(doctypes[0][2], datetime.datetime)) + self.assertTrue(len(doctypes[0]) == 2) # same for pg as well since we order_by None def test_field_comparison(self): """Test DatabaseQuery.execute to test field comparison""" diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py index 365bcbfac2..78527667fb 100644 --- a/frappe/tests/test_naming.py +++ b/frappe/tests/test_naming.py @@ -381,7 +381,6 @@ class TestNaming(IntegrationTestCase): name = parse_naming_series(series, doc=webhook) self.assertTrue(name.startswith("KOOH---"), f"incorrect name generated {name}") - @run_only_if(db_type_is.MARIADB) def test_hash_collision(self): doctype = new_doctype(autoname="hash").insert().name name = frappe.generate_hash() diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 1db2491e56..96e78990f3 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -68,6 +68,8 @@ class TestQuery(IntegrationTestCase): setup_for_tests() def test_multiple_tables_in_filters(self): + query = "SELECT `tabDocType`.* FROM `tabDocType` LEFT JOIN `tabDocField` ON `tabDocField`.`parent`=`tabDocType`.`name` AND `tabDocField`.`parenttype`='DocType' AND `tabDocField`.`parentfield`='fields' WHERE `tabDocField`.`name` LIKE 'f%' AND `tabDocType`.`parent`='something'" + query = query.replace("LIKE", "ILIKE" if frappe.db.db_type == "postgres" else "LIKE") self.assertQueryEqual( frappe.qb.get_query( "DocType", @@ -77,7 +79,7 @@ class TestQuery(IntegrationTestCase): ["DocType", "parent", "=", "something"], ], ).get_sql(), - "SELECT `tabDocType`.* FROM `tabDocType` LEFT JOIN `tabDocField` ON `tabDocField`.`parent`=`tabDocType`.`name` AND `tabDocField`.`parenttype`='DocType' AND `tabDocField`.`parentfield`='fields' WHERE `tabDocField`.`name` LIKE 'f%' AND `tabDocType`.`parent`='something'", + query, ) def test_string_fields(self): @@ -360,13 +362,15 @@ class TestQuery(IntegrationTestCase): "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name`='frappe'", ) + query = "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name` LIKE 'frap%'" + query = query.replace("LIKE", "ILIKE" if frappe.db.db_type == "postgres" else "LIKE") self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], filters={"module.app_name": ("like", "frap%")}, ).get_sql(), - "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name` LIKE 'frap%'", + query, ) self.assertQueryEqual( @@ -422,141 +426,125 @@ class TestQuery(IntegrationTestCase): def test_or_filters(self): """Test OR filter conditions.""" # Test 1: Basic dict or_filters - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], or_filters={"name": "User", "module": "Core"}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name`='User' OR `module`='Core'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name`='User' OR `module`='Core'", ) # Test 2: List format or_filters - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], or_filters=[["name", "=", "User"], ["module", "=", "Core"]], ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name`='User' OR `module`='Core'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name`='User' OR `module`='Core'", ) # Test 3: OR filters with operators - self.assertEqual( + query = "SELECT `name` FROM `tabDocType` WHERE `name` LIKE 'User%' OR `module` IN ('Core','Custom')" + query = query = query.replace("LIKE", "ILIKE" if frappe.db.db_type == "postgres" else "LIKE") + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], or_filters={"name": ("like", "User%"), "module": ("in", ["Core", "Custom"])}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name` LIKE 'User%' OR `module` IN ('Core','Custom')".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + query, ) # Test 4: Combining filters (AND) with or_filters (OR) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], filters={"issingle": 0}, or_filters={"name": "User", "module": "Core"}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `issingle`=0 AND (`name`='User' OR `module`='Core')".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `issingle`=0 AND (`name`='User' OR `module`='Core')", ) # Test 5: Multiple AND filters with OR filters - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], filters={"issingle": 0, "custom": 0}, or_filters={"name": "User", "module": "Core"}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `issingle`=0 AND `custom`=0 AND (`name`='User' OR `module`='Core')".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `issingle`=0 AND `custom`=0 AND (`name`='User' OR `module`='Core')", ) # Test 6: OR filters with simple list (name IN) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", or_filters=["User", "Role", "Note"], ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name` IN ('User','Role','Note')".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name` IN ('User','Role','Note')", ) # Test 7: OR filters with greater than and less than - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], or_filters={"idx": (">", 5), "issingle": ("=", 1)}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `idx`>5 OR `issingle`=1".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `idx`>5 OR `issingle`=1", ) # Test 8: OR filters with list including doctype - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], or_filters=[["DocType", "name", "=", "User"], ["DocType", "name", "=", "Role"]], ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name`='User' OR `name`='Role'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name`='User' OR `name`='Role'", ) # Test 9: OR filters with != operator - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], or_filters={"name": ("!=", "User"), "module": ("!=", "Core")}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name`<>'User' OR `module`<>'Core'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name`<>'User' OR `module`<>'Core'", ) # Test 10: Empty or_filters should return query without OR conditions - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], filters={"custom": 0}, or_filters={}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `custom`=0".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `custom`=0", ) # Test 11: OR filters with not in operator - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], or_filters={"name": ("not in", ["User", "Role"]), "module": ("=", "Core")}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name` NOT IN ('User','Role') OR `module`='Core'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name` NOT IN ('User','Role') OR `module`='Core'", ) # Test 12: OR filters with mixed field types - self.assertEqual( + query = ( + "SELECT `name`,`module` FROM `tabDocType` WHERE `name` LIKE 'User%' OR `issingle`=1 OR `custom`=0" + ) + query = query = query.replace("LIKE", "ILIKE" if frappe.db.db_type == "postgres" else "LIKE") + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name", "module"], @@ -566,9 +554,7 @@ class TestQuery(IntegrationTestCase): ["custom", "=", 0], ], ).get_sql(), - "SELECT `name`,`module` FROM `tabDocType` WHERE `name` LIKE 'User%' OR `issingle`=1 OR `custom`=0".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + query, ) def test_nested_filters(self): @@ -665,7 +651,11 @@ class TestQuery(IntegrationTestCase): .where( (User.creation > "2023-01-01") & ( - (User.email.like("%@example.com")) + ( + User.email.ilike("%@example.com") + if frappe.db.db_type == "postgres" + else User.email.like("%@example.com") + ) | ((User.first_name.isin(["Admin", "Guest"])) & (User.enabled != 1)) ) ) @@ -710,49 +700,41 @@ class TestQuery(IntegrationTestCase): def test_implicit_join_query(self): self.maxDiff = None - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "Note", filters={"name": "Test Note Title"}, fields=["name", "`tabNote Seen By`.`user` as seen_by"], ).get_sql(), - "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'", ) # output doesn't contain parentfield condition because it can't be inferred - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "Note", filters={"name": "Test Note Title"}, fields=["name", "`tabNote Seen By`.`user` as seen_by", "`tabNote Seen By`.`idx` as idx"], ).get_sql(), - "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by`,`tabNote Seen By`.`idx` `idx` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by`,`tabNote Seen By`.`idx` `idx` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'", ) # output contains parentfield condition because it can be inferred by "seen_by.user" - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "Note", filters={"name": "Test Note Title"}, fields=["name", "seen_by.user as seen_by", "`tabNote Seen By`.`idx` as idx"], ).get_sql(), - "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by`,`tabNote Seen By`.`idx` `idx` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' AND `tabNote Seen By`.`parentfield`='seen_by' WHERE `tabNote`.`name`='Test Note Title'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by`,`tabNote Seen By`.`idx` `idx` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' AND `tabNote Seen By`.`parentfield`='seen_by' WHERE `tabNote`.`name`='Test Note Title'", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name", "module.app_name as app_name"], ).get_sql(), - "SELECT `tabDocType`.`name`,`tabModule Def`.`app_name` `app_name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module`".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `tabDocType`.`name`,`tabModule Def`.`app_name` `app_name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module`", ) # fields now has strict validation, so this test is not valid anymore @@ -878,7 +860,9 @@ class TestQuery(IntegrationTestCase): if frappe.db.db_type == "mariadb": self.assertIn("IFNULL(`name`,'')='' OR `name` IN ('_Test Blog Post 1','_Test Blog Post')", query) elif frappe.db.db_type == "postgres": - self.assertIn("\"name\" IS NULL OR \"name\" IN ('_Test Blog Post 1','_Test Blog Post')", query) + self.assertIn( + "IFNULL(\"name\",'')='' OR \"name\" IN ('_Test Blog Post 1','_Test Blog Post')", query + ) # works in pg due to `coalesce` sub during sql execution frappe.set_user("Administrator") clear_user_permissions_for_doctype("Test Blog Post", "test2@example.com") @@ -1762,39 +1746,39 @@ class TestQuery(IntegrationTestCase): # Test simple addition query = frappe.qb.get_query("User", fields=[{"ADD": [1, 2], "as": "sum_result"}]) sql = query.get_sql() - self.assertIn("1+2 `sum_result`", sql) + self.assertIn(self.normalize_sql("1+2 `sum_result`"), sql) # Test simple subtraction query = frappe.qb.get_query("User", fields=[{"SUB": [10, 5], "as": "diff_result"}]) sql = query.get_sql() - self.assertIn("10-5 `diff_result`", sql) + self.assertIn(self.normalize_sql("10-5 `diff_result`"), sql) # Test simple multiplication query = frappe.qb.get_query("User", fields=[{"MUL": [3, 4], "as": "prod_result"}]) sql = query.get_sql() - self.assertIn("3*4 `prod_result`", sql) + self.assertIn(self.normalize_sql("3*4 `prod_result`"), sql) # Test simple division query = frappe.qb.get_query("User", fields=[{"DIV": [10, 2], "as": "div_result"}]) sql = query.get_sql() - self.assertIn("10/2 `div_result`", sql) + self.assertIn(self.normalize_sql("10/2 `div_result`"), sql) # Test operator with field names query = frappe.qb.get_query("User", fields=[{"ADD": ["enabled", "login_after"], "as": "field_sum"}]) sql = query.get_sql() - self.assertIn("`enabled`+`login_after` `field_sum`", sql) + self.assertIn(self.normalize_sql("`enabled`+`login_after` `field_sum`"), sql) # Test nested operators query = frappe.qb.get_query("User", fields=[{"ADD": [{"MUL": [2, 3]}, 4], "as": "nested_result"}]) sql = query.get_sql() - self.assertIn("2*3+4 `nested_result`", sql) + self.assertIn(self.normalize_sql("2*3+4 `nested_result`"), sql) # Test operator with function - NULLIF query = frappe.qb.get_query( "User", fields=[{"DIV": [1, {"NULLIF": ["enabled", 0]}], "as": "safe_div"}] ) sql = query.get_sql() - self.assertIn("1/NULLIF(`enabled`,0) `safe_div`", sql) + self.assertIn(self.normalize_sql("1/NULLIF(`enabled`,0) `safe_div`"), self.normalize_sql(sql)) # Test complex nested expression: (1 / NULLIF(value, 0)) query = frappe.qb.get_query( @@ -1805,8 +1789,8 @@ class TestQuery(IntegrationTestCase): ], ) sql = query.get_sql() - self.assertIn("`name`", sql) - self.assertIn("1/NULLIF(`enabled`,0) `inverse`", sql) + self.assertIn(self.normalize_sql("`name`"), sql) + self.assertIn(self.normalize_sql("1/NULLIF(`enabled`,0) `inverse`"), self.normalize_sql(sql)) # Test operator with LOCATE function (search relevance pattern) query = frappe.qb.get_query( @@ -1817,7 +1801,10 @@ class TestQuery(IntegrationTestCase): ], ) sql = query.get_sql() - self.assertIn("1/NULLIF(LOCATE('test',`name`),0) `relevance`", sql) + self.assertIn( + self.normalize_sql("1/NULLIF(LOCATE('test',`name`),0) `relevance`"), + self.normalize_sql(sql), + ) # Test multiple operators in fields query = frappe.qb.get_query( @@ -1829,9 +1816,9 @@ class TestQuery(IntegrationTestCase): ], ) sql = query.get_sql() - self.assertIn("`name`", sql) - self.assertIn("`enabled`+1 `enabled_plus_one`", sql) - self.assertIn("`enabled`*2 `enabled_times_two`", sql) + self.assertIn(self.normalize_sql("`name`"), sql) + self.assertIn(self.normalize_sql("`enabled`+1 `enabled_plus_one`"), sql) + self.assertIn(self.normalize_sql("`enabled`*2 `enabled_times_two`"), sql) # Test operator without alias query = frappe.qb.get_query("User", fields=[{"ADD": [1, 1]}]) @@ -1898,9 +1885,12 @@ class TestQuery(IntegrationTestCase): ) sql = query.get_sql() - self.assertIn("GROUP BY `created_date`", sql) - self.assertIn("ORDER BY `created_date`", sql) - self.assertIn("`creation` `created_date`", sql) + self.assertIn(self.normalize_sql("GROUP BY `created_date`"), self.normalize_sql(sql)) + if ( + frappe.db.db_type != "postgres" + ): # since Postgres requires fields in Order by to be grouped or aggregated, order by is dropped + self.assertIn(self.normalize_sql("ORDER BY `created_date`"), self.normalize_sql(sql)) + self.assertIn(self.normalize_sql("`creation` `created_date`"), self.normalize_sql(sql)) def test_field_alias_permission_check(self): query = frappe.qb.get_query( @@ -1910,7 +1900,7 @@ class TestQuery(IntegrationTestCase): ) sql = query.get_sql() # If we get here without PermissionError, the test passes - self.assertIn("GROUP BY `created_date`", sql) + self.assertIn(self.normalize_sql("GROUP BY `created_date`"), self.normalize_sql(sql)) # This function is used as a permission query condition hook diff --git a/frappe/utils/pdf_generator/cdp_connection.py b/frappe/utils/pdf_generator/cdp_connection.py index 3c0874aca4..b3b06bd6b3 100644 --- a/frappe/utils/pdf_generator/cdp_connection.py +++ b/frappe/utils/pdf_generator/cdp_connection.py @@ -93,7 +93,7 @@ class CDPSocketClient: async def _disconnect(self): try: - if self.connection and not self.connection.closed: + if self.connection: await self.connection.close() self.connection = None except Exception: diff --git a/frappe/utils/user.py b/frappe/utils/user.py index 700ed6efda..a3cd923a7e 100644 --- a/frappe/utils/user.py +++ b/frappe/utils/user.py @@ -176,7 +176,10 @@ class UserPermissions: self.can_read += self.can_write self.shared = frappe.get_all( - "DocShare", {"user": self.name, "read": 1}, distinct=True, pluck="share_doctype" + "DocShare", + {"user": self.name, "read": 1}, + distinct=True, + pluck="share_doctype", ) self.can_read = list(set(self.can_read + self.shared)) self.all_read += self.can_read diff --git a/frappe/website/utils.py b/frappe/website/utils.py index 5dca169bc0..7ea118824a 100644 --- a/frappe/website/utils.py +++ b/frappe/website/utils.py @@ -559,7 +559,7 @@ def build_response(path, data, http_status_code, headers: dict | None = None): if headers: for key, val in headers.items(): - response.headers[key] = cstr(cstr(val).encode("ascii", errors="xmlcharrefreplace")) + response.headers[key] = cstr(cstr(val).encode("utf-8", errors="xmlcharrefreplace")) return response