From da59dea46f55c64a3090b83a6321fbecd2ee6f60 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 2 Aug 2026 22:45:51 +0200 Subject: Witness calendar entries --- app/controllers/events_controller.rb | 6 +- app/helpers/node_actions_helper.rb | 87 ++++++++++++++++++++++++- app/models/event.rb | 77 ++++++++++++++++++++++ app/models/node_action.rb | 30 +++++++++ app/views/node_actions/_change_details.html.erb | 6 ++ config/locales/de.yml | 33 +++++++--- config/locales/en.yml | 35 +++++++--- test/models/event_test.rb | 71 ++++++++++++++++++++ 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 @event = Event.new(event_params) respond_to do |format| - if @event.save + if @event.save_witnessed(:actor => current_user) flash[:notice] = t("flash.events.created") format.html { redirect_to(safe_return_to(params[:return_to] || (@event.node ? edit_node_path(@event.node) : edit_event_path(@event)))) } format.xml { render :xml => @event, :status => :created, :location => @event } @@ -82,7 +82,7 @@ class EventsController < ApplicationController @event = Event.find(params[:id]) respond_to do |format| - if @event.update(event_params) + if @event.update_witnessed(event_params, :actor => current_user) flash[:notice] = t("flash.events.updated") format.html { redirect_to(safe_return_to(params[:return_to] || events_path)) } format.xml { head :ok } @@ -97,7 +97,7 @@ class EventsController < ApplicationController # DELETE /events/1.xml def destroy @event = Event.find(params[:id]) - @event.destroy + @event.destroy_witnessed(:actor => current_user) respond_to do |format| 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 "user_deactivate" => "user-off", "user_reactivate" => "user-check", "redaktion_grant" => "users-plus", - "redaktion_revoke" => "users-minus" + "redaktion_revoke" => "users-minus", + "event_create" => "calendar-plus", + "event_update" => "calendar-event", + "event_destroy" => "calendar-x" }.freeze def verb_icon action @@ -57,6 +60,7 @@ module NodeActionsHelper def action_details? action m = action.metadata return true if m["translation_diff"].present? + return true if m["changes"].present? || m["description_changed"] return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to") %w[author tags template_changed assets assets_changed assets_reordered abstract_changed body_changed].any? { |key| m[key].present? } @@ -92,6 +96,59 @@ module NodeActionsHelper items end + def event_changes_list action + m = action.metadata + c = m["changes"] || {} + items = [] + + if c["title"] + items << t("node_actions.detail_title", + :from => c.dig("title", "from"), :to => c.dig("title", "to")) + end + + if %w[rrule start_time end_time].any? { |field| c.key?(field) } + items << t("node_actions.detail_event_schedule", + :from => event_schedule_side(m, c, "from"), + :to => event_schedule_side(m, c, "to")) + end + + if c["allday"] + items << t("node_actions.detail_event_allday", + :from => t("admin.common.#{c.dig("allday", "from") ? "yes" : "no"}"), + :to => t("admin.common.#{c.dig("allday", "to") ? "yes" : "no"}")) + end + + %w[location url].each do |field| + next unless c[field] + items << t("node_actions.detail_event_#{field}", + :from => c.dig(field, "from").presence || t("node_actions.event_none"), + :to => c.dig(field, "to").presence || t("node_actions.event_none")) + end + + if c["tags"] + items << t("node_actions.detail_tags", + :from => Array(c.dig("tags", "from")).join(", "), + :to => Array(c.dig("tags", "to")).join(", ")) + end + + if c["node_path"] + items << t("node_actions.detail_event_moved", + :from => c.dig("node_path", "from") || t("node_actions.event_none"), + :to => c.dig("node_path", "to") || t("node_actions.event_none")) + end + + items << t("node_actions.detail_event_description") if m["description_changed"] + items << t("node_actions.detail_event_coordinates") if c["latitude"] || c["longitude"] + items + end + + def event_schedule_side metadata, changes, side + attributes = %w[rrule start_time end_time].each_with_object({}) do |field, acc| + acc[field.to_sym] = changes.key?(field) ? changes.dig(field, side) : metadata[field] + end + event_schedule_text(Event.new(attributes)).presence || t("node_actions.event_none") + end + def translation_changes diff case diff["status"] when "added" then [t("node_actions.locale_added", :title => diff.dig("title", "to"))] @@ -188,6 +245,12 @@ module NodeActionsHelper participant ? link_to(name, edit_user_path(participant)) : name end + def event_ref action + event = action.action_participants.detect { |p| p.subject_type == "Event" }&.subject + name = h(action.metadata["event_title"].presence || t("node_actions.unknown_event")) + event ? link_to(name, edit_event_path(event)) : name + end + def summarize_publish action if action.metadata["via"] == "revision" t("node_actions.publish_rollback", @@ -301,4 +364,26 @@ module NodeActionsHelper t("node_actions.user_create", :actor => actor_ref(action), :target => user_participant_ref(action)).html_safe end + + def summarize_event_create action + event_sentence(action, "event_create") + end + + def summarize_event_update action + event_sentence(action, "event_update") + end + + def summarize_event_destroy action + event_sentence(action, "event_destroy") + end + + def event_sentence action, key + if action.node + t("node_actions.#{key}_on", :actor => actor_ref(action), + :event => event_ref(action), :subject => subject_ref(action)).html_safe + else + t("node_actions.#{key}", :actor => actor_ref(action), + :event => event_ref(action)).html_safe + end + end 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 title.presence || node&.head&.title || "Untitled event" end + def save_witnessed(actor:) + saved = false + transaction do + saved = save + raise ActiveRecord::Rollback unless saved + witness_event!("event_create", actor) + end + saved + end + + def update_witnessed(attributes, actor:) + updated = false + transaction do + updated = update(attributes) + raise ActiveRecord::Rollback unless updated + changes = event_changes + witness_event!("event_update", actor, changes) if changes.any? + end + updated + end + + def destroy_witnessed(actor:) + destroyed = false + transaction do + witness_event!("event_destroy", actor) + destroyed = destroy + raise ActiveRecord::Rollback unless destroyed + end + destroyed + end + private def generate_occurrences Occurrence.generate self end + + def event_snapshot + { + :event_title => title.presence || node&.unique_name || "##{id}", + :start_time => start_time&.iso8601, + :end_time => end_time&.iso8601, + :allday => allday, + :rrule => rrule.presence, + :location => location.presence, + :url => url.presence, + :event_tags => tag_list.to_a.sort, + :path => node&.unique_name + }.compact + end + + def event_changes + pairs = {} + + saved_changes.except("updated_at", "created_at", "description") + .each do |attribute, (before, after)| + key, pair = + case attribute + when "node_id" + ["node_path", { "from" => Node.find_by(:id => before)&.unique_name, + "to" => Node.find_by(:id => after)&.unique_name }] + when "start_time", "end_time" + [attribute, { "from" => before&.iso8601, "to" => after&.iso8601 }] + when "tag_list" + ["tags", { "from" => Array(before).sort, "to" => Array(after).sort }] + else + [attribute, { "from" => before, "to" => after }] + end + pairs[key] = pair + end + + extra = {} + extra[:changes] = pairs if pairs.any? + extra[:description_changed] = true if saved_changes.key?("description") + extra + end + + def witness_event!(verb, actor, extra = {}) + NodeAction.record!(:node => node, :participants => [node, self].compact, + :action => verb, :user => actor, + **event_snapshot, **extra) + end 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 # participant differ: # "target_login" -- flat string, the affected account's login # + # "event_create" / "event_update" / "event_destroy" (calendar + # entries; participants: the Event and, when it has one, its Node, + # which also fills the node column. Deliberately not gated -- + # events reach chapter pages and widgets, never the feeds, and + # protection here follows emission, not position). + # + # Events carry no revisions, so the log is their only history: + # every entry holds a full snapshot of the state it produced, and + # walking an event's entries reconstructs it. Times are ISO 8601 + # and the rrule is raw, never humanised -- entries are read in both + # locales and event_schedule_text resolves that at render time. + # "event_title" -- flat string; the title, else the node's + # unique_name, else "#" + # "start_time", "end_time" -- ISO 8601, when set + # "allday" -- boolean + # "rrule" -- raw RRULE, when set + # "location", "url" -- flat strings, when set + # "event_tags" -- array of names, sorted, always. Named apart + # from the node verbs' "tags", which is a pair, + # so one renderer cannot mistake the other. + # "path" -- the node's unique_name, when it has a node + # + # On "event_update" only, and only when something changed -- an + # update that changes nothing records no entry at all: + # "changes" -- {field => pair}, node_id resolved to paths + # under "node_path", tag_list under "tags", + # times as ISO 8601 + # "description_changed" -- boolean; prose, flagged not quoted, + # as abstract_changed and body_changed are + # # Reserved: "demote" (via "trash" | "depublish") for an explicit # depublish workflow, if ever built. # 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 @@ <% end %> + <% if (event_items = event_changes_list(action_entry)).any? %> + + + <%= safe_join(event_items, tag.br) %> + + <% end %> <% (action_entry.metadata["translation_diff"] || {}).each do |locale, diff| %> <%= locale.upcase %> 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: node_actions: heading: "Letzte Änderungen" - show_all: "Alle Einträge zeigen" + show_all: "Alle Einträge anzeigen" backfilled: "rekonstruiert" show_changes: "Änderungen im Detail" - view_revision: "Diese Revision ansehen" + view_revision: "Diese Revision anzeigen" view_diff: "Diff anzeigen" - unknown: "%{actor} hat %{action} auf %{subject} ausgeführt" + unknown: "%{actor} hat %{action} an %{subject} ausgeführt" publish: "%{actor} hat %{revision} von %{subject} veröffentlicht" publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt" publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht" @@ -195,8 +195,8 @@ de: move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben" discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verworfen" destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen" - locale_added: "Übersetzung angelegt, Titel \"%{title}\"" - locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\"" + locale_added: "Übersetzung angelegt, Titel „%{title}“" + locale_removed: "Übersetzung entfernt, letzter Titel „%{title}“" abstract_changed: "Abstract geändert" body_changed: "Text geändert" revision_new: "eine neue Revision" @@ -206,7 +206,7 @@ de: detail_author: "Autor %{from} → %{to}" detail_tags: "Tags %{from} → %{to}" template_changed: "Template geändert" - assets_changed: "Bildliste geändert" + assets_changed: "Anhänge geändert" trash: "%{actor} hat %{subject} in den Papierkorb verschoben (vorher unter %{from})" restore_from_trash: "%{actor} hat %{subject} aus dem Papierkorb nach %{to} wiederhergestellt" destroy: "%{actor} hat %{subject} endgültig gelöscht (zuletzt unter %{path})" @@ -224,13 +224,28 @@ de: detail_assets_removed: "Anhänge entfernt: %{names}" assets_reordered: "Anhänge umsortiert" otp_enroll: "%{actor} hat einen zweiten Faktor eingerichtet" - otp_disable: "%{actor} hat den zweiten Faktor entfernt" + otp_disable: "%{actor} hat seinen zweiten Faktor entfernt" otp_reset: "%{actor} hat den zweiten Faktor von %{target} zurückgesetzt" user_create: "%{actor} hat das Konto %{target} angelegt" user_deactivate: "%{actor} hat %{target} deaktiviert" user_reactivate: "%{actor} hat %{target} reaktiviert" redaktion_grant: "%{actor} hat %{target} in die Redaktion aufgenommen" redaktion_revoke: "%{actor} hat %{target} aus der Redaktion entfernt" + event_create: "%{actor} hat den Termin %{event} angelegt" + event_create_on: "%{actor} hat den Termin %{event} unter %{subject} angelegt" + event_update: "%{actor} hat den Termin %{event} geändert" + event_update_on: "%{actor} hat den Termin %{event} bei %{subject} geändert" + event_destroy: "%{actor} hat den Termin %{event} gelöscht" + event_destroy_on: "%{actor} hat den Termin %{event} unter %{subject} gelöscht" + detail_event_schedule: "Zeitangabe %{from} → %{to}" + detail_event_location: "Ort %{from} → %{to}" + detail_event_url: "Link %{from} → %{to}" + detail_event_moved: "verschoben von %{from} nach %{to}" + detail_event_description: "Beschreibung geändert" + detail_event_coordinates: "Koordinaten geändert" + detail_event_allday: "ganztägig %{from} → %{to}" + event_none: "—" + unknown_event: "ein Termin" open_gallery: "Gallerie anzeigen" asset_licenses: @@ -552,14 +567,14 @@ de: edit: title: "Termin bearbeiten" change_node: "Node ändern" - change_node_hint: "Verknüpft den Termin mit einem anderen Node — selten nötig." + change_node_hint: "Verknüpft den Termin mit einem anderen Node (selten nötig)." without_node: title: "Termine ohne Node" all_events_link: "← Alle Termine" form: node_hint: "Optional — Node suchen und auswählen, um diesen Termin mit einer Seite zu verknüpfen." recurrence: "Wiederholung" - title_hint: "Optional — bleibt das Feld leer, wird der Titel des verknüpften Nodes verwendet." + title_hint: "Optional. Bleibt das Feld leer, wird der Titel des verknüpften Nodes verwendet." rrule_builder: weekly: "Wöchentlich" 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: node_actions: - heading: "Action log" + heading: "Recent changes" show_all: "Show all entries" backfilled: "backfilled" show_changes: "Change details" - view_revision: "view this revision" + view_revision: "Show this revision" view_diff: "Show diff" - unknown: "%{actor} did %{action} on %{subject}" + unknown: "%{actor} performed %{action} on %{subject}" publish: "%{actor} published %{revision} of %{subject}" publish_rollback: "%{actor} rolled %{subject} back to %{revision}" publish_first: "%{actor} published %{subject} for the first time" @@ -142,18 +142,18 @@ en: move: "%{actor} moved %{subject} from %{from} to %{to}" discard_autosave: "%{actor} discarded unsaved changes on %{subject}" destroy_draft: "%{actor} discarded the draft of %{subject}" - locale_added: "translation added, titled \"%{title}\"" - locale_removed: "translation removed, last titled \"%{title}\"" + locale_added: "translation added, titled “%{title}”" + locale_removed: "translation removed, last titled “%{title}”" abstract_changed: "abstract changed" body_changed: "body changed" revision_new: "a new revision" revision_earlier: "an earlier revision" node_history: "node history" - detail_title: "title \"%{from}\" → \"%{to}\"" + detail_title: "title “%{from}” → “%{to}”" detail_author: "author %{from} → %{to}" detail_tags: "tags %{from} → %{to}" template_changed: "template changed" - assets_changed: "attached images changed" + assets_changed: "attachments changed" trash: "%{actor} moved %{subject} to the Trash (was at %{from})" restore_from_trash: "%{actor} restored %{subject} from the Trash to %{to}" destroy: "%{actor} permanently deleted %{subject} (last at %{path})" @@ -167,9 +167,9 @@ en: asset_create: "%{actor} uploaded asset “%{asset}”" asset_attach: "%{actor} attached “%{asset}” to %{subject}" asset_attach_headline: "%{actor} attached “%{asset}” to %{subject} as its headline" - detail_assets_added: "Attachments added: %{names}" - detail_assets_removed: "Attachments removed: %{names}" - assets_reordered: "Attachments reordered" + detail_assets_added: "attachments added: %{names}" + detail_assets_removed: "attachments removed: %{names}" + assets_reordered: "attachments reordered" otp_enroll: "%{actor} set up a second factor" otp_disable: "%{actor} removed their second factor" otp_reset: "%{actor} reset the second factor of %{target}" @@ -178,6 +178,21 @@ en: user_reactivate: "%{actor} reactivated %{target}" redaktion_grant: "%{actor} added %{target} to Redaktion" redaktion_revoke: "%{actor} removed %{target} from Redaktion" + event_create: "%{actor} added the event %{event}" + event_create_on: "%{actor} added the event %{event} to %{subject}" + event_update: "%{actor} changed the event %{event}" + event_update_on: "%{actor} changed the event %{event} on %{subject}" + event_destroy: "%{actor} deleted the event %{event}" + event_destroy_on: "%{actor} deleted the event %{event} from %{subject}" + detail_event_schedule: "schedule %{from} → %{to}" + detail_event_location: "location %{from} → %{to}" + detail_event_url: "link %{from} → %{to}" + detail_event_moved: "node %{from} → %{to}" + detail_event_description: "description changed" + detail_event_coordinates: "coordinates changed" + detail_event_allday: "all-day %{from} → %{to}" + event_none: "—" + unknown_event: "an event" open_gallery: "Open gallery" 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 @cal_node.publish_draft! @cal_node.head.reload end + + def event_entries + NodeAction.where(:action => %w[event_create event_update event_destroy]).order(:id) + end test 'verfy setup data' do assert_not_nil @cal_node @@ -107,4 +111,71 @@ class EventTest < ActiveSupport::TestCase chaosradio_days = scoped_occurrences.map {|x| x.start_time.day} assert_equal expected_days, chaosradio_days end + + test "creating an event is witnessed with a full snapshot" do + event = Event.new(:title => "Chaostreff", + :start_time => Time.utc(2026, 9, 1, 19, 0), + :end_time => Time.utc(2026, 9, 1, 21, 0), + :rrule => "FREQ=WEEKLY;BYDAY=TU", :location => "Zentrale", + :tag_list => "open-day") + + assert_difference -> { event_entries.count }, 1 do + assert event.save_witnessed(:actor => users(:aaron)) + end + + entry = event_entries.last + assert_equal "event_create", entry.action + assert_equal users(:aaron).id, entry.user_id + assert_equal "Chaostreff", entry.metadata["event_title"] + assert_equal "FREQ=WEEKLY;BYDAY=TU", entry.metadata["rrule"] + assert_equal ["open-day"], entry.metadata["event_tags"] + assert_equal event.id, entry.action_participants.first.subject_id + assert_equal "Event", entry.action_participants.first.subject_type + end + + test "updating an event records only what changed" do + event = Event.new(:title => "Chaostreff", :location => "Zentrale") + event.save_witnessed(:actor => users(:aaron)) + + assert_difference -> { event_entries.count }, 1 do + assert event.update_witnessed({ :location => "Neue Zentrale" }, :actor => users(:aaron)) + end + + entry = event_entries.last + assert_equal "event_update", entry.action + assert_equal({ "from" => "Zentrale", "to" => "Neue Zentrale" }, + entry.metadata.dig("changes", "location")) + assert_nil entry.metadata.dig("changes", "title") + assert_equal "Neue Zentrale", entry.metadata["location"] + end + + test "an update that changes nothing records no entry" do + event = Event.new(:title => "Chaostreff") + event.save_witnessed(:actor => users(:aaron)) + + assert_no_difference -> { event_entries.count } do + assert event.update_witnessed({ :title => "Chaostreff" }, :actor => users(:aaron)) + end + end + + test "deleting an event is witnessed before the row goes" do + event = Event.new(:title => "Chaostreff", :location => "Zentrale") + event.save_witnessed(:actor => users(:aaron)) + + assert_difference -> { event_entries.count }, 1 do + assert event.destroy_witnessed(:actor => users(:aaron)) + end + + entry = event_entries.last + assert_equal "event_destroy", entry.action + assert_equal "Chaostreff", entry.metadata["event_title"] + assert_equal "Zentrale", entry.metadata["location"] + assert_nil Event.find_by(:id => event.id) + end + + test "an event may start without ending" do + event = Event.new(:title => "Chaostreff", :start_time => Time.utc(2026, 9, 1, 19, 0)) + assert event.save + assert_equal event.start_time, event.occurrences.first&.start_time + end 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 assert_includes verb_icon(entry("publish", { "via" => "revision" })), "node_action_icon--history" assert_includes verb_icon(entry("frobnicate")), "node_action_icon--circle-dashed" end + + test "an event entry renders after its event is gone" do + event = Event.new(:title => "Chaostreff", :rrule => "FREQ=WEEKLY;BYDAY=TU", + :start_time => Time.utc(2026, 9, 1, 19, 0), + :end_time => Time.utc(2026, 9, 1, 21, 0)) + event.save_witnessed(:actor => users(:aaron)) + event.destroy_witnessed(:actor => users(:aaron)) + + entry = NodeAction.where(:action => "event_destroy").order(:id).last + + assert_match "Chaostreff", action_summary(entry) + assert_nothing_raised { event_changes_list(entry) } + end end -- cgit v1.3