From 388d8b2bc737a7db34e5be8b314c3bb2d8c27672 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jun 2025 12:17:40 +0530 Subject: [PATCH] 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: