summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/events_controller.rb6
-rw-r--r--app/helpers/node_actions_helper.rb87
-rw-r--r--app/models/event.rb77
-rw-r--r--app/models/node_action.rb30
-rw-r--r--app/views/node_actions/_change_details.html.erb6
-rw-r--r--config/locales/de.yml33
-rw-r--r--config/locales/en.yml35
-rw-r--r--test/models/event_test.rb71
-rw-r--r--test/models/helpers/node_actions_helper_test.rb13
9 files changed, 335 insertions, 23 deletions
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index a322181d..818459c4 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -65,7 +65,7 @@ class EventsController < ApplicationController
65 @event = Event.new(event_params) 65 @event = Event.new(event_params)
66 66
67 respond_to do |format| 67 respond_to do |format|
68 if @event.save 68 if @event.save_witnessed(:actor => current_user)
69 flash[:notice] = t("flash.events.created") 69 flash[:notice] = t("flash.events.created")
70 format.html { redirect_to(safe_return_to(params[:return_to] || (@event.node ? edit_node_path(@event.node) : edit_event_path(@event)))) } 70 format.html { redirect_to(safe_return_to(params[:return_to] || (@event.node ? edit_node_path(@event.node) : edit_event_path(@event)))) }
71 format.xml { render :xml => @event, :status => :created, :location => @event } 71 format.xml { render :xml => @event, :status => :created, :location => @event }
@@ -82,7 +82,7 @@ class EventsController < ApplicationController
82 @event = Event.find(params[:id]) 82 @event = Event.find(params[:id])
83 83
84 respond_to do |format| 84 respond_to do |format|
85 if @event.update(event_params) 85 if @event.update_witnessed(event_params, :actor => current_user)
86 flash[:notice] = t("flash.events.updated") 86 flash[:notice] = t("flash.events.updated")
87 format.html { redirect_to(safe_return_to(params[:return_to] || events_path)) } 87 format.html { redirect_to(safe_return_to(params[:return_to] || events_path)) }
88 format.xml { head :ok } 88 format.xml { head :ok }
@@ -97,7 +97,7 @@ class EventsController < ApplicationController
97 # DELETE /events/1.xml 97 # DELETE /events/1.xml
98 def destroy 98 def destroy
99 @event = Event.find(params[:id]) 99 @event = Event.find(params[:id])
100 @event.destroy 100 @event.destroy_witnessed(:actor => current_user)
101 101
102 respond_to do |format| 102 respond_to do |format|
103 format.html { redirect_to(events_url) } 103 format.html { redirect_to(events_url) }
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index 019a9cf7..672dda36 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -23,7 +23,10 @@ module NodeActionsHelper
23 "user_deactivate" => "user-off", 23 "user_deactivate" => "user-off",
24 "user_reactivate" => "user-check", 24 "user_reactivate" => "user-check",
25 "redaktion_grant" => "users-plus", 25 "redaktion_grant" => "users-plus",
26 "redaktion_revoke" => "users-minus" 26 "redaktion_revoke" => "users-minus",
27 "event_create" => "calendar-plus",
28 "event_update" => "calendar-event",
29 "event_destroy" => "calendar-x"
27 }.freeze 30 }.freeze
28 31
29 def verb_icon action 32 def verb_icon action
@@ -57,6 +60,7 @@ module NodeActionsHelper
57 def action_details? action 60 def action_details? action
58 m = action.metadata 61 m = action.metadata
59 return true if m["translation_diff"].present? 62 return true if m["translation_diff"].present?
63 return true if m["changes"].present? || m["description_changed"]
60 return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to") 64 return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to")
61 %w[author tags template_changed assets assets_changed assets_reordered 65 %w[author tags template_changed assets assets_changed assets_reordered
62 abstract_changed body_changed].any? { |key| m[key].present? } 66 abstract_changed body_changed].any? { |key| m[key].present? }
@@ -92,6 +96,59 @@ module NodeActionsHelper
92 items 96 items
93 end 97 end
94 98
99 def event_changes_list action
100 m = action.metadata
101 c = m["changes"] || {}
102 items = []
103
104 if c["title"]
105 items << t("node_actions.detail_title",
106 :from => c.dig("title", "from"), :to => c.dig("title", "to"))
107 end
108
109 if %w[rrule start_time end_time].any? { |field| c.key?(field) }
110 items << t("node_actions.detail_event_schedule",
111 :from => event_schedule_side(m, c, "from"),
112 :to => event_schedule_side(m, c, "to"))
113 end
114
115 if c["allday"]
116 items << t("node_actions.detail_event_allday",
117 :from => t("admin.common.#{c.dig("allday", "from") ? "yes" : "no"}"),
118 :to => t("admin.common.#{c.dig("allday", "to") ? "yes" : "no"}"))
119 end
120
121 %w[location url].each do |field|
122 next unless c[field]
123 items << t("node_actions.detail_event_#{field}",
124 :from => c.dig(field, "from").presence || t("node_actions.event_none"),
125 :to => c.dig(field, "to").presence || t("node_actions.event_none"))
126 end
127
128 if c["tags"]
129 items << t("node_actions.detail_tags",
130 :from => Array(c.dig("tags", "from")).join(", "),
131 :to => Array(c.dig("tags", "to")).join(", "))
132 end
133
134 if c["node_path"]
135 items << t("node_actions.detail_event_moved",
136 :from => c.dig("node_path", "from") || t("node_actions.event_none"),
137 :to => c.dig("node_path", "to") || t("node_actions.event_none"))
138 end
139
140 items << t("node_actions.detail_event_description") if m["description_changed"]
141 items << t("node_actions.detail_event_coordinates") if c["latitude"] || c["longitude"]
142 items
143 end
144
145 def event_schedule_side metadata, changes, side
146 attributes = %w[rrule start_time end_time].each_with_object({}) do |field, acc|
147 acc[field.to_sym] = changes.key?(field) ? changes.dig(field, side) : metadata[field]
148 end
149 event_schedule_text(Event.new(attributes)).presence || t("node_actions.event_none")
150 end
151
95 def translation_changes diff 152 def translation_changes diff
96 case diff["status"] 153 case diff["status"]
97 when "added" then [t("node_actions.locale_added", :title => diff.dig("title", "to"))] 154 when "added" then [t("node_actions.locale_added", :title => diff.dig("title", "to"))]
@@ -188,6 +245,12 @@ module NodeActionsHelper
188 participant ? link_to(name, edit_user_path(participant)) : name 245 participant ? link_to(name, edit_user_path(participant)) : name
189 end 246 end
190 247
248 def event_ref action
249 event = action.action_participants.detect { |p| p.subject_type == "Event" }&.subject
250 name = h(action.metadata["event_title"].presence || t("node_actions.unknown_event"))
251 event ? link_to(name, edit_event_path(event)) : name
252 end
253
191 def summarize_publish action 254 def summarize_publish action
192 if action.metadata["via"] == "revision" 255 if action.metadata["via"] == "revision"
193 t("node_actions.publish_rollback", 256 t("node_actions.publish_rollback",
@@ -301,4 +364,26 @@ module NodeActionsHelper
301 t("node_actions.user_create", :actor => actor_ref(action), 364 t("node_actions.user_create", :actor => actor_ref(action),
302 :target => user_participant_ref(action)).html_safe 365 :target => user_participant_ref(action)).html_safe
303 end 366 end
367
368 def summarize_event_create action
369 event_sentence(action, "event_create")
370 end
371
372 def summarize_event_update action
373 event_sentence(action, "event_update")
374 end
375
376 def summarize_event_destroy action
377 event_sentence(action, "event_destroy")
378 end
379
380 def event_sentence action, key
381 if action.node
382 t("node_actions.#{key}_on", :actor => actor_ref(action),
383 :event => event_ref(action), :subject => subject_ref(action)).html_safe
384 else
385 t("node_actions.#{key}", :actor => actor_ref(action),
386 :event => event_ref(action)).html_safe
387 end
388 end
304end 389end
diff --git a/app/models/event.rb b/app/models/event.rb
index b8651a81..7726f9bf 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -20,8 +20,85 @@ class Event < ApplicationRecord
20 title.presence || node&.head&.title || "Untitled event" 20 title.presence || node&.head&.title || "Untitled event"
21 end 21 end
22 22
23 def save_witnessed(actor:)
24 saved = false
25 transaction do
26 saved = save
27 raise ActiveRecord::Rollback unless saved
28 witness_event!("event_create", actor)
29 end
30 saved
31 end
32
33 def update_witnessed(attributes, actor:)
34 updated = false
35 transaction do
36 updated = update(attributes)
37 raise ActiveRecord::Rollback unless updated
38 changes = event_changes
39 witness_event!("event_update", actor, changes) if changes.any?
40 end
41 updated
42 end
43
44 def destroy_witnessed(actor:)
45 destroyed = false
46 transaction do
47 witness_event!("event_destroy", actor)
48 destroyed = destroy
49 raise ActiveRecord::Rollback unless destroyed
50 end
51 destroyed
52 end
53
23 private 54 private
24 def generate_occurrences 55 def generate_occurrences
25 Occurrence.generate self 56 Occurrence.generate self
26 end 57 end
58
59 def event_snapshot
60 {
61 :event_title => title.presence || node&.unique_name || "##{id}",
62 :start_time => start_time&.iso8601,
63 :end_time => end_time&.iso8601,
64 :allday => allday,
65 :rrule => rrule.presence,
66 :location => location.presence,
67 :url => url.presence,
68 :event_tags => tag_list.to_a.sort,
69 :path => node&.unique_name
70 }.compact
71 end
72
73 def event_changes
74 pairs = {}
75
76 saved_changes.except("updated_at", "created_at", "description")
77 .each do |attribute, (before, after)|
78 key, pair =
79 case attribute
80 when "node_id"
81 ["node_path", { "from" => Node.find_by(:id => before)&.unique_name,
82 "to" => Node.find_by(:id => after)&.unique_name }]
83 when "start_time", "end_time"
84 [attribute, { "from" => before&.iso8601, "to" => after&.iso8601 }]
85 when "tag_list"
86 ["tags", { "from" => Array(before).sort, "to" => Array(after).sort }]
87 else
88 [attribute, { "from" => before, "to" => after }]
89 end
90 pairs[key] = pair
91 end
92
93 extra = {}
94 extra[:changes] = pairs if pairs.any?
95 extra[:description_changed] = true if saved_changes.key?("description")
96 extra
97 end
98
99 def witness_event!(verb, actor, extra = {})
100 NodeAction.record!(:node => node, :participants => [node, self].compact,
101 :action => verb, :user => actor,
102 **event_snapshot, **extra)
103 end
27end 104end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 82a3ef44..0167762b 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -107,6 +107,36 @@ class NodeAction < ApplicationRecord
107 # participant differ: 107 # participant differ:
108 # "target_login" -- flat string, the affected account's login 108 # "target_login" -- flat string, the affected account's login
109 # 109 #
110 # "event_create" / "event_update" / "event_destroy" (calendar
111 # entries; participants: the Event and, when it has one, its Node,
112 # which also fills the node column. Deliberately not gated --
113 # events reach chapter pages and widgets, never the feeds, and
114 # protection here follows emission, not position).
115 #
116 # Events carry no revisions, so the log is their only history:
117 # every entry holds a full snapshot of the state it produced, and
118 # walking an event's entries reconstructs it. Times are ISO 8601
119 # and the rrule is raw, never humanised -- entries are read in both
120 # locales and event_schedule_text resolves that at render time.
121 # "event_title" -- flat string; the title, else the node's
122 # unique_name, else "#<id>"
123 # "start_time", "end_time" -- ISO 8601, when set
124 # "allday" -- boolean
125 # "rrule" -- raw RRULE, when set
126 # "location", "url" -- flat strings, when set
127 # "event_tags" -- array of names, sorted, always. Named apart
128 # from the node verbs' "tags", which is a pair,
129 # so one renderer cannot mistake the other.
130 # "path" -- the node's unique_name, when it has a node
131 #
132 # On "event_update" only, and only when something changed -- an
133 # update that changes nothing records no entry at all:
134 # "changes" -- {field => pair}, node_id resolved to paths
135 # under "node_path", tag_list under "tags",
136 # times as ISO 8601
137 # "description_changed" -- boolean; prose, flagged not quoted,
138 # as abstract_changed and body_changed are
139 #
110 # Reserved: "demote" (via "trash" | "depublish") for an explicit 140 # Reserved: "demote" (via "trash" | "depublish") for an explicit
111 # depublish workflow, if ever built. 141 # depublish workflow, if ever built.
112 # 142 #
diff --git a/app/views/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
index 2da8bfd5..98e70d8f 100644
--- a/app/views/node_actions/_change_details.html.erb
+++ b/app/views/node_actions/_change_details.html.erb
@@ -17,6 +17,12 @@
17 </td> 17 </td>
18 </tr> 18 </tr>
19 <% end %> 19 <% end %>
20 <% if (event_items = event_changes_list(action_entry)).any? %>
21 <tr>
22 <th></th>
23 <td><%= safe_join(event_items, tag.br) %></td>
24 </tr>
25 <% end %>
20 <% (action_entry.metadata["translation_diff"] || {}).each do |locale, diff| %> 26 <% (action_entry.metadata["translation_diff"] || {}).each do |locale, diff| %>
21 <tr> 27 <tr>
22 <th><%= locale.upcase %></th> 28 <th><%= locale.upcase %></th>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 9ea828ed..8c39770d 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -181,12 +181,12 @@ de:
181 181
182 node_actions: 182 node_actions:
183 heading: "Letzte Änderungen" 183 heading: "Letzte Änderungen"
184 show_all: "Alle Einträge zeigen" 184 show_all: "Alle Einträge anzeigen"
185 backfilled: "rekonstruiert" 185 backfilled: "rekonstruiert"
186 show_changes: "Änderungen im Detail" 186 show_changes: "Änderungen im Detail"
187 view_revision: "Diese Revision ansehen" 187 view_revision: "Diese Revision anzeigen"
188 view_diff: "Diff anzeigen" 188 view_diff: "Diff anzeigen"
189 unknown: "%{actor} hat %{action} auf %{subject} ausgeführt" 189 unknown: "%{actor} hat %{action} an %{subject} ausgeführt"
190 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht" 190 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht"
191 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt" 191 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt"
192 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht" 192 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht"
@@ -195,8 +195,8 @@ de:
195 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben" 195 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
196 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verworfen" 196 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verworfen"
197 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen" 197 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen"
198 locale_added: "Übersetzung angelegt, Titel \"%{title}\"" 198 locale_added: "Übersetzung angelegt, Titel %{title}"
199 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\"" 199 locale_removed: "Übersetzung entfernt, letzter Titel %{title}"
200 abstract_changed: "Abstract geändert" 200 abstract_changed: "Abstract geändert"
201 body_changed: "Text geändert" 201 body_changed: "Text geändert"
202 revision_new: "eine neue Revision" 202 revision_new: "eine neue Revision"
@@ -206,7 +206,7 @@ de:
206 detail_author: "Autor %{from} → %{to}" 206 detail_author: "Autor %{from} → %{to}"
207 detail_tags: "Tags %{from} → %{to}" 207 detail_tags: "Tags %{from} → %{to}"
208 template_changed: "Template geändert" 208 template_changed: "Template geändert"
209 assets_changed: "Bildliste geändert" 209 assets_changed: "Anhänge geändert"
210 trash: "%{actor} hat %{subject} in den Papierkorb verschoben (vorher unter %{from})" 210 trash: "%{actor} hat %{subject} in den Papierkorb verschoben (vorher unter %{from})"
211 restore_from_trash: "%{actor} hat %{subject} aus dem Papierkorb nach %{to} wiederhergestellt" 211 restore_from_trash: "%{actor} hat %{subject} aus dem Papierkorb nach %{to} wiederhergestellt"
212 destroy: "%{actor} hat %{subject} endgültig gelöscht (zuletzt unter %{path})" 212 destroy: "%{actor} hat %{subject} endgültig gelöscht (zuletzt unter %{path})"
@@ -224,13 +224,28 @@ de:
224 detail_assets_removed: "Anhänge entfernt: %{names}" 224 detail_assets_removed: "Anhänge entfernt: %{names}"
225 assets_reordered: "Anhänge umsortiert" 225 assets_reordered: "Anhänge umsortiert"
226 otp_enroll: "%{actor} hat einen zweiten Faktor eingerichtet" 226 otp_enroll: "%{actor} hat einen zweiten Faktor eingerichtet"
227 otp_disable: "%{actor} hat den zweiten Faktor entfernt" 227 otp_disable: "%{actor} hat seinen zweiten Faktor entfernt"
228 otp_reset: "%{actor} hat den zweiten Faktor von %{target} zurückgesetzt" 228 otp_reset: "%{actor} hat den zweiten Faktor von %{target} zurückgesetzt"
229 user_create: "%{actor} hat das Konto %{target} angelegt" 229 user_create: "%{actor} hat das Konto %{target} angelegt"
230 user_deactivate: "%{actor} hat %{target} deaktiviert" 230 user_deactivate: "%{actor} hat %{target} deaktiviert"
231 user_reactivate: "%{actor} hat %{target} reaktiviert" 231 user_reactivate: "%{actor} hat %{target} reaktiviert"
232 redaktion_grant: "%{actor} hat %{target} in die Redaktion aufgenommen" 232 redaktion_grant: "%{actor} hat %{target} in die Redaktion aufgenommen"
233 redaktion_revoke: "%{actor} hat %{target} aus der Redaktion entfernt" 233 redaktion_revoke: "%{actor} hat %{target} aus der Redaktion entfernt"
234 event_create: "%{actor} hat den Termin %{event} angelegt"
235 event_create_on: "%{actor} hat den Termin %{event} unter %{subject} angelegt"
236 event_update: "%{actor} hat den Termin %{event} geändert"
237 event_update_on: "%{actor} hat den Termin %{event} bei %{subject} geändert"
238 event_destroy: "%{actor} hat den Termin %{event} gelöscht"
239 event_destroy_on: "%{actor} hat den Termin %{event} unter %{subject} gelöscht"
240 detail_event_schedule: "Zeitangabe %{from} → %{to}"
241 detail_event_location: "Ort %{from} → %{to}"
242 detail_event_url: "Link %{from} → %{to}"
243 detail_event_moved: "verschoben von %{from} nach %{to}"
244 detail_event_description: "Beschreibung geändert"
245 detail_event_coordinates: "Koordinaten geändert"
246 detail_event_allday: "ganztägig %{from} → %{to}"
247 event_none: "—"
248 unknown_event: "ein Termin"
234 249
235 open_gallery: "Gallerie anzeigen" 250 open_gallery: "Gallerie anzeigen"
236 asset_licenses: 251 asset_licenses:
@@ -552,14 +567,14 @@ de:
552 edit: 567 edit:
553 title: "Termin bearbeiten" 568 title: "Termin bearbeiten"
554 change_node: "Node ändern" 569 change_node: "Node ändern"
555 change_node_hint: "Verknüpft den Termin mit einem anderen Node selten nötig." 570 change_node_hint: "Verknüpft den Termin mit einem anderen Node (selten nötig)."
556 without_node: 571 without_node:
557 title: "Termine ohne Node" 572 title: "Termine ohne Node"
558 all_events_link: "← Alle Termine" 573 all_events_link: "← Alle Termine"
559 form: 574 form:
560 node_hint: "Optional — Node suchen und auswählen, um diesen Termin mit einer Seite zu verknüpfen." 575 node_hint: "Optional — Node suchen und auswählen, um diesen Termin mit einer Seite zu verknüpfen."
561 recurrence: "Wiederholung" 576 recurrence: "Wiederholung"
562 title_hint: "Optional — bleibt das Feld leer, wird der Titel des verknüpften Nodes verwendet." 577 title_hint: "Optional. Bleibt das Feld leer, wird der Titel des verknüpften Nodes verwendet."
563 rrule_builder: 578 rrule_builder:
564 weekly: "Wöchentlich" 579 weekly: "Wöchentlich"
565 monthly: "Monatlich" 580 monthly: "Monatlich"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 7b5b1b86..ec57e33d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -127,13 +127,13 @@ en:
127 127
128 128
129 node_actions: 129 node_actions:
130 heading: "Action log" 130 heading: "Recent changes"
131 show_all: "Show all entries" 131 show_all: "Show all entries"
132 backfilled: "backfilled" 132 backfilled: "backfilled"
133 show_changes: "Change details" 133 show_changes: "Change details"
134 view_revision: "view this revision" 134 view_revision: "Show this revision"
135 view_diff: "Show diff" 135 view_diff: "Show diff"
136 unknown: "%{actor} did %{action} on %{subject}" 136 unknown: "%{actor} performed %{action} on %{subject}"
137 publish: "%{actor} published %{revision} of %{subject}" 137 publish: "%{actor} published %{revision} of %{subject}"
138 publish_rollback: "%{actor} rolled %{subject} back to %{revision}" 138 publish_rollback: "%{actor} rolled %{subject} back to %{revision}"
139 publish_first: "%{actor} published %{subject} for the first time" 139 publish_first: "%{actor} published %{subject} for the first time"
@@ -142,18 +142,18 @@ en:
142 move: "%{actor} moved %{subject} from %{from} to %{to}" 142 move: "%{actor} moved %{subject} from %{from} to %{to}"
143 discard_autosave: "%{actor} discarded unsaved changes on %{subject}" 143 discard_autosave: "%{actor} discarded unsaved changes on %{subject}"
144 destroy_draft: "%{actor} discarded the draft of %{subject}" 144 destroy_draft: "%{actor} discarded the draft of %{subject}"
145 locale_added: "translation added, titled \"%{title}\"" 145 locale_added: "translation added, titled %{title}"
146 locale_removed: "translation removed, last titled \"%{title}\"" 146 locale_removed: "translation removed, last titled %{title}"
147 abstract_changed: "abstract changed" 147 abstract_changed: "abstract changed"
148 body_changed: "body changed" 148 body_changed: "body changed"
149 revision_new: "a new revision" 149 revision_new: "a new revision"
150 revision_earlier: "an earlier revision" 150 revision_earlier: "an earlier revision"
151 node_history: "node history" 151 node_history: "node history"
152 detail_title: "title \"%{from}\"\"%{to}\"" 152 detail_title: "title %{from}%{to}"
153 detail_author: "author %{from} → %{to}" 153 detail_author: "author %{from} → %{to}"
154 detail_tags: "tags %{from} → %{to}" 154 detail_tags: "tags %{from} → %{to}"
155 template_changed: "template changed" 155 template_changed: "template changed"
156 assets_changed: "attached images changed" 156 assets_changed: "attachments changed"
157 trash: "%{actor} moved %{subject} to the Trash (was at %{from})" 157 trash: "%{actor} moved %{subject} to the Trash (was at %{from})"
158 restore_from_trash: "%{actor} restored %{subject} from the Trash to %{to}" 158 restore_from_trash: "%{actor} restored %{subject} from the Trash to %{to}"
159 destroy: "%{actor} permanently deleted %{subject} (last at %{path})" 159 destroy: "%{actor} permanently deleted %{subject} (last at %{path})"
@@ -167,9 +167,9 @@ en:
167 asset_create: "%{actor} uploaded asset “%{asset}”" 167 asset_create: "%{actor} uploaded asset “%{asset}”"
168 asset_attach: "%{actor} attached “%{asset}” to %{subject}" 168 asset_attach: "%{actor} attached “%{asset}” to %{subject}"
169 asset_attach_headline: "%{actor} attached “%{asset}” to %{subject} as its headline" 169 asset_attach_headline: "%{actor} attached “%{asset}” to %{subject} as its headline"
170 detail_assets_added: "Attachments added: %{names}" 170 detail_assets_added: "attachments added: %{names}"
171 detail_assets_removed: "Attachments removed: %{names}" 171 detail_assets_removed: "attachments removed: %{names}"
172 assets_reordered: "Attachments reordered" 172 assets_reordered: "attachments reordered"
173 otp_enroll: "%{actor} set up a second factor" 173 otp_enroll: "%{actor} set up a second factor"
174 otp_disable: "%{actor} removed their second factor" 174 otp_disable: "%{actor} removed their second factor"
175 otp_reset: "%{actor} reset the second factor of %{target}" 175 otp_reset: "%{actor} reset the second factor of %{target}"
@@ -178,6 +178,21 @@ en:
178 user_reactivate: "%{actor} reactivated %{target}" 178 user_reactivate: "%{actor} reactivated %{target}"
179 redaktion_grant: "%{actor} added %{target} to Redaktion" 179 redaktion_grant: "%{actor} added %{target} to Redaktion"
180 redaktion_revoke: "%{actor} removed %{target} from Redaktion" 180 redaktion_revoke: "%{actor} removed %{target} from Redaktion"
181 event_create: "%{actor} added the event %{event}"
182 event_create_on: "%{actor} added the event %{event} to %{subject}"
183 event_update: "%{actor} changed the event %{event}"
184 event_update_on: "%{actor} changed the event %{event} on %{subject}"
185 event_destroy: "%{actor} deleted the event %{event}"
186 event_destroy_on: "%{actor} deleted the event %{event} from %{subject}"
187 detail_event_schedule: "schedule %{from} → %{to}"
188 detail_event_location: "location %{from} → %{to}"
189 detail_event_url: "link %{from} → %{to}"
190 detail_event_moved: "node %{from} → %{to}"
191 detail_event_description: "description changed"
192 detail_event_coordinates: "coordinates changed"
193 detail_event_allday: "all-day %{from} → %{to}"
194 event_none: "—"
195 unknown_event: "an event"
181 196
182 open_gallery: "Open gallery" 197 open_gallery: "Open gallery"
183 asset_licenses: 198 asset_licenses:
diff --git a/test/models/event_test.rb b/test/models/event_test.rb
index 2a671b7c..a5449a2a 100644
--- a/test/models/event_test.rb
+++ b/test/models/event_test.rb
@@ -13,6 +13,10 @@ class EventTest < ActiveSupport::TestCase
13 @cal_node.publish_draft! 13 @cal_node.publish_draft!
14 @cal_node.head.reload 14 @cal_node.head.reload
15 end 15 end
16
17 def event_entries
18 NodeAction.where(:action => %w[event_create event_update event_destroy]).order(:id)
19 end
16 20
17 test 'verfy setup data' do 21 test 'verfy setup data' do
18 assert_not_nil @cal_node 22 assert_not_nil @cal_node
@@ -107,4 +111,71 @@ class EventTest < ActiveSupport::TestCase
107 chaosradio_days = scoped_occurrences.map {|x| x.start_time.day} 111 chaosradio_days = scoped_occurrences.map {|x| x.start_time.day}
108 assert_equal expected_days, chaosradio_days 112 assert_equal expected_days, chaosradio_days
109 end 113 end
114
115 test "creating an event is witnessed with a full snapshot" do
116 event = Event.new(:title => "Chaostreff",
117 :start_time => Time.utc(2026, 9, 1, 19, 0),
118 :end_time => Time.utc(2026, 9, 1, 21, 0),
119 :rrule => "FREQ=WEEKLY;BYDAY=TU", :location => "Zentrale",
120 :tag_list => "open-day")
121
122 assert_difference -> { event_entries.count }, 1 do
123 assert event.save_witnessed(:actor => users(:aaron))
124 end
125
126 entry = event_entries.last
127 assert_equal "event_create", entry.action
128 assert_equal users(:aaron).id, entry.user_id
129 assert_equal "Chaostreff", entry.metadata["event_title"]
130 assert_equal "FREQ=WEEKLY;BYDAY=TU", entry.metadata["rrule"]
131 assert_equal ["open-day"], entry.metadata["event_tags"]
132 assert_equal event.id, entry.action_participants.first.subject_id
133 assert_equal "Event", entry.action_participants.first.subject_type
134 end
135
136 test "updating an event records only what changed" do
137 event = Event.new(:title => "Chaostreff", :location => "Zentrale")
138 event.save_witnessed(:actor => users(:aaron))
139
140 assert_difference -> { event_entries.count }, 1 do
141 assert event.update_witnessed({ :location => "Neue Zentrale" }, :actor => users(:aaron))
142 end
143
144 entry = event_entries.last
145 assert_equal "event_update", entry.action
146 assert_equal({ "from" => "Zentrale", "to" => "Neue Zentrale" },
147 entry.metadata.dig("changes", "location"))
148 assert_nil entry.metadata.dig("changes", "title")
149 assert_equal "Neue Zentrale", entry.metadata["location"]
150 end
151
152 test "an update that changes nothing records no entry" do
153 event = Event.new(:title => "Chaostreff")
154 event.save_witnessed(:actor => users(:aaron))
155
156 assert_no_difference -> { event_entries.count } do
157 assert event.update_witnessed({ :title => "Chaostreff" }, :actor => users(:aaron))
158 end
159 end
160
161 test "deleting an event is witnessed before the row goes" do
162 event = Event.new(:title => "Chaostreff", :location => "Zentrale")
163 event.save_witnessed(:actor => users(:aaron))
164
165 assert_difference -> { event_entries.count }, 1 do
166 assert event.destroy_witnessed(:actor => users(:aaron))
167 end
168
169 entry = event_entries.last
170 assert_equal "event_destroy", entry.action
171 assert_equal "Chaostreff", entry.metadata["event_title"]
172 assert_equal "Zentrale", entry.metadata["location"]
173 assert_nil Event.find_by(:id => event.id)
174 end
175
176 test "an event may start without ending" do
177 event = Event.new(:title => "Chaostreff", :start_time => Time.utc(2026, 9, 1, 19, 0))
178 assert event.save
179 assert_equal event.start_time, event.occurrences.first&.start_time
180 end
110end 181end
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
index 1b72ec9c..01b57962 100644
--- a/test/models/helpers/node_actions_helper_test.rb
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -143,4 +143,17 @@ class NodeActionsHelperTest < ActionView::TestCase
143 assert_includes verb_icon(entry("publish", { "via" => "revision" })), "node_action_icon--history" 143 assert_includes verb_icon(entry("publish", { "via" => "revision" })), "node_action_icon--history"
144 assert_includes verb_icon(entry("frobnicate")), "node_action_icon--circle-dashed" 144 assert_includes verb_icon(entry("frobnicate")), "node_action_icon--circle-dashed"
145 end 145 end
146
147 test "an event entry renders after its event is gone" do
148 event = Event.new(:title => "Chaostreff", :rrule => "FREQ=WEEKLY;BYDAY=TU",
149 :start_time => Time.utc(2026, 9, 1, 19, 0),
150 :end_time => Time.utc(2026, 9, 1, 21, 0))
151 event.save_witnessed(:actor => users(:aaron))
152 event.destroy_witnessed(:actor => users(:aaron))
153
154 entry = NodeAction.where(:action => "event_destroy").order(:id).last
155
156 assert_match "Chaostreff", action_summary(entry)
157 assert_nothing_raised { event_changes_list(entry) }
158 end
146end 159end