diff --git a/frappe/core/doctype/permission_log/permission_log.json b/frappe/core/doctype/permission_log/permission_log.json index 61c2a8b1b5..a46f15feff 100644 --- a/frappe/core/doctype/permission_log/permission_log.json +++ b/frappe/core/doctype/permission_log/permission_log.json @@ -118,7 +118,7 @@ "fieldtype": "Select", "hidden": 1, "label": "Status", - "options": "Updated\nRemoved\nAdded" + "options": "Updated\nRemoved\nAdded\nReset" } ], "index_web_pages_for_search": 1, @@ -150,6 +150,10 @@ { "color": "Green", "title": "Added" + }, + { + "color": "Blue", + "title": "Reset" } ], "title_field": "changed_by" diff --git a/frappe/core/doctype/permission_log/permission_log.py b/frappe/core/doctype/permission_log/permission_log.py index 5f8d5a4e75..3eb4956297 100644 --- a/frappe/core/doctype/permission_log/permission_log.py +++ b/frappe/core/doctype/permission_log/permission_log.py @@ -23,7 +23,7 @@ class PermissionLog(Document): for_document: DF.DynamicLink reference: DF.DynamicLink | None reference_type: DF.Link | None - status: DF.Literal["Updated", "Removed", "Added"] + status: DF.Literal["Updated", "Removed", "Added", "Reset"] # end: auto-generated types @property @@ -34,6 +34,13 @@ class PermissionLog(Document): def make_perm_log(doc, method=None): if not hasattr(doc, "get_permission_log_options"): return + # During reset we insert a single "Reset" log; skip per-Custom-DocPerm "Removed" logs + if ( + method == "after_delete" + and doc.doctype == "Custom DocPerm" + and getattr(frappe.flags, "skip_perm_log_for_doctype", None) == doc.parent + ): + return params = doc.get_permission_log_options(method) or {} if not getattr(doc, "_no_perm_log", False): @@ -46,16 +53,34 @@ def insert_perm_log( for_doctype: str | None = None, for_document: str | None = None, fields: list | tuple | None = None, + custom_changes: dict | None = None, ): + """Log a permission change. When custom_changes is provided (e.g. for reset-to-standard), + it must be {"from": {...}, "to": {...}} and optionally "status"; doc is used for + reference/owner only.""" if frappe.flags.in_install or frappe.flags.in_migrate: # no need to log changes when migrating or installing app/site return - current, previous = get_changes(doc, doc_before_save, fields) - if not previous and not current: - return - - status = "Updated" if doc_before_save else ("Added" if doc.flags.in_insert else "Removed") + if custom_changes is not None: + previous = custom_changes.get("from", {}) + current = custom_changes.get("to", {}) + status = custom_changes.get("status", "Updated") + else: + current, previous = get_changes(doc, doc_before_save, fields) + if not previous and not current: + return + status = "Updated" if doc_before_save else ("Added" if doc.flags.in_insert else "Removed") + # Ensure role (and parent) are always in changes for Custom DocPerm so the UI can show them + if doc.doctype == "Custom DocPerm": + previous["role"] = previous.get("role") or ( + doc_before_save and getattr(doc_before_save, "role", None) + ) + current["role"] = current.get("role") or getattr(doc, "role", None) + previous["parent"] = previous.get("parent") or ( + doc_before_save and getattr(doc_before_save, "parent", None) + ) + current["parent"] = current.get("parent") or getattr(doc, "parent", None) frappe.get_doc( { diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js index 7136de5dec..79a2d11cb0 100644 --- a/frappe/core/page/permission_manager/permission_manager.js +++ b/frappe/core/page/permission_manager/permission_manager.js @@ -72,6 +72,11 @@ frappe.PermissionEngine = class PermissionEngine { this.page.add_inner_button(__("Set User Permissions"), () => { return frappe.set_route("List", "User Permission"); }); + + this.page.add_inner_button(__("View Activity Log"), () => { + this.show_activity_log(); + }); + this.set_from_route(); } @@ -577,4 +582,159 @@ frappe.PermissionEngine = class PermissionEngine { options: ["not in", ["User", "[Select]"]], }); } + + show_activity_log() { + const PERM_FIELDS = [ + "select", + "read", + "write", + "create", + "delete", + "submit", + "cancel", + "amend", + "print", + "email", + "report", + "import", + "export", + "share", + "mask", + ]; + const STATUS_COLOR = { Added: "green", Removed: "red", Updated: "orange", Reset: "blue" }; + + let doctype = this.get_doctype(); + let show_doctype_column = !doctype; + + let title = doctype + ? __("Activity Log for {0}", [__(doctype)]) + : __("Role Permissions Activity Log"); + + let d = new frappe.ui.Dialog({ title, size: "large" }); + let $body = $(d.body); + $body.html(`
${__("Loading\u2026")}
`); + + frappe + .call({ + module: "frappe.core", + page: "permission_manager", + method: "get_permission_logs", + args: { doctype: doctype || null, limit: 50 }, + }) + .then((r) => { + let logs = r.message || []; + $body.empty(); + + if (!logs.length) { + $body.html( + `
${__( + "No activity recorded yet." + )}
` + ); + return; + } + + let rows = logs + .map((log) => { + let ch = log.changes || {}; + let from = ch.from || {}; + let to = ch.to || {}; + + // Role: prefer the side that has data + let role = + (log.status === "Removed" ? from.role : to.role) || from.role || "—"; + + // Active permissions: for Added/Removed show the full set; + // for Updated show only what flipped; for Reset show summary + let changes_text = ""; + if (log.status === "Reset") { + changes_text = __("Restored to standard permissions"); + } else if (log.status === "Updated") { + let parts = []; + PERM_FIELDS.forEach((f) => { + if (f in to && to[f] !== from[f]) { + let label = toTitle(frappe.unscrub(f)); + parts.push( + to[f] + ? `${__(label)}` + : `${__(label)}` + ); + } + }); + changes_text = parts.join(", ") || "—"; + } else { + // Added or Removed — list the active permission types + let source = log.status === "Removed" ? from : to; + let active = PERM_FIELDS.filter( + (f) => source[f] == 1 || source[f] === true + ); + changes_text = + active.map((f) => __(toTitle(frappe.unscrub(f)))).join(", ") || + "—"; + } + + let badge_color = STATUS_COLOR[log.status] || "grey"; + let ts = frappe.datetime.comment_when(log.changed_at); + let user_display = log.changed_by || "—"; + + let doctype_cell = + show_doctype_column && log.for_document + ? `${frappe.utils.get_form_link( + "DocType", + log.for_document, + true + )}` + : ""; + + return ` + ${user_display} + ${__(log.status)} + ${__(role)} + ${doctype_cell} + ${changes_text} + ${ts} + `; + }) + .join(""); + + let header_doctype = show_doctype_column + ? `${__("DocType")}` + : ""; + + $body.html(` +
+ + + + + + + ${header_doctype} + + + + + ${rows} +
${__("Modified By")}${__("Action")}${__("Role")}${__("Changes")}${__("Timestamp")}
+
+
+ +
+ `); + + $body.find(".btn-view-full-log").on("click", () => { + d.hide(); + frappe.route_options = { for_doctype: "DocType" }; + if (doctype) { + frappe.route_options.for_document = doctype; + } + frappe.set_route("List", "Permission Log"); + }); + }); + + d.show(); + } }; diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 0e5f2c828c..ee86035bf3 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -178,8 +178,35 @@ def remove(doctype: str, role: str, permlevel: int, if_owner: str | int = 0): @frappe.whitelist() def reset(doctype: str): frappe.only_for("System Manager") - reset_perms(doctype) - clear_permissions_cache(doctype) + + from frappe.core.doctype.permission_log.permission_log import insert_perm_log + + frappe.flags.skip_perm_log_for_doctype = doctype + try: + reset_perms(doctype) + clear_permissions_cache(doctype) + + doc = frappe.new_doc("DocType") + doc.name = doctype + standard_perms = frappe.get_all("DocPerm", filters={"parent": doctype}, fields="*") + insert_perm_log( + doc, + for_doctype="DocType", + for_document=doctype, + custom_changes={ + "from": {"permissions": "custom"}, + "to": { + "permissions": "standard", + "standard_rules": [ + {"role": p.role, "permlevel": p.permlevel, "read": p.read, "write": p.write} + for p in standard_perms + ], + }, + "status": "Reset", + }, + ) + finally: + frappe.flags.pop("skip_perm_log_for_doctype", None) @frappe.whitelist() @@ -199,3 +226,35 @@ def get_standard_permissions(doctype: str): # also used to setup permissions via patch path = get_file_path(meta.module, "DocType", doctype) return read_doc_from_file(path).get("permissions") + + +@frappe.whitelist() +def get_permission_logs(doctype: str | None = None, limit: int = 20) -> list: + """Return recent Permission Log entries for the given DocType (or all if not specified). + + Args: + doctype: Filter logs to a specific DocType. If omitted, returns logs for all DocTypes. + limit: Maximum number of log entries to return (default 20). + """ + frappe.only_for("System Manager") + + filters = {"for_doctype": "DocType"} + if doctype: + filters["for_document"] = doctype + + logs = frappe.get_all( + "Permission Log", + filters=filters, + fields=["name", "changed_by", "creation", "status", "for_document", "changes"], + order_by="creation desc", + limit=limit, + ) + + for log in logs: + log["changed_at"] = log.pop("creation") + try: + log["changes"] = frappe.parse_json(log["changes"]) + except Exception: + pass + + return logs