feat: add activity log support (#37696)
This commit is contained in:
parent
5e92776a4b
commit
852d264698
4 changed files with 257 additions and 9 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue