summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-16 03:30:01 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-16 03:30:01 +0200
commit190daaa8a0bb79e750894b83e31b9d99f7d900a4 (patch)
tree308a7bdeba7bd22733729408883e548f851a8812
parentc0c9250141c2fa64fed967b8b9ad9bada3694c3d (diff)
Add a reader for the action log at admin/log
NodeActionsController#index lists entries newest-first, filterable by node_id or user_id -- the two zoom shapes the log was designed around. Rendering goes through NodeActionsHelper.action_summary, which builds one sentence per entry from metadata alone, so entries referencing deleted users or nodes render from their snapshots; live associations only upgrade names to links. Unknown verbs degrade to a generic sentence rather than an error, since the log outlives its vocabulary. The helper is the escaping boundary: every metadata value passes through h() before assembly. Actor names link to the log's own user zoom rather than the unused users page -- inspecting a suspicious user's other actions is the intended workflow. Publish entries with a translation_diff expose a collapsed per-locale change table linking out to the revision itself. Sentences live in en.yml/de.yml following the existing widget-string convention. nodes#show links to its node's zoomed log.
-rw-r--r--app/controllers/node_actions_controller.rb14
-rw-r--r--app/helpers/node_actions_helper.rb68
-rw-r--r--app/views/node_actions/_change_details.html.erb27
-rw-r--r--app/views/node_actions/index.html.erb22
-rw-r--r--app/views/nodes/show.html.erb1
-rw-r--r--config/locales/de.yml20
-rw-r--r--config/locales/en.yml20
-rw-r--r--config/routes.rb1
-rw-r--r--test/controllers/node_actions_controller_test.rb51
-rw-r--r--test/models/helpers/node_actions_helper_test.rb85
10 files changed, 309 insertions, 0 deletions
diff --git a/app/controllers/node_actions_controller.rb b/app/controllers/node_actions_controller.rb
new file mode 100644
index 0000000..6e46719
--- /dev/null
+++ b/app/controllers/node_actions_controller.rb
@@ -0,0 +1,14 @@
1class NodeActionsController < ApplicationController
2
3 before_action :login_required
4
5 layout 'admin'
6
7 def index
8 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc)
9 @actions = @actions.where(:node_id => params[:node_id]) if params[:node_id].present?
10 @actions = @actions.where(:user_id => params[:user_id]) if params[:user_id].present?
11 @actions = @actions.includes(:node, :user)
12 .paginate(:page => params[:page], :per_page => 50)
13 end
14end
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
new file mode 100644
index 0000000..ef42b75
--- /dev/null
+++ b/app/helpers/node_actions_helper.rb
@@ -0,0 +1,68 @@
1module NodeActionsHelper
2 include ERB::Util
3
4 # One sentence per entry, rendered from metadata alone, so entries
5 # stay renderable after the rows they reference are gone. Live
6 # associations only upgrade plain names to links. Every metadata
7 # value passes through h() here -- this helper is the escaping
8 # boundary; the locale sentences are trusted, the data never is.
9 def action_summary action
10 renderer = "summarize_#{action.action}"
11 return send(renderer, action) if respond_to?(renderer, true)
12
13 # Unknown-verb fallback: the log outlives the vocabulary. A renamed
14 # or future verb degrades to an ugly sentence, never to a 500.
15 t("node_actions.unknown", :actor => actor_ref(action),
16 :action => h(action.action), :subject => subject_ref(action)).html_safe
17 end
18
19 private
20
21 def actor_ref action
22 if action.user && respond_to?(:current_user) && action.user == current_user
23 return t("node_actions.actor_self")
24 end
25 action.user ? link_to(h(action.actor_name), admin_log_path(:user_id => action.user_id))
26 : h(action.actor_name)
27 end
28
29 def subject_ref action
30 action.node ? link_to(h(action.subject_name), node_path(action.node))
31 : h(action.subject_name)
32 end
33
34 def summarize_publish action
35 key = if action.metadata["via"] == "revision"
36 "node_actions.publish_rollback"
37 elsif action.metadata.dig("title", "from").nil?
38 "node_actions.publish_first"
39 else
40 "node_actions.publish"
41 end
42
43 t(key, :actor => actor_ref(action), :subject => subject_ref(action),
44 :from => h(action.metadata.dig("title", "from")),
45 :to => h(action.metadata.dig("title", "to"))).html_safe
46 end
47
48 def summarize_move action
49 t("node_actions.move", :actor => actor_ref(action), :subject => subject_ref(action),
50 :from => h(action.metadata.dig("path", "from")),
51 :to => h(action.metadata.dig("path", "to"))).html_safe
52 end
53
54 def summarize_create action
55 t("node_actions.create", :actor => actor_ref(action), :subject => subject_ref(action),
56 :path => h(action.metadata["path"])).html_safe
57 end
58
59 def summarize_discard_autosave action
60 t("node_actions.discard_autosave",
61 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
62 end
63
64 def summarize_destroy_draft action
65 t("node_actions.destroy_draft",
66 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
67 end
68end
diff --git a/app/views/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
new file mode 100644
index 0000000..383f53a
--- /dev/null
+++ b/app/views/node_actions/_change_details.html.erb
@@ -0,0 +1,27 @@
1<details class="node_action_details">
2 <summary><%= t("node_actions.show_changes") %></summary>
3 <table>
4 <% action_entry.metadata["translation_diff"].each do |locale, diff| %>
5 <tr>
6 <th><%= locale.upcase %></th>
7 <td>
8 <% case diff["status"] %>
9 <% when "added" %>
10 <%= t("node_actions.locale_added", :title => diff.dig("title", "to")) %>
11 <% when "removed" %>
12 <%= t("node_actions.locale_removed", :title => diff.dig("title", "from")) %>
13 <% else %>
14 <% if diff["title"] %>
15 <%= diff.dig("title", "from") %> → <%= diff.dig("title", "to") %>
16 <% end %>
17 <%= t("node_actions.abstract_changed") if diff["abstract_changed"] %>
18 <%= t("node_actions.body_changed") if diff["body_changed"] %>
19 <% end %>
20 </td>
21 </tr>
22 <% end %>
23 </table>
24 <% if action_entry.page && action_entry.node %>
25 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %>
26 <% end %>
27</details>
diff --git a/app/views/node_actions/index.html.erb b/app/views/node_actions/index.html.erb
new file mode 100644
index 0000000..92740cd
--- /dev/null
+++ b/app/views/node_actions/index.html.erb
@@ -0,0 +1,22 @@
1<h1><%= t("node_actions.heading") %></h1>
2
3<% if params[:node_id].present? || params[:user_id].present? %>
4 <p><%= link_to t("node_actions.show_all"), admin_log_path %></p>
5<% end %>
6
7<%= will_paginate @actions %>
8
9<ul id="node_action_list">
10 <% @actions.each do |action| %>
11 <li class="node_action node_action--<%= action.action %>">
12 <span class="node_action_time"><%= l(action.occurred_at, :format => :ccc) %></span>
13 <% if action.inferred_from %>
14 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
15 <% end %>
16 <span class="node_action_summary"><%= action_summary(action) %></span>
17 <%= render "change_details", :action_entry => action if action.metadata["translation_diff"].present? %>
18 </li>
19 <% end %>
20</ul>
21
22<%= will_paginate @actions %>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index 66f04f9..d0eff60 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -183,6 +183,7 @@
183 </ul> 183 </ul>
184 </details> 184 </details>
185 <p class="revisions_full_history_link"><%= link_to 'Full history (diff / restore)', node_revisions_path(@node) %></p> 185 <p class="revisions_full_history_link"><%= link_to 'Full history (diff / restore)', node_revisions_path(@node) %></p>
186 <p class)"revisions_full_history_link"><%= link_to t("node_actions.heading"), admin_log_path(:node_id => @node.id) %></p>
186 </div> 187 </div>
187 188
188 189
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 74cd183..4e7b11d 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -82,3 +82,23 @@ de:
82 page_gap: "&hellip;" 82 page_gap: "&hellip;"
83 container_aria_label: "Seitennavigation" 83 container_aria_label: "Seitennavigation"
84 page_aria_label: "Seite %{page}" 84 page_aria_label: "Seite %{page}"
85
86 node_actions:
87 heading: "Letzte Änderungen"
88 show_all: "Alle Einträge zeigen"
89 backfilled: "rekonstruiert"
90 show_changes: "Änderungen an Übersetzungen"
91 view_revision: "Diese Revision ansehen"
92 actor_self: "du"
93 unknown: "%{actor} hat %{action} auf %{subject} aus"
94 publish: "%{actor} hat %{subject} veröffentlicht, (vorher: %{from})"
95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht"
96 publish_rollback: "%{actor} hat %{subject} auf eine frühere Revision zurückgesetzt (vorher: %{from})"
97 create: "%{actor} hat %{subject} unter %{path} angelegt"
98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verschoben"
100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen"
101 locale_added: "Übersetzung angelegt, Titel \"%{title}\""
102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\""
103 abstract_changed: "Abstract geändert"
104 body_changed: "Text geändert"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c52280d..8470778 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -34,3 +34,23 @@ en:
34 page_gap: "&hellip;" 34 page_gap: "&hellip;"
35 container_aria_label: "Pagination" 35 container_aria_label: "Pagination"
36 page_aria_label: "Page %{page}" 36 page_aria_label: "Page %{page}"
37
38 node_actions:
39 heading: "Action log"
40 show_all: "Show all entries"
41 backfilled: "backfilled"
42 show_changes: "translation changes"
43 view_revision: "view this revision"
44 actor_self: "you"
45 unknown: "%{actor} did %{action} on %{subject}"
46 publish: "%{actor} published %{subject} (was: %{from})"
47 publish_first: "%{actor} published %{subject} for the first time"
48 publish_rollback: "%{actor} rolled %{subject} back to an earlier revision (was: %{from})"
49 create: "%{actor} created %{subject} at %{path}"
50 move: "%{actor} moved %{subject} from %{from} to %{to}"
51 discard_autosave: "%{actor} discarded unsaved changes on %{subject}"
52 destroy_draft: "%{actor} discarded the draft of %{subject}"
53 locale_added: "translation added, titled \"%{title}\""
54 locale_removed: "translation removed, last titled \"%{title}\""
55 abstract_changed: "abstract changed"
56 body_changed: "body changed"
diff --git a/config/routes.rb b/config/routes.rb
index 8e5ff23..c2b9590 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -86,6 +86,7 @@ Cccms::Application.routes.draw do
86 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get 86 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get
87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get 87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get
88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get 88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get
89 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get
89 end 90 end
90 91
91 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete 92 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete
diff --git a/test/controllers/node_actions_controller_test.rb b/test/controllers/node_actions_controller_test.rb
new file mode 100644
index 0000000..8bc68ec
--- /dev/null
+++ b/test/controllers/node_actions_controller_test.rb
@@ -0,0 +1,51 @@
1require 'test_helper'
2
3class NodeActionsControllerTest < ActionController::TestCase
4
5 def setup
6 login_as :quentin
7 end
8
9 def logged_node slug, title
10 node = Node.root.children.create!(:slug => slug)
11 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => title) }
12 node
13 end
14
15 test "index renders" do
16 get :index
17 assert_response :success
18 end
19
20 test "index filters by node" do
21 node_a = logged_node("log_filter_a", "Alpha Subject")
22 node_b = logged_node("log_filter_b", "Beta Subject")
23 NodeAction.record!(:node => node_a, :action => "create", :user => users(:quentin))
24 NodeAction.record!(:node => node_b, :action => "create", :user => users(:quentin))
25
26 get :index, params: { :node_id => node_a.id }
27
28 assert_response :success
29 assert_includes @response.body, "Alpha Subject"
30 assert_not_includes @response.body, "Beta Subject"
31 end
32
33 test "index filters by user" do
34 node = logged_node("log_filter_user", "Gamma Subject")
35 NodeAction.record!(:node => node, :action => "create", :user => users(:quentin))
36
37 get :index, params: { :user_id => users(:quentin).id }
38 assert_includes @response.body, "Gamma Subject"
39
40 other = User.where.not(:id => users(:quentin).id).first
41 get :index, params: { :user_id => other.id }
42 assert_not_includes @response.body, "Gamma Subject"
43 end
44
45 test "index requires login" do
46 session[:user_id] = nil
47 @controller.instance_variable_set(:@current_user, nil)
48 get :index
49 assert_response :redirect
50 end
51end
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
new file mode 100644
index 0000000..93bbdf6
--- /dev/null
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -0,0 +1,85 @@
1require 'test_helper'
2
3class NodeActionsHelperTest < ActionView::TestCase
4 def setup
5 @original_locale = I18n.locale
6 I18n.locale = :en
7 end
8
9 def default_url_options
10 { :locale => nil }
11 end
12
13 def teardown
14 I18n.locale = @original_locale
15 end
16
17 def entry action, metadata = {}, node: nil, user: nil, page: nil
18 NodeAction.create!(:node => node, :user => user, :page => page,
19 :action => action, :occurred_at => Time.now,
20 :metadata => { "username" => "quentin",
21 "human_readable_node_name" => "Subject" }.merge(metadata))
22 end
23
24 test "publish renders the title pair" do
25 out = action_summary(entry("publish",
26 { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } }))
27
28 assert_includes out, "quentin"
29 assert_includes out, "Old"
30 end
31
32 test "first publish uses its own sentence" do
33 out = action_summary(entry("publish",
34 { "via" => "draft", "title" => { "from" => nil, "to" => "New" } }))
35
36 assert_includes out, "for the first time"
37 end
38
39 test "rollback publishes get the rollback sentence" do
40 out = action_summary(entry("publish",
41 { "via" => "revision", "title" => { "from" => "Now", "to" => "Then" } }))
42
43 assert_includes out, "rolled"
44 end
45
46 test "move renders the path pair" do
47 out = action_summary(entry("move",
48 { "path" => { "from" => "a/b", "to" => "a/c" } }))
49
50 assert_includes out, "a/b"
51 assert_includes out, "a/c"
52 end
53
54 test "unknown verbs degrade to a generic sentence, never an error" do
55 out = action_summary(entry("frobnicate"))
56
57 assert_includes out, "frobnicate"
58 assert_includes out, "quentin"
59 end
60
61 test "dead references render as plain names from metadata, no links" do
62 out = action_summary(entry("publish",
63 { "title" => { "from" => "Old", "to" => "New" } }))
64
65 assert_not_includes out, "<a "
66 assert_includes out, "Subject"
67 end
68
69 test "metadata values are escaped" do
70 out = action_summary(entry("publish",
71 { "human_readable_node_name" => "<b>bold</b>",
72 "title" => { "from" => "<script>alert(1)</script>", "to" => "x" } }))
73
74 assert_not_includes out, "<script>"
75 assert_not_includes out, "<b>"
76 end
77
78 test "live associations upgrade names to links" do
79 out = action_summary(entry("publish",
80 { "title" => { "from" => "Old", "to" => "New" } },
81 :user => users(:quentin)))
82
83 assert_includes out, "<a "
84 end
85end