feat: add activity log support (#37696)

This commit is contained in:
Clayton 2026-03-04 05:45:11 -06:00 committed by GitHub
parent 5e92776a4b
commit 852d264698
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 257 additions and 9 deletions

View file

@ -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"

View file

@ -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(
{

View file

@ -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(`<div class="text-muted text-center p-4">${__("Loading\u2026")}</div>`);
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(
`<div class="text-muted text-center p-4">${__(
"No activity recorded yet."
)}</div>`
);
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]
? `<span class="diff-add">${__(label)}</span>`
: `<span class="diff-remove">${__(label)}</span>`
);
}
});
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
? `<td>${frappe.utils.get_form_link(
"DocType",
log.for_document,
true
)}</td>`
: "";
return `<tr>
<td>${user_display}</td>
<td><span class="indicator-pill ${badge_color}">${__(log.status)}</span></td>
<td>${__(role)}</td>
${doctype_cell}
<td class="small">${changes_text}</td>
<td class="frappe-timestamp-cell">${ts}</td>
</tr>`;
})
.join("");
let header_doctype = show_doctype_column
? `<th style="min-width:120px">${__("DocType")}</th>`
: "";
$body.html(`
<div style="overflow-x: auto;">
<table class="table table-bordered table-sm" style="font-size:13px">
<thead style="background:var(--fg-color)">
<tr>
<th style="min-width:110px">${__("Modified By")}</th>
<th style="min-width:90px">${__("Action")}</th>
<th style="min-width:110px">${__("Role")}</th>
${header_doctype}
<th>${__("Changes")}</th>
<th style="min-width:100px">${__("Timestamp")}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="text-right mt-2">
<button class="btn btn-sm btn-default btn-view-full-log">
${frappe.utils.icon("external-link", "sm", "mr-1")}
${__("View full log")}
</button>
</div>
`);
$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();
}
};

View file

@ -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