diff options
| -rw-r--r-- | app/controllers/node_actions_controller.rb | 6 | ||||
| -rw-r--r-- | app/models/action_participant.rb | 4 | ||||
| -rw-r--r-- | app/models/node.rb | 23 | ||||
| -rw-r--r-- | app/models/node_action.rb | 19 | ||||
| -rw-r--r-- | db/migrate/20260723011350_create_action_participants.rb | 13 | ||||
| -rw-r--r-- | lib/tasks/node_actions.rake | 14 | ||||
| -rw-r--r-- | test/controllers/assets_controller_test.rb | 41 | ||||
| -rw-r--r-- | test/controllers/node_actions_controller_test.rb | 10 | ||||
| -rw-r--r-- | test/controllers/nodes_controller_test.rb | 29 | ||||
| -rw-r--r-- | test/models/node_action_test.rb | 20 | ||||
| -rw-r--r-- | test/models/node_test.rb | 10 |
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 @@ | |||
| 1 | class ActionParticipant < ApplicationRecord | ||
| 2 | belongs_to :node_action | ||
| 3 | belongs_to :subject, :polymorphic => true | ||
| 4 | end | ||
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 @@ | |||
| 1 | class 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 | ||
| 13 | end | ||
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 | ||
| 61 | end | 75 | end |
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 | ||
| 51 | end | 61 | end |
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 | ||
| 128 | end | 146 | end |
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") |
