diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-02 22:45:51 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-02 22:45:51 +0200 |
| commit | da59dea46f55c64a3090b83a6321fbecd2ee6f60 (patch) | |
| tree | ea64bb3e3d124f8141ae0cfb07e1dfffc19162bb | |
| parent | 30d85119ba5cda8a23ca99df5af9e94f5391eb78 (diff) | |
Witness calendar entries
| -rw-r--r-- | app/controllers/events_controller.rb | 6 | ||||
| -rw-r--r-- | app/helpers/node_actions_helper.rb | 87 | ||||
| -rw-r--r-- | app/models/event.rb | 77 | ||||
| -rw-r--r-- | app/models/node_action.rb | 30 | ||||
| -rw-r--r-- | app/views/node_actions/_change_details.html.erb | 6 | ||||
| -rw-r--r-- | config/locales/de.yml | 33 | ||||
| -rw-r--r-- | config/locales/en.yml | 35 | ||||
| -rw-r--r-- | test/models/event_test.rb | 71 | ||||
| -rw-r--r-- | test/models/helpers/node_actions_helper_test.rb | 13 |
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 | ||
| 304 | end | 389 | end |
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 | ||
| 27 | end | 104 | end |
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 | ||
| 110 | end | 181 | end |
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 | ||
| 146 | end | 159 | end |
