diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-23 12:24:52 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-23 12:24:52 +0200 |
| commit | 932d4a4be40587fa6d489eca16a1ef6d6f2936d1 (patch) | |
| tree | a7512889db54347845ce8f72e5a2155d917b4500 | |
| parent | 6ef98ad444631b1d5ba67bb16aaaa2afdfa53ae0 (diff) | |
Witness asset uploads and out-of-band attaches in the action log
| -rw-r--r-- | app/controllers/assets_controller.rb | 5 | ||||
| -rw-r--r-- | app/helpers/node_actions_helper.rb | 15 | ||||
| -rw-r--r-- | app/models/node.rb | 8 | ||||
| -rw-r--r-- | app/models/node_action.rb | 11 | ||||
| -rw-r--r-- | config/locales/de.yml | 3 | ||||
| -rw-r--r-- | config/locales/en.yml | 3 | ||||
| -rw-r--r-- | test/controllers/assets_controller_test.rb | 10 | ||||
| -rw-r--r-- | test/models/node_attach_asset_test.rb | 19 |
8 files changed, 74 insertions, 0 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb index 8df4c94d..7edd9c05 100644 --- a/app/controllers/assets_controller.rb +++ b/app/controllers/assets_controller.rb | |||
| @@ -50,6 +50,11 @@ class AssetsController < ApplicationController | |||
| 50 | respond_to do |format| | 50 | respond_to do |format| |
| 51 | if @asset.save | 51 | if @asset.save |
| 52 | flash[:notice] = 'Asset was successfully created.' | 52 | flash[:notice] = 'Asset was successfully created.' |
| 53 | NodeAction.record!(:participants => [@asset], :user => current_user, | ||
| 54 | :action => "asset_create", | ||
| 55 | :asset_name => @asset.name, | ||
| 56 | :content_type => @asset.upload_content_type, | ||
| 57 | :path => @asset.upload.url.sub(/\?\d+$/, "")) | ||
| 53 | attach_to(attach_node) if attach_node | 58 | attach_to(attach_node) if attach_node |
| 54 | format.html { redirect_to(@asset) } | 59 | format.html { redirect_to(@asset) } |
| 55 | format.xml { render :xml => @asset, :status => :created, :location => @asset } | 60 | format.xml { render :xml => @asset, :status => :created, :location => @asset } |
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb index 02d1ba83..ed8d0407 100644 --- a/app/helpers/node_actions_helper.rb +++ b/app/helpers/node_actions_helper.rb | |||
| @@ -13,6 +13,9 @@ module NodeActionsHelper | |||
| 13 | "destroy" => "trash-x", | 13 | "destroy" => "trash-x", |
| 14 | "discard_autosave" => "eraser", | 14 | "discard_autosave" => "eraser", |
| 15 | "destroy_draft" => "eraser", | 15 | "destroy_draft" => "eraser", |
| 16 | "asset_create" => "upload", | ||
| 17 | "asset_attach" => "paperclip", | ||
| 18 | "asset_destroy" => "file-x" | ||
| 16 | }.freeze | 19 | }.freeze |
| 17 | 20 | ||
| 18 | def verb_icon action | 21 | def verb_icon action |
| @@ -181,6 +184,18 @@ module NodeActionsHelper | |||
| 181 | :path => h(action.metadata["path"])).html_safe | 184 | :path => h(action.metadata["path"])).html_safe |
| 182 | end | 185 | end |
| 183 | 186 | ||
| 187 | def summarize_asset_create action | ||
| 188 | t("node_actions.asset_create", :actor => actor_ref(action), | ||
| 189 | :asset => h(action.metadata["asset_name"].presence || action.metadata["path"])).html_safe | ||
| 190 | end | ||
| 191 | |||
| 192 | def summarize_asset_attach action | ||
| 193 | m = action.metadata | ||
| 194 | key = m["headline"] ? "node_actions.asset_attach_headline" : "node_actions.asset_attach" | ||
| 195 | t(key, :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 196 | :asset => h(m["asset_name"].presence || m["path"])).html_safe | ||
| 197 | end | ||
| 198 | |||
| 184 | def summarize_asset_destroy action | 199 | def summarize_asset_destroy action |
| 185 | m = action.metadata | 200 | m = action.metadata |
| 186 | parts = [t("node_actions.asset_destroy", :actor => actor_ref(action), | 201 | parts = [t("node_actions.asset_destroy", :actor => actor_ref(action), |
diff --git a/app/models/node.rb b/app/models/node.rb index 7a93e799..0a9cd2d1 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -501,6 +501,14 @@ class Node < ApplicationRecord | |||
| 501 | to_attach.each do |row| | 501 | to_attach.each do |row| |
| 502 | row.related_assets.create!(:asset => asset, :headline => headline_state == :set) | 502 | row.related_assets.create!(:asset => asset, :headline => headline_state == :set) |
| 503 | end | 503 | end |
| 504 | |||
| 505 | if to_attach.any? | ||
| 506 | metadata = { :asset_name => asset.name, | ||
| 507 | :path => asset.upload.url.sub(/\?\d+$/, "") } | ||
| 508 | metadata[:headline] = true if headline_state == :set | ||
| 509 | NodeAction.record!(:node => self, :participants => [self, asset], | ||
| 510 | :user => user, :action => "asset_attach", **metadata) | ||
| 511 | end | ||
| 504 | end | 512 | end |
| 505 | 513 | ||
| 506 | { :attached => to_attach.size, | 514 | { :attached => to_attach.size, |
diff --git a/app/models/node_action.rb b/app/models/node_action.rb index 9ed0b628..aa52f489 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb | |||
| @@ -69,6 +69,17 @@ class NodeAction < ApplicationRecord | |||
| 69 | # "path" -- final path, flat string (create-symmetric) | 69 | # "path" -- final path, flat string (create-symmetric) |
| 70 | # "destroyed_descendants" -- integer, only when positive; one entry | 70 | # "destroyed_descendants" -- integer, only when positive; one entry |
| 71 | # at the root, per the subtree rule. | 71 | # at the root, per the subtree rule. |
| 72 | # | ||
| 73 | # "asset_create" (witnessed upload; participants: the asset alone): | ||
| 74 | # "asset_name", "content_type", "path" -- flat strings | ||
| 75 | # | ||
| 76 | # "asset_attach" (out-of-band attach via Node#attach_asset!; written | ||
| 77 | # only when at least one new join was created, per the tandem rule -- | ||
| 78 | # in-editor curation stays draft-scoped and surfaces at publish. | ||
| 79 | # participants: the node (primary) and the asset): | ||
| 80 | # "asset_name", "path" -- flat strings | ||
| 81 | # "headline" -- boolean, only when set by this attach | ||
| 82 | # | ||
| 72 | # "asset_destroy" (witnessed asset deletion; always written, even for | 83 | # "asset_destroy" (witnessed asset deletion; always written, even for |
| 73 | # unattached assets -- the files were publicly reachable; node column | 84 | # unattached assets -- the files were publicly reachable; node column |
| 74 | # nil, subjects via participants: the asset plus every then-attached | 85 | # nil, subjects via participants: the asset plus every then-attached |
diff --git a/config/locales/de.yml b/config/locales/de.yml index d6241d51..fff74799 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -116,6 +116,9 @@ de: | |||
| 116 | revision_created: "angelegt am %{date} von %{actor}" | 116 | revision_created: "angelegt am %{date} von %{actor}" |
| 117 | revision_published: "veröffentlicht am %{date} von %{actor}" | 117 | revision_published: "veröffentlicht am %{date} von %{actor}" |
| 118 | revision_restored: "wiederhergestellt am %{date} von %{actor}" | 118 | revision_restored: "wiederhergestellt am %{date} von %{actor}" |
| 119 | asset_create: "%{actor} hat das Asset „%{asset}“ hochgeladen" | ||
| 120 | asset_attach: "%{actor} hat „%{asset}“ an %{subject} angehängt" | ||
| 121 | asset_attach_headline: "%{actor} hat „%{asset}“ als Aufmacher an %{subject} angehängt" | ||
| 119 | asset_destroy: "%{actor} hat das Asset „%{asset}“ gelöscht" | 122 | asset_destroy: "%{actor} hat das Asset „%{asset}“ gelöscht" |
| 120 | asset_destroy_detached: "— entfernt von %{paths}" | 123 | asset_destroy_detached: "— entfernt von %{paths}" |
| 121 | asset_destroy_headlines: "(war Aufmacher von %{paths})" | 124 | asset_destroy_headlines: "(war Aufmacher von %{paths})" |
diff --git a/config/locales/en.yml b/config/locales/en.yml index bd59915b..fc9f5d13 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -71,6 +71,9 @@ en: | |||
| 71 | asset_destroy: "%{actor} destroyed asset “%{asset}”" | 71 | asset_destroy: "%{actor} destroyed asset “%{asset}”" |
| 72 | asset_destroy_detached: "— detached from %{paths}" | 72 | asset_destroy_detached: "— detached from %{paths}" |
| 73 | asset_destroy_headlines: "(was the headline of %{paths})" | 73 | asset_destroy_headlines: "(was the headline of %{paths})" |
| 74 | asset_create: "%{actor} uploaded asset “%{asset}”" | ||
| 75 | asset_attach: "%{actor} attached “%{asset}” to %{subject}" | ||
| 76 | asset_attach_headline: "%{actor} attached “%{asset}” to %{subject} as its headline" | ||
| 74 | 77 | ||
| 75 | open_gallery: "Open gallery" | 78 | open_gallery: "Open gallery" |
| 76 | asset_licenses: | 79 | asset_licenses: |
diff --git a/test/controllers/assets_controller_test.rb b/test/controllers/assets_controller_test.rb index 0e251aad..4be2e8a1 100644 --- a/test/controllers/assets_controller_test.rb +++ b/test/controllers/assets_controller_test.rb | |||
| @@ -133,6 +133,16 @@ class AssetsControllerTest < ActionController::TestCase | |||
| 133 | assert_equal node_path(node), flash[:headline_kept_path] | 133 | assert_equal node_path(node), flash[:headline_kept_path] |
| 134 | end | 134 | end |
| 135 | 135 | ||
| 136 | test "create with node_id writes an asset_create and an asset_attach entry" do | ||
| 137 | node = Node.root.children.create!(:slug => "asset_log_pair") | ||
| 138 | assert_difference 'NodeAction.where(:action => "asset_create").count' do | ||
| 139 | assert_difference 'NodeAction.where(:action => "asset_attach").count' do | ||
| 140 | post :create, params: { asset: { name: 'Logged twice' }, node_id: node.id } | ||
| 141 | end | ||
| 142 | end | ||
| 143 | assert_equal users(:quentin), NodeAction.last.user | ||
| 144 | end | ||
| 145 | |||
| 136 | # --- edit --- | 146 | # --- edit --- |
| 137 | 147 | ||
| 138 | test "get edit" do | 148 | test "get edit" do |
diff --git a/test/models/node_attach_asset_test.rb b/test/models/node_attach_asset_test.rb index cb5b60f5..2df2cfbb 100644 --- a/test/models/node_attach_asset_test.rb +++ b/test/models/node_attach_asset_test.rb | |||
| @@ -95,6 +95,25 @@ class NodeAttachAssetTest < ActiveSupport::TestCase | |||
| 95 | assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) } | 95 | assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) } |
| 96 | end | 96 | end |
| 97 | 97 | ||
| 98 | test "attaching writes an asset_attach entry with node and asset participants" do | ||
| 99 | result = @node.attach_asset!(@image, :user => @user, :headline => true) | ||
| 100 | assert_equal :set, result[:headline] | ||
| 101 | |||
| 102 | action = NodeAction.where(:action => "asset_attach").last | ||
| 103 | assert_equal @node, action.node | ||
| 104 | subjects = action.action_participants.map { |p| [p.subject_type, p.subject_id] } | ||
| 105 | assert_includes subjects, ["Node", @node.id] | ||
| 106 | assert_includes subjects, ["Asset", @image.id] | ||
| 107 | assert action.metadata["headline"] | ||
| 108 | end | ||
| 109 | |||
| 110 | test "a fully redundant attach writes no entry" do | ||
| 111 | @node.attach_asset!(@image, :user => @user) | ||
| 112 | assert_no_difference 'NodeAction.count' do | ||
| 113 | @node.attach_asset!(@image, :user => @user) | ||
| 114 | end | ||
| 115 | end | ||
| 116 | |||
| 98 | private | 117 | private |
| 99 | 118 | ||
| 100 | def create_image_asset | 119 | def create_image_asset |
