From 388d8b2bc737a7db34e5be8b314c3bb2d8c27672 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 12:17:40 +0530 Subject: [PATCH 01/51] feat: Computed/virtual child-tables - They need to be a non-data descriptor i.e. cached_property or equivalent - When "loading from DB", we will trigger the descriptor - The descriptor can return dict or docs for better DX. - We initialize docs as normal child tables --- frappe/core/doctype/doctype/doctype.py | 5 +- frappe/core/doctype/user/user.json | 17 +++++- frappe/core/doctype/user/user.py | 32 +++++++++++ .../doctype/user_session_display/__init__.py | 0 .../user_session_display.json | 54 +++++++++++++++++++ .../user_session_display.py | 48 +++++++++++++++++ frappe/model/document.py | 8 ++- 7 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 frappe/core/doctype/user_session_display/__init__.py create mode 100644 frappe/core/doctype/user_session_display/user_session_display.json create mode 100644 frappe/core/doctype/user_session_display/user_session_display.py diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 1b94db76a9..5c8a33227d 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1641,10 +1641,9 @@ def validate_fields(meta: Meta): title=_("Invalid Option"), ) - if meta.is_virtual != child_doctype_meta.is_virtual: - error_msg = " should be virtual." if meta.is_virtual else " cannot be virtual." + if meta.is_virtual and not child_doctype_meta.is_virtual: frappe.throw( - _("Child Table {0} for field {1}" + error_msg).format( + _("Cannot put a non-virtual Child Table {0} for field {1} on Virtual parent").format( frappe.bold(doctype), frappe.bold(docfield.fieldname) ), title=_("Invalid Option"), diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index dc9d2bc6a5..4ecffd349a 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -111,7 +111,9 @@ "column_break_65", "api_secret", "onboarding_status", - "connections_tab" + "connections_tab", + "sessions_tab", + "active_sessions" ], "fields": [ { @@ -834,6 +836,17 @@ "fieldname": "show_absolute_datetime_in_timeline", "fieldtype": "Check", "label": "Show Absolute Datetime in Timeline" + }, + { + "fieldname": "sessions_tab", + "fieldtype": "Tab Break", + "label": "Sessions" + }, + { + "fieldname": "active_sessions", + "fieldtype": "Table", + "label": "Active Sessions", + "options": "User Session Display" } ], "icon": "fa fa-user", @@ -892,7 +905,7 @@ } ], "make_attachments_public": 1, - "modified": "2025-06-05 23:55:27.884061", + "modified": "2025-06-23 14:24:20.505385", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index f53351b4ff..d0a9301a48 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -3,6 +3,7 @@ from collections.abc import Iterable from datetime import timedelta +from functools import cached_property import frappe import frappe.defaults @@ -62,9 +63,11 @@ class User(Document): from frappe.core.doctype.has_role.has_role import HasRole from frappe.core.doctype.user_email.user_email import UserEmail from frappe.core.doctype.user_role_profile.user_role_profile import UserRoleProfile + from frappe.core.doctype.user_session_display.user_session_display import UserSessionDisplay from frappe.core.doctype.user_social_login.user_social_login import UserSocialLogin from frappe.types import DF + active_sessions: DF.Table[UserSessionDisplay] allowed_in_mentions: DF.Check api_key: DF.Data | None api_secret: DF.Password | None @@ -142,6 +145,35 @@ class User(Document): __new_password = None + @cached_property + def active_sessions(self): + sessions = frappe.qb.DocType("Sessions") + + sessions_data = ( + frappe.qb.from_(sessions) + .select(sessions.user, sessions.sessiondata, sessions.sid) + .where(sessions.user == self.name) + ).run(as_dict=True) + + def mask(sid: str): + return sid[:4] + "*" * (len(sid) - 4) + + session_docs = [] + for session in sessions_data: + data = frappe.parse_json(session.sessiondata) + session_docs.append( + { + "name": mask(session.sid), + "id": mask(session.sid), + "owner": session.user, + "modified_by": session.user, + "ip_address": data.session_ip, + "last_updated": data.last_updated, + "session_created": data.creation, + } + ) + return session_docs + def __setup__(self): # because it is handled separately self.flags.ignore_save_passwords = ["new_password"] diff --git a/frappe/core/doctype/user_session_display/__init__.py b/frappe/core/doctype/user_session_display/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/user_session_display/user_session_display.json b/frappe/core/doctype/user_session_display/user_session_display.json new file mode 100644 index 0000000000..17cecd8930 --- /dev/null +++ b/frappe/core/doctype/user_session_display/user_session_display.json @@ -0,0 +1,54 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-06-23 14:19:48.353900", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "id", + "ip_address", + "session_created", + "last_updated" + ], + "fields": [ + { + "fieldname": "id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "ID" + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "in_list_view": 1, + "label": "IP Address" + }, + { + "fieldname": "session_created", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Session Created" + }, + { + "fieldname": "last_updated", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Last Updated" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "is_virtual": 1, + "istable": 1, + "links": [], + "modified": "2025-06-23 14:25:15.690896", + "modified_by": "Administrator", + "module": "Core", + "name": "User Session Display", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/core/doctype/user_session_display/user_session_display.py b/frappe/core/doctype/user_session_display/user_session_display.py new file mode 100644 index 0000000000..4af8f0a8ea --- /dev/null +++ b/frappe/core/doctype/user_session_display/user_session_display.py @@ -0,0 +1,48 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class UserSessionDisplay(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + id: DF.Data | None + ip_address: DF.Data | None + last_updated: DF.Datetime | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + session_created: DF.Datetime | None + # end: auto-generated types + + def db_insert(self, *args, **kwargs): + raise NotImplementedError + + def load_from_db(self): + raise NotImplementedError + + def db_update(self): + raise NotImplementedError + + def delete(self): + raise NotImplementedError + + @staticmethod + def get_list(filters=None, page_length=20, **kwargs): + pass + + @staticmethod + def get_count(filters=None, **kwargs): + pass + + @staticmethod + def get_stats(**kwargs): + pass diff --git a/frappe/model/document.py b/frappe/model/document.py index 48da6c7355..719730394f 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -274,7 +274,13 @@ class Document(BaseDocument): for fieldname, child_doctype in self._table_fieldnames.items(): # Make sure not to query the DB for a child table, if it is a virtual one. if not is_doctype and is_virtual_doctype(child_doctype): - self.set(fieldname, []) + # Users must specify non-data descriptor/cached_property for computed table + # Remove previous value if any, required for reload. + self.__dict__.pop(fieldname, None) + values = getattr(self, fieldname, []) + # Convert to documents + self.__dict__[fieldname] = [] + self.extend(fieldname, values) continue if is_doctype: From 17324982b54a01df846b3e6d0d0d0ba5505d447b Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 12:39:56 +0530 Subject: [PATCH 02/51] fix: force virtual field too --- frappe/core/doctype/doctype/doctype.py | 10 +++++++++- frappe/core/doctype/user/user.json | 3 ++- frappe/model/document.py | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 5c8a33227d..3dc6f9a21f 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1643,12 +1643,20 @@ def validate_fields(meta: Meta): if meta.is_virtual and not child_doctype_meta.is_virtual: frappe.throw( - _("Cannot put a non-virtual Child Table {0} for field {1} on Virtual parent").format( + _("Child Table {0} for field {1} must be virtual").format( frappe.bold(doctype), frappe.bold(docfield.fieldname) ), title=_("Invalid Option"), ) + if not meta.is_virtual and child_doctype_meta.is_virtual and not docfield.is_virtual: + frappe.throw( + _("Field {0} must be a virtual field to support virtual doctype.").format( + frappe.bold(docfield.fieldname) + ), + title=_("Virtual tabels must be virtual fields"), + ) + def check_max_height(docfield): if getattr(docfield, "max_height", None) and (docfield.max_height[-2:] not in ("px", "em")): frappe.throw(f"Max for {frappe.bold(docfield.fieldname)} height must be in px, em, rem") diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 4ecffd349a..93874a441e 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -845,6 +845,7 @@ { "fieldname": "active_sessions", "fieldtype": "Table", + "is_virtual": 1, "label": "Active Sessions", "options": "User Session Display" } @@ -905,7 +906,7 @@ } ], "make_attachments_public": 1, - "modified": "2025-06-23 14:24:20.505385", + "modified": "2025-06-25 12:42:47.074515", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/model/document.py b/frappe/model/document.py index 719730394f..13d14c45e3 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -273,7 +273,11 @@ class Document(BaseDocument): for fieldname, child_doctype in self._table_fieldnames.items(): # Make sure not to query the DB for a child table, if it is a virtual one. - if not is_doctype and is_virtual_doctype(child_doctype): + if ( + not is_doctype + and is_virtual_doctype(child_doctype) + and self.meta.get_field(fieldname).is_virtual + ): # Users must specify non-data descriptor/cached_property for computed table # Remove previous value if any, required for reload. self.__dict__.pop(fieldname, None) From bb58e7aebe24da326a1deee7e81308d3021b7874 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 12:51:42 +0530 Subject: [PATCH 03/51] fix: Don't update computed tables --- frappe/model/document.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/model/document.py b/frappe/model/document.py index 13d14c45e3..f877d61ed9 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -590,6 +590,8 @@ class Document(BaseDocument): def update_child_table(self, fieldname: str, df: Optional["DocField"] = None): """sync child table for given fieldname""" df: DocField = df or self.meta.get_field(fieldname) + if df.is_virtual: + return all_rows = self.get(df.fieldname) # delete rows that do not match the ones in the document From 7b76bd709129a6ecbfb750026c9f2791322027e1 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 13:54:57 +0530 Subject: [PATCH 04/51] fix: Keep updating session IP --- frappe/sessions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/sessions.py b/frappe/sessions.py index 5e9fdb43b9..5aa7ba610d 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -419,6 +419,7 @@ class Session: ) and not frappe.flags.read_only: self.data.data.last_updated = now self.data.data.lang = str(frappe.lang) + self.data.data.session_ip = frappe.local.request_ip Sessions = frappe.qb.DocType("Sessions") # update sessions table From 9a6ecae78a6bf1160831dd3dd727bb1752c7fd1e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 13:59:42 +0530 Subject: [PATCH 05/51] feat: Log out of a session --- frappe/core/doctype/user/user.js | 8 +++++++ frappe/core/doctype/user/user.py | 24 +++++++++++++++++-- .../user_session_display.json | 21 ++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index dc84f5f638..d780a79fc6 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -437,3 +437,11 @@ function get_roles_for_editing_user() { .map((perm) => perm.role) || ["System Manager"] ); } + +frappe.ui.form.on("User Session Display", { + sign_out(frm, doctype, name) { + frappe + .xcall("frappe.core.doctype.user.user.clear_session", { sid_hash: name }) + .then(() => frm.reload_doc()); + }, +}); diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index d0a9301a48..fc66153663 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -148,6 +148,9 @@ class User(Document): @cached_property def active_sessions(self): sessions = frappe.qb.DocType("Sessions") + if self.name != frappe.session.user: + # sec: only allow users to see their sessions. + return [] sessions_data = ( frappe.qb.from_(sessions) @@ -156,14 +159,14 @@ class User(Document): ).run(as_dict=True) def mask(sid: str): - return sid[:4] + "*" * (len(sid) - 4) + return sid[:4] + "*" * 10 session_docs = [] for session in sessions_data: data = frappe.parse_json(session.sessiondata) session_docs.append( { - "name": mask(session.sid), + "name": sha256_hash(session.sid), "id": mask(session.sid), "owner": session.user, "modified_by": session.user, @@ -1414,3 +1417,20 @@ def impersonate(user: str, reason: str): notification.set("type", "Alert") notification.insert(ignore_permissions=True) frappe.local.login_manager.impersonate(user) + + +@frappe.whitelist() +@rate_limit(limit=10, seconds=60 * 60, methods="POST") +def clear_session(sid_hash: str): + from frappe.sessions import delete_session + + sessions = frappe.qb.DocType("Sessions") + sessions_data = ( + frappe.qb.from_(sessions).select(sessions.sid).where(sessions.user == frappe.session.user) + ).run(pluck=True) + + for session in sessions_data: + if sha256_hash(session) == sid_hash: + delete_session(sid=session, reason="Force Logged out by the user", user=frappe.session.user) + frappe.toast(_("Successfully signed out")) + return diff --git a/frappe/core/doctype/user_session_display/user_session_display.json b/frappe/core/doctype/user_session_display/user_session_display.json index 17cecd8930..b0586d8df5 100644 --- a/frappe/core/doctype/user_session_display/user_session_display.json +++ b/frappe/core/doctype/user_session_display/user_session_display.json @@ -7,8 +7,11 @@ "field_order": [ "id", "ip_address", + "column_break_cjgl", "session_created", - "last_updated" + "last_updated", + "actions_section", + "sign_out" ], "fields": [ { @@ -34,6 +37,20 @@ "fieldtype": "Datetime", "in_list_view": 1, "label": "Last Updated" + }, + { + "fieldname": "sign_out", + "fieldtype": "Button", + "label": "Sign Out" + }, + { + "fieldname": "column_break_cjgl", + "fieldtype": "Column Break" + }, + { + "fieldname": "actions_section", + "fieldtype": "Section Break", + "label": "Actions" } ], "grid_page_length": 50, @@ -41,7 +58,7 @@ "is_virtual": 1, "istable": 1, "links": [], - "modified": "2025-06-23 14:25:15.690896", + "modified": "2025-06-25 14:09:28.616334", "modified_by": "Administrator", "module": "Core", "name": "User Session Display", From 4065ec2903aaa9a4881c1e3ea7d13afd31e703a4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 14:30:30 +0530 Subject: [PATCH 06/51] feat: Identify current session and store user agent --- frappe/core/doctype/user/user.py | 7 +++++-- .../user_session_display.json | 16 +++++++++++++++- .../user_session_display/user_session_display.py | 2 ++ frappe/sessions.py | 3 +++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index fc66153663..8b2060103b 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -164,15 +164,18 @@ class User(Document): session_docs = [] for session in sessions_data: data = frappe.parse_json(session.sessiondata) + sid_hash = sha256_hash(session.sid) session_docs.append( { - "name": sha256_hash(session.sid), - "id": mask(session.sid), + "name": sid_hash, + "id": mask(sid_hash), "owner": session.user, "modified_by": session.user, "ip_address": data.session_ip, "last_updated": data.last_updated, + "current": session.sid == frappe.session.sid, "session_created": data.creation, + "user_agent": data.user_agent, } ) return session_docs diff --git a/frappe/core/doctype/user_session_display/user_session_display.json b/frappe/core/doctype/user_session_display/user_session_display.json index b0586d8df5..7e3d6dee73 100644 --- a/frappe/core/doctype/user_session_display/user_session_display.json +++ b/frappe/core/doctype/user_session_display/user_session_display.json @@ -6,7 +6,9 @@ "engine": "InnoDB", "field_order": [ "id", + "current", "ip_address", + "user_agent", "column_break_cjgl", "session_created", "last_updated", @@ -51,6 +53,18 @@ "fieldname": "actions_section", "fieldtype": "Section Break", "label": "Actions" + }, + { + "default": "0", + "fieldname": "current", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Current?" + }, + { + "fieldname": "user_agent", + "fieldtype": "Small Text", + "label": "User Agent" } ], "grid_page_length": 50, @@ -58,7 +72,7 @@ "is_virtual": 1, "istable": 1, "links": [], - "modified": "2025-06-25 14:09:28.616334", + "modified": "2025-06-25 14:34:28.765780", "modified_by": "Administrator", "module": "Core", "name": "User Session Display", diff --git a/frappe/core/doctype/user_session_display/user_session_display.py b/frappe/core/doctype/user_session_display/user_session_display.py index 4af8f0a8ea..e6d2733f1e 100644 --- a/frappe/core/doctype/user_session_display/user_session_display.py +++ b/frappe/core/doctype/user_session_display/user_session_display.py @@ -14,6 +14,7 @@ class UserSessionDisplay(Document): if TYPE_CHECKING: from frappe.types import DF + current: DF.Check id: DF.Data | None ip_address: DF.Data | None last_updated: DF.Datetime | None @@ -21,6 +22,7 @@ class UserSessionDisplay(Document): parentfield: DF.Data parenttype: DF.Data session_created: DF.Datetime | None + user_agent: DF.SmallText | None # end: auto-generated types def db_insert(self, *args, **kwargs): diff --git a/frappe/sessions.py b/frappe/sessions.py index 5aa7ba610d..c319e1a680 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -256,6 +256,9 @@ class Session: self.data.data.user = self.user self.data.data.session_ip = frappe.local.request_ip + if frappe.request: + self.data.data.user_agent = frappe.request.headers.get("User-Agent") + if session_end: self.data.data.session_end = session_end From 87e0253736ae4e66af332860a4ac1a7eff07a7f6 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 17:56:51 +0530 Subject: [PATCH 07/51] fix: Skip virtual fields while renaming --- frappe/model/rename_doc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 5b1f9c3fe0..85b406df57 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -9,6 +9,7 @@ from frappe import _, bold from frappe.model.document import Document from frappe.model.dynamic_links import get_dynamic_link_map from frappe.model.naming import validate_name +from frappe.model.utils import is_virtual_doctype from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data from frappe.query_builder import Field from frappe.utils.data import cint, cstr, sbool @@ -416,6 +417,8 @@ def rename_doctype(doctype: str, old: str, new: str) -> None: def update_child_docs(old: str, new: str, meta: "Meta") -> None: # update "parent" for df in meta.get_table_fields(): + if df.is_virtual or is_virtual_doctype(df.options): + continue ( frappe.qb.update(df.options) .set("parent", new) From 3d50221762733ac06f199d1805ff38fed417bcbf Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 17:59:48 +0530 Subject: [PATCH 08/51] fix: Don't lazy load virtual tables --- frappe/model/document.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/model/document.py b/frappe/model/document.py index f877d61ed9..cb429e2195 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1969,6 +1969,8 @@ def get_lazy_controller(doctype): # Dynamically construct a class that subclasses LazyDocument and original controller. lazy_controller = type(f"Lazy{original_controller.__name__}", (LazyDocument, original_controller), {}) for fieldname, child_doctype in meta._table_doctypes.items(): + if meta.get_field(fieldname).is_virtual: + continue setattr(lazy_controller, fieldname, LazyChildTable(fieldname, child_doctype)) lazy_controllers[doctype] = lazy_controller From ff9995ec295e7339e7a863ec48a25969594d9148 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 10 Sep 2025 07:30:22 +0200 Subject: [PATCH 09/51] fix: boilerplate for virtual CT --- frappe/modules/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index c115ce5511..22994c9a39 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -308,7 +308,7 @@ def make_boilerplate( base_class = "NestedSet" base_class_import = "from frappe.utils.nestedset import NestedSet" - if doc.get("is_virtual"): + if doc.get("is_virtual") and not doc.get("istable"): controller_body = indent( dedent( """ From 76c04fef274f83dfd26cca659e32aeb6ca36b528 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 10 Sep 2025 08:07:51 +0200 Subject: [PATCH 10/51] refactor: move virtual child table check into get_table_fields --- frappe/model/document.py | 6 ++---- frappe/model/meta.py | 9 +++++++-- frappe/model/rename_doc.py | 4 +--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frappe/model/document.py b/frappe/model/document.py index 0ccc5742bb..36cd1f509f 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1983,10 +1983,8 @@ def get_lazy_controller(doctype): # Dynamically construct a class that subclasses LazyDocument and original controller. lazy_controller = type(f"Lazy{original_controller.__name__}", (LazyDocument, original_controller), {}) - for fieldname, child_doctype in meta._table_doctypes.items(): - if meta.get_field(fieldname).is_virtual: - continue - setattr(lazy_controller, fieldname, LazyChildTable(fieldname, child_doctype)) + for df in meta.get_table_fields(include_virtual=False): + setattr(lazy_controller, df.fieldname, LazyChildTable(df.fieldname, df.options)) lazy_controllers[doctype] = lazy_controller return lazy_controllers[doctype] diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 5d45b1ef2e..2be3bee9d3 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -221,8 +221,8 @@ class Meta(Document): return set_only_once_fields - def get_table_fields(self): - return self._table_fields + def get_table_fields(self, include_virtual=True): + return self._table_fields if include_virtual else self._non_virtual_table_fields def get_global_search_fields(self): """Return list of fields with `in_global_search` set and `name` if set.""" @@ -491,6 +491,11 @@ class Meta(Document): self._table_fields = DOCTYPE_TABLE_FIELDS else: self._table_fields = self.get("fields", {"fieldtype": ["in", table_fields]}) + self._non_virtual_table_fields = ( + [] + if self.is_virtual + else self.get("fields", {"fieldtype": ["in", table_fields], "is_virtual": 0}) + ) # table fieldname: doctype map self._table_doctypes = {field.fieldname: field.options for field in self._table_fields} diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 18bb3ecfb4..a75552d0a3 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -416,9 +416,7 @@ def rename_doctype(doctype: str, old: str, new: str) -> None: def update_child_docs(old: str, new: str, meta: "Meta") -> None: # update "parent" - for df in meta.get_table_fields(): - if df.is_virtual or is_virtual_doctype(df.options): - continue + for df in meta.get_table_fields(include_virtual=False): ( frappe.qb.update(df.options) .set("parent", new) From dddfe7a02f22078b02c2f9085b6f027563a525e5 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Wed, 10 Sep 2025 12:32:12 +0530 Subject: [PATCH 11/51] fix: more foolproof implementation for virtal ct --- frappe/model/base_document.py | 58 +++++++++++++++++++++++++++++------ frappe/model/document.py | 13 +------- frappe/model/meta.py | 2 +- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 3a5057595e..dbaf83a134 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -145,7 +145,44 @@ def import_controller(doctype): if not issubclass(class_, BaseDocument): raise ImportError(f"{doctype}: {classname} is not a subclass of BaseDocument") - return _get_extended_class(class_, doctype) + class_ = _get_extended_class(class_, doctype) + return _update_virtual_ct_props(class_, doctype) + + +def _update_virtual_ct_props(class_, doctype): + if doctype in DOCTYPES_FOR_DOCTYPE or getattr(class_, "_virtual_ct_props_updated", False): + return class_ + + meta = frappe.get_meta(doctype) + for df in meta.get_table_fields(): + if df.is_virtual: + _update_virtual_ct_prop(class_, df) + + class_._virtual_ct_props_updated = True + return class_ + + +def _update_virtual_ct_prop(class_, df): + fieldname = df.fieldname + original_prop = getattr(class_, fieldname, None) + + def virtual_ct_prop(self): + if original_prop and is_a_property(original_prop): + value = original_prop.__get__(self, type(self)) + + elif options := getattr(df, "options", None): + value = self._evaluate_virtual_field_options(options) + + else: + # no property or options found + # to compare, default value is None for non-child table virtual fields + value = [] + + # converting to document objects + caching + self.set(fieldname, value) + return self.__dict__[fieldname] + + setattr(class_, fieldname, property(virtual_ct_prop)) def _get_extended_class(base_class, doctype): @@ -462,6 +499,15 @@ class BaseDocument: return self.meta.get_table_fields() + def _evaluate_virtual_field_options(self, options): + from frappe.utils.safe_exec import get_safe_globals + + return frappe.safe_eval( + code=options, + eval_globals=get_safe_globals(), + eval_locals={"doc": self}, + ) + def get_valid_dict( self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False ) -> _dict: @@ -489,13 +535,7 @@ class BaseDocument: value = getattr(self, fieldname) elif options := getattr(df, "options", None): - from frappe.utils.safe_exec import get_safe_globals - - value = frappe.safe_eval( - code=options, - eval_globals=get_safe_globals(), - eval_locals={"doc": self}, - ) + value = self._evaluate_virtual_field_options(options) fieldtype = df.fieldtype if isinstance(value, list) and fieldtype not in table_fields: @@ -610,7 +650,7 @@ class BaseDocument: doc["doctype"] = self.doctype for fieldname in self._table_fieldnames: - children = self.get(fieldname) or [] + children = getattr(self, fieldname) or [] doc[fieldname] = [ d.as_dict( convert_dates_to_str=convert_dates_to_str, diff --git a/frappe/model/document.py b/frappe/model/document.py index 36cd1f509f..76c6d8d1a2 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -275,18 +275,7 @@ class Document(BaseDocument): for fieldname, child_doctype in self._table_fieldnames.items(): # Make sure not to query the DB for a child table, if it is a virtual one. - if ( - not is_doctype - and is_virtual_doctype(child_doctype) - and self.meta.get_field(fieldname).is_virtual - ): - # Users must specify non-data descriptor/cached_property for computed table - # Remove previous value if any, required for reload. - self.__dict__.pop(fieldname, None) - values = getattr(self, fieldname, []) - # Convert to documents - self.__dict__[fieldname] = [] - self.extend(fieldname, values) + if not is_doctype and is_virtual_doctype(child_doctype): continue if is_doctype: diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 2be3bee9d3..4c7a284ada 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -493,7 +493,7 @@ class Meta(Document): self._table_fields = self.get("fields", {"fieldtype": ["in", table_fields]}) self._non_virtual_table_fields = ( [] - if self.is_virtual + if self.get("is_virtual") else self.get("fields", {"fieldtype": ["in", table_fields], "is_virtual": 0}) ) From a9dbd2193a0601fbd01bb759746233dee3005dc0 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:58:30 +0200 Subject: [PATCH 12/51] fix: ignore virtual fields in filter UI --- frappe/public/js/frappe/ui/filters/field_select.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/filters/field_select.js b/frappe/public/js/frappe/ui/filters/field_select.js index 16af594493..5bcb8e9161 100644 --- a/frappe/public/js/frappe/ui/filters/field_select.js +++ b/frappe/public/js/frappe/ui/filters/field_select.js @@ -117,6 +117,10 @@ frappe.ui.FieldSelect = class FieldSelect { // main table var main_table_fields = std_filters.concat(frappe.meta.docfield_list[me.doctype]); $.each(frappe.utils.sort(main_table_fields, "label", "string"), function (i, df) { + if (df.is_virtual) { + return; + } + let doctype = frappe.get_meta(me.doctype).istable && me.parent_doctype ? me.parent_doctype @@ -128,7 +132,7 @@ frappe.ui.FieldSelect = class FieldSelect { // child tables $.each(me.table_fields, function (i, table_df) { - if (table_df.options) { + if (table_df.options && !table_df.is_virtual) { let child_table_fields = [].concat(frappe.meta.docfield_list[table_df.options]); if (table_df.fieldtype === "Table MultiSelect") { From 396114fe00f9bdd9fe9b6896c2f0a93454c22070 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:39:04 +0200 Subject: [PATCH 13/51] fix(User): hide Sign Out button for current session --- .../doctype/user_session_display/user_session_display.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/user_session_display/user_session_display.json b/frappe/core/doctype/user_session_display/user_session_display.json index 7e3d6dee73..9e155f5215 100644 --- a/frappe/core/doctype/user_session_display/user_session_display.json +++ b/frappe/core/doctype/user_session_display/user_session_display.json @@ -41,6 +41,7 @@ "label": "Last Updated" }, { + "depends_on": "eval:!doc.current", "fieldname": "sign_out", "fieldtype": "Button", "label": "Sign Out" @@ -72,7 +73,7 @@ "is_virtual": 1, "istable": 1, "links": [], - "modified": "2025-06-25 14:34:28.765780", + "modified": "2025-09-10 11:38:09.491850", "modified_by": "Administrator", "module": "Core", "name": "User Session Display", From dc4c8feff507843ac9bc06206b2d7b4b49458ad7 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:45:10 +0200 Subject: [PATCH 14/51] refactor(User): rename current to is_current --- frappe/core/doctype/user/user.py | 2 +- .../user_session_display.json | 20 +++++++++---------- .../user_session_display.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 4efd82aba8..63e0eb96e2 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -174,7 +174,7 @@ class User(Document): "modified_by": session.user, "ip_address": data.session_ip, "last_updated": data.last_updated, - "current": session.sid == frappe.session.sid, + "is_current": session.sid == frappe.session.sid, "session_created": data.creation, "user_agent": data.user_agent, } diff --git a/frappe/core/doctype/user_session_display/user_session_display.json b/frappe/core/doctype/user_session_display/user_session_display.json index 9e155f5215..e24f79a09a 100644 --- a/frappe/core/doctype/user_session_display/user_session_display.json +++ b/frappe/core/doctype/user_session_display/user_session_display.json @@ -6,7 +6,7 @@ "engine": "InnoDB", "field_order": [ "id", - "current", + "is_current", "ip_address", "user_agent", "column_break_cjgl", @@ -41,7 +41,7 @@ "label": "Last Updated" }, { - "depends_on": "eval:!doc.current", + "depends_on": "eval:!doc.is_current", "fieldname": "sign_out", "fieldtype": "Button", "label": "Sign Out" @@ -55,17 +55,17 @@ "fieldtype": "Section Break", "label": "Actions" }, - { - "default": "0", - "fieldname": "current", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Current?" - }, { "fieldname": "user_agent", "fieldtype": "Small Text", "label": "User Agent" + }, + { + "default": "0", + "fieldname": "is_current", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Current" } ], "grid_page_length": 50, @@ -73,7 +73,7 @@ "is_virtual": 1, "istable": 1, "links": [], - "modified": "2025-09-10 11:38:09.491850", + "modified": "2025-09-10 11:43:47.325045", "modified_by": "Administrator", "module": "Core", "name": "User Session Display", diff --git a/frappe/core/doctype/user_session_display/user_session_display.py b/frappe/core/doctype/user_session_display/user_session_display.py index e6d2733f1e..0ab49c90d4 100644 --- a/frappe/core/doctype/user_session_display/user_session_display.py +++ b/frappe/core/doctype/user_session_display/user_session_display.py @@ -14,9 +14,9 @@ class UserSessionDisplay(Document): if TYPE_CHECKING: from frappe.types import DF - current: DF.Check id: DF.Data | None ip_address: DF.Data | None + is_current: DF.Check last_updated: DF.Datetime | None parent: DF.Data parentfield: DF.Data From d93c177f45f87587d986ccae4125dce551407333 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:46:25 +0200 Subject: [PATCH 15/51] chore(User Session Display): remove old boilerplate --- .../user_session_display.py | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/frappe/core/doctype/user_session_display/user_session_display.py b/frappe/core/doctype/user_session_display/user_session_display.py index 0ab49c90d4..953a769f47 100644 --- a/frappe/core/doctype/user_session_display/user_session_display.py +++ b/frappe/core/doctype/user_session_display/user_session_display.py @@ -24,27 +24,3 @@ class UserSessionDisplay(Document): session_created: DF.Datetime | None user_agent: DF.SmallText | None # end: auto-generated types - - def db_insert(self, *args, **kwargs): - raise NotImplementedError - - def load_from_db(self): - raise NotImplementedError - - def db_update(self): - raise NotImplementedError - - def delete(self): - raise NotImplementedError - - @staticmethod - def get_list(filters=None, page_length=20, **kwargs): - pass - - @staticmethod - def get_count(filters=None, **kwargs): - pass - - @staticmethod - def get_stats(**kwargs): - pass From 9fdaf091c7ad0a8ba35488dd4d2ad2e9275ab2a4 Mon Sep 17 00:00:00 2001 From: UmakanthKaspa Date: Wed, 24 Sep 2025 12:11:05 +0000 Subject: [PATCH 16/51] feat(report-view): add Select All/Unselect All in Pick Columns dialog --- frappe/public/js/frappe/views/reports/report_view.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 9dfe87684f..cba1ab7d8a 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -1610,6 +1610,18 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { }, }); + const $bulk = $(` +
+ + +
+ `); + const toggleAll = (checked) => + d.$wrapper.find(":checkbox").prop("checked", checked).trigger("change"); + $bulk.on("click", "[data-action=select_all]", () => toggleAll(true)); + $bulk.on("click", "[data-action=unselect_all]", () => toggleAll(false)); + d.$body.prepend($bulk); + d.$body.prepend(`