summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/node_actions_controller.rb6
-rw-r--r--app/models/action_participant.rb4
-rw-r--r--app/models/node.rb23
-rw-r--r--app/models/node_action.rb19
-rw-r--r--db/migrate/20260723011350_create_action_participants.rb13
-rw-r--r--lib/tasks/node_actions.rake14
-rw-r--r--test/controllers/assets_controller_test.rb41
-rw-r--r--test/controllers/node_actions_controller_test.rb10
-rw-r--r--test/controllers/nodes_controller_test.rb29
-rw-r--r--test/models/node_action_test.rb20
-rw-r--r--test/models/node_test.rb10
11 files changed, 175 insertions, 14 deletions
diff --git a/app/controllers/node_actions_controller.rb b/app/controllers/node_actions_controller.rb
index 6e46719..9b97b45 100644
--- a/app/controllers/node_actions_controller.rb
+++ b/app/controllers/node_actions_controller.rb
@@ -6,7 +6,11 @@ class NodeActionsController < ApplicationController
6 6
7 def index 7 def index
8 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc) 8 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc)
9 @actions = @actions.where(:node_id => params[:node_id]) if params[:node_id].present? 9 if params[:node_id].present?
10 @actions = @actions.joins(:action_participants)
11 .where(:action_participants => { :subject_type => "Node",
12 :subject_id => params[:node_id] })
13 end
10 @actions = @actions.where(:user_id => params[:user_id]) if params[:user_id].present? 14 @actions = @actions.where(:user_id => params[:user_id]) if params[:user_id].present?
11 @actions = @actions.includes(:node, :user) 15 @actions = @actions.includes(:node, :user)
12 .paginate(:page => params[:page], :per_page => 50) 16 .paginate(:page => params[:page], :per_page => 50)
diff --git a/app/models/action_participant.rb b/app/models/action_participant.rb
new file mode 100644
index 0000000..f179a48
--- /dev/null
+++ b/app/models/action_participant.rb
@@ -0,0 +1,4 @@
1class ActionParticipant < ApplicationRecord
2 belongs_to :node_action
3 belongs_to :subject, :polymorphic => true
4end
diff --git a/app/models/node.rb b/app/models/node.rb
index a5a40d3..7a93e79 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -4,7 +4,14 @@ class Node < ApplicationRecord
4 4
5 # Associations 5 # Associations
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7
8 # Entries where this node is the primary subject (fast-path column).
9 # For a *complete* history -- including subtree trash/destroy entries
10 # recorded at an ancestor -- use participated_actions instead.
7 has_many :node_actions, :dependent => :nullify 11 has_many :node_actions, :dependent => :nullify
12 has_many :action_participations, :class_name => "ActionParticipant", :as => :subject
13 has_many :participated_actions, :through => :action_participations, :source => :node_action
14
8 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true 15 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
9 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true 16 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
10 # Autosave pages carry no node_id, so has_many :pages does not cover 17 # Autosave pages carry no node_id, so has_many :pages does not cover
@@ -312,14 +319,14 @@ class Node < ApplicationRecord
312 was_published = head_id.present? 319 was_published = head_id.present?
313 final_published_at = head&.published_at 320 final_published_at = head&.published_at
314 321
315 demoted = 0 322 subtree = [self] + descendants.to_a
316 ([self] + descendants.to_a).each do |node| 323 demoted_nodes = subtree.select do |node|
317 next unless node.head_id 324 next false unless node.head_id
318 former = node.head 325 former = node.head
319 node.head = nil 326 node.head = nil
320 node.draft_id = former.id if node.draft_id.nil? 327 node.draft_id = former.id if node.draft_id.nil?
321 node.save! 328 node.save!
322 demoted += 1 329 true
323 end 330 end
324 331
325 self.reload 332 self.reload
@@ -332,9 +339,10 @@ class Node < ApplicationRecord
332 metadata = { :path => { "from" => path_before, "to" => unique_name } } 339 metadata = { :path => { "from" => path_before, "to" => unique_name } }
333 metadata[:was_published] = true if was_published 340 metadata[:was_published] = true if was_published
334 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at 341 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at
335 metadata[:demoted_heads] = demoted if demoted > 0 342 metadata[:demoted_heads] = demoted_nodes.size if demoted_nodes.any?
336 343
337 NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata) 344 NodeAction.record!(:participants => subtree, :user => current_user,
345 :action => "trash", **metadata)
338 self 346 self
339 end 347 end
340 end 348 end
@@ -382,7 +390,8 @@ class Node < ApplicationRecord
382 metadata = { :path => unique_name } 390 metadata = { :path => unique_name }
383 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1 391 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1
384 392
385 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata) 393 NodeAction.record!(:participants => doomed, :user => current_user,
394 :action => "destroy", **metadata)
386 doomed.each(&:destroy!) 395 doomed.each(&:destroy!)
387 end 396 end
388 end 397 end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 63a99ae..8a3dd8b 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -3,6 +3,8 @@ class NodeAction < ApplicationRecord
3 belongs_to :page, optional: true 3 belongs_to :page, optional: true
4 belongs_to :user, optional: true 4 belongs_to :user, optional: true
5 5
6 has_many :action_participants, :dependent => :destroy
7
6 validates :action, presence: true 8 validates :action, presence: true
7 validates :occurred_at, presence: true 9 validates :occurred_at, presence: true
8 10
@@ -81,10 +83,15 @@ class NodeAction < ApplicationRecord
81 # This log records; it does not undo. No IP, session, or user 83 # This log records; it does not undo. No IP, session, or user
82 # agent, ever. Success only. 84 # agent, ever. Success only.
83 85
84 def self.record!(node:, action:, user: nil, page: nil, locale: nil, 86 def self.record!(node: nil, participants: [], action:, user: nil, page: nil,
85 occurred_at: nil, inferred_from: nil, **extra) 87 locale: nil, occurred_at: nil, inferred_from: nil, **extra)
88 participants = participants.presence || [node].compact
89 raise ArgumentError, "NodeAction.record! needs at least one participant" if participants.empty?
90
91 primary_node = node || (participants.first if participants.first.is_a?(Node))
92
86 create!( 93 create!(
87 :node => node, 94 :node => primary_node,
88 :page => page, 95 :page => page,
89 :user => user, 96 :user => user,
90 :action => action, 97 :action => action,
@@ -94,10 +101,12 @@ class NodeAction < ApplicationRecord
94 :metadata => { 101 :metadata => {
95 "username" => user&.login, 102 "username" => user&.login,
96 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { 103 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
97 node&.head&.title || node&.draft&.title 104 primary_node&.head&.title || primary_node&.draft&.title
98 }, 105 },
99 }.merge(extra.stringify_keys) 106 }.merge(extra.stringify_keys)
100 ) 107 ).tap do |na|
108 participants.each { |subject| na.action_participants.create!(:subject => subject) }
109 end
101 end 110 end
102 111
103 # Computes the publish-entry diff between an outgoing head and the 112 # Computes the publish-entry diff between an outgoing head and the
diff --git a/db/migrate/20260723011350_create_action_participants.rb b/db/migrate/20260723011350_create_action_participants.rb
new file mode 100644
index 0000000..493ac50
--- /dev/null
+++ b/db/migrate/20260723011350_create_action_participants.rb
@@ -0,0 +1,13 @@
1class CreateActionParticipants < ActiveRecord::Migration[8.1]
2 def change
3 create_table :action_participants do |t|
4 t.references :node_action, null: false, foreign_key: true, index: false
5 t.references :subject, polymorphic: true, null: false, index: false
6 end
7 add_index :action_participants, [:subject_type, :subject_id, :node_action_id],
8 :name => "index_action_participants_on_subject_and_action"
9 add_index :action_participants, [:node_action_id, :subject_type, :subject_id],
10 :unique => true,
11 :name => "index_action_participants_uniqueness"
12 end
13end
diff --git a/lib/tasks/node_actions.rake b/lib/tasks/node_actions.rake
index fdd286c..c378db2 100644
--- a/lib/tasks/node_actions.rake
+++ b/lib/tasks/node_actions.rake
@@ -58,4 +58,18 @@ namespace :node_actions do
58 58
59 puts "Created #{created} inferred entries" 59 puts "Created #{created} inferred entries"
60 end 60 end
61
62 desc "Backfill action_participants for existing single-subject entries. " \
63 "Idempotent: entries that already have a participant row are skipped. " \
64 "Multi-node trash/destroy entries predating this feature are NOT " \
65 "reconstructable (only a count was ever stored) and are left as-is."
66 task :backfill_participants => :environment do
67 scope = NodeAction.where.missing(:action_participants).where.not(:node_id => nil)
68 created = 0
69 scope.find_each do |action|
70 action.action_participants.create!(:subject => action.node)
71 created += 1
72 end
73 puts "Created #{created} participant rows"
74 end
61end 75end
diff --git a/test/controllers/assets_controller_test.rb b/test/controllers/assets_controller_test.rb
index f834541..05fc6de 100644
--- a/test/controllers/assets_controller_test.rb
+++ b/test/controllers/assets_controller_test.rb
@@ -92,6 +92,47 @@ class AssetsControllerTest < ActionController::TestCase
92 end 92 end
93 end 93 end
94 94
95 # --- create with attach ---
96
97 test "create with node_id attaches the asset to the node's draft" do
98 node = Node.root.children.create!(:slug => "asset_attach_target")
99
100 post :create, params: { asset: { name: 'Attach me' }, node_id: node.id }
101
102 assert_response :redirect
103 asset = Asset.last
104 assert_includes node.draft.assets.reload, asset
105 assert_match /attached/, flash[:notice]
106 end
107
108 test "create against a foreign-locked node keeps the asset but refuses the attach" do
109 node = Node.root.children.create!(:slug => "asset_attach_locked")
110 node.lock_for_editing!(users(:aaron))
111
112 assert_difference 'Asset.count', 1 do
113 post :create, params: { asset: { name: 'Orphaned for now' }, node_id: node.id }
114 end
115
116 assert_empty node.draft.assets.reload
117 assert_equal node_path(node), flash[:locked_node_path]
118 assert_equal "aaron", flash[:locked_by]
119 end
120
121 test "create with headline against a page that has one keeps the incumbent and warns" do
122 node = Node.root.children.create!(:slug => "asset_attach_headline")
123 incumbent = Asset.create!(:name => 'Incumbent', :upload_content_type => 'image/png')
124 node.draft.related_assets.create!(:asset => incumbent, :headline => true)
125
126 uploaded = Rack::Test::UploadedFile.new(
127 Rails.root.join('test', 'fixtures', 'files', 'test_image.png'), 'image/png')
128 post :create, params: { asset: { name: 'Challenger', upload: uploaded },
129 node_id: node.id, headline: "1" }
130
131 assert_includes node.draft.assets.reload, Asset.last
132 assert_equal incumbent, node.draft.reload.headline_asset
133 assert_equal node_path(node), flash[:headline_kept_path]
134 end
135
95 # --- edit --- 136 # --- edit ---
96 137
97 test "get edit" do 138 test "get edit" do
diff --git a/test/controllers/node_actions_controller_test.rb b/test/controllers/node_actions_controller_test.rb
index 8bc68ec..cfc5a31 100644
--- a/test/controllers/node_actions_controller_test.rb
+++ b/test/controllers/node_actions_controller_test.rb
@@ -48,4 +48,14 @@ class NodeActionsControllerTest < ActionController::TestCase
48 get :index 48 get :index
49 assert_response :redirect 49 assert_response :redirect
50 end 50 end
51
52 test "zooming on a node finds subtree entries recorded at its ancestor" do
53 parent = Node.root.children.create!(:slug => "zoom_participant_parent")
54 child = parent.children.create!(:slug => "zoom_participant_child")
55 parent.trash!(users(:quentin))
56
57 get :index, params: { :node_id => child.id }
58 assert_response :success
59 assert_includes assigns(:actions).map(&:action), "trash"
60 end
51end 61end
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index 55da524..d777108 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -79,6 +79,35 @@ class NodesControllerTest < ActionController::TestCase
79 end 79 end
80 end 80 end
81 81
82 test "create with asset_id attaches the asset to the new draft, as headline when asked" do
83 login_as :quentin
84 asset = Asset.create!(:name => 'Birth attachment', :upload_content_type => 'image/png')
85
86 post :create, params: { :kind => "generic", :parent_id => Node.root.id,
87 :title => "Born Attached",
88 :asset_id => asset.id, :asset_headline => "1" }
89
90 assert_response :redirect
91 node = Node.last
92 assert_includes node.draft.assets, asset
93 assert_equal asset, node.draft.headline_asset
94 assert_match /attached/, flash[:notice]
95 end
96
97 test "the attach notice survives the redirect into the editor" do
98 login_as :quentin
99 asset = Asset.create!(:name => 'Flash survivor', :upload_content_type => 'image/png')
100
101 post :create, params: { :kind => "generic", :parent_id => Node.root.id,
102 :title => "Notice Carrier", :asset_id => asset.id }
103 assert_redirected_to edit_node_path(Node.last)
104
105 get :edit, params: { :id => Node.last.id }
106 assert_response :success
107 assert_match /attached/, flash[:notice]
108 assert_no_match /ready to edit/, flash[:notice]
109 end
110
82 test "editing a node" do 111 test "editing a node" do
83 login_as :quentin 112 login_as :quentin
84 113
diff --git a/test/models/node_action_test.rb b/test/models/node_action_test.rb
index b177cca..849b36f 100644
--- a/test/models/node_action_test.rb
+++ b/test/models/node_action_test.rb
@@ -118,11 +118,29 @@ class NodeActionTest < ActiveSupport::TestCase
118 118
119 test "record! passes provenance and historical timestamps through" do 119 test "record! passes provenance and historical timestamps through" do
120 long_ago = 2.years.ago 120 long_ago = 2.years.ago
121 action = NodeAction.record!(:node => nil, :action => "publish", 121 node = Node.root.children.create!(:slug => "provenance_backfill_test")
122 action = NodeAction.record!(:node => node, :action => "publish",
122 :occurred_at => long_ago, 123 :occurred_at => long_ago,
123 :inferred_from => "from_page_revision") 124 :inferred_from => "from_page_revision")
124 125
125 assert_equal "from_page_revision", action.inferred_from 126 assert_equal "from_page_revision", action.inferred_from
126 assert_in_delta long_ago.to_f, action.occurred_at.to_f, 1.0 127 assert_in_delta long_ago.to_f, action.occurred_at.to_f, 1.0
127 end 128 end
129
130
131 test "record! with participants: writes one action_participant per subject" do
132 n1, n2 = Node.root.children.create!(:slug => "participant_a"), Node.root.children.create!(:slug => "participant_b")
133 action = NodeAction.record!(:participants => [n1, n2], :action => "trash", :user => users(:quentin))
134 assert_equal [n1, n2], action.action_participants.map(&:subject)
135 end
136
137 test "record! with node: alone still writes exactly one participant" do
138 node = Node.root.children.create!(:slug => "participant_sugar")
139 action = NodeAction.record!(:node => node, :action => "create", :user => users(:quentin))
140 assert_equal [node], action.action_participants.map(&:subject)
141 end
142
143 test "record! refuses an empty participant set" do
144 assert_raises(ArgumentError) { NodeAction.record!(:action => "create", :user => users(:quentin)) }
145 end
128end 146end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index ba38340..0083b08 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -756,6 +756,16 @@ class NodeTest < ActiveSupport::TestCase
756 assert node.valid? 756 assert node.valid?
757 end 757 end
758 758
759 test "trash! records every subtree node as a participant, not just the root" do
760 parent = Node.root.children.create!(:slug => "trash_participants_parent")
761 child = parent.children.create!(:slug => "trash_participants_child")
762
763 parent.trash!(users(:quentin))
764
765 action = parent.node_actions.where(:action => "trash").last
766 assert_includes action.action_participants.map(&:subject), child
767 end
768
759 test "destroying a node with children is refused" do 769 test "destroying a node with children is refused" do
760 parent = Node.root.children.create!(:slug => "destroy_guard_parent") 770 parent = Node.root.children.create!(:slug => "destroy_guard_parent")
761 parent.children.create!(:slug => "destroy_guard_child") 771 parent.children.create!(:slug => "destroy_guard_child")