summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTING.md89
-rw-r--r--app/controllers/nodes_controller.rb2
-rw-r--r--app/controllers/page_translations_controller.rb3
-rw-r--r--app/models/node.rb65
-rw-r--r--app/models/node_action.rb33
-rw-r--r--app/views/admin/_recent_changes.html.erb24
-rw-r--r--app/views/admin/index.html.erb12
-rw-r--r--app/views/nodes/_recent_change_item.html.erb9
-rw-r--r--app/views/nodes/recent.html.erb6
-rw-r--r--config/locales/de.yml2
-rw-r--r--config/locales/en.yml2
-rw-r--r--db/migrate/20260714233414_create_node_actions.rb19
-rw-r--r--public/stylesheets/admin.css19
-rw-r--r--test/controllers/page_translations_controller_test.rb15
-rw-r--r--test/models/node_test.rb83
15 files changed, 318 insertions, 65 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c26956c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,89 @@
1# Contributing to CCCMS
2
3## Stack
4Rails 8.1, Ruby 3.2, PostgreSQL 16, ImageMagick 7 (`magick`, not `convert`).
5No Sprockets pipeline for public or admin static assets except
6`admin_bundle.js` — everything else is served straight from `public/` and
7cache-busted via file mtime (`ApplicationHelper#mtime_busted_path`), not
8fingerprinted content hashing.
9
10## Content model
11- **Node** — the tree structure (`awesome_nested_set`-derived, migrating
12 toward a plain `parent_id` model — both may be present during that
13 transition). Holds three separate Page references: `head` (published),
14 `draft` (being worked on), `autosave` (in-progress typing).
15- **Page** — one revision's content. `belongs_to :node`, except `autosave`
16 layers specifically: their `node_id` is deliberately `nil`, which is what
17 keeps them out of `node.pages` (the real revision history) entirely —
18 an autosave is not a revision.
19- **Page::Translation** — Globalize's own generated class (`translates
20 :title, :abstract, :body` on `Page`). Not a top-level `PageTranslation`
21 constant, and it has `page_id`, not a `page` association reader.
22
23## Publishing lifecycle
24There is exactly one path from an edit to a published change:
25`autosave!` (writes into the autosave layer, creating it via
26`clone_attributes_from` on the previous head/draft if it doesn't exist yet)
27→ `save_draft!` (clones the *entire* autosave into the draft layer,
28wholesale — every locale, not just the one just edited) → `publish_draft!`
29(promotes draft to head). Nothing writes to `draft` or `head` directly.
30Locking (`lock_for_editing!`, raising `LockedByAnotherUser`) gates every
31step; a lock can exist with no draft or autosave at all (acquisition is
32deliberately separate from content creation).
33
34## Locale architecture
35Presentation locale (which language the admin chrome or public page
36renders in) and content-target locale (which translation a given screen is
37reading or writing) are two different concerns and never share a
38mechanism. The route's `:locale` segment governs the former only. The
39primary node editor always targets the default locale, enforced via
40`Globalize.with_locale` (never `I18n.locale`, which would leak into
41generated URLs through `default_url_options`). Non-default-locale editing
42uses a separately-named route param (`:translation_locale`), deliberately
43distinct from `:locale` so the two can never be conflated by a link helper.
44Globalize's configured fallback chain is correct for public rendering and
45wrong for editing — anywhere a screen needs to know a translation's real,
46unborrowed state, read the `Page::Translation` row directly rather than the
47locale-dependent attribute accessor.
48
49## Assets
50`FileAttachment` (`app/models/concerns/file_attachment.rb`) is a from-
51scratch Paperclip replacement. `STYLES` is a hash of style name to a full
52ImageMagick argument array (not just a geometry string — some styles need
53`-gravity`/`-extent` alongside `-resize`, which a single string can't
54express). `generate_variants` loops over `STYLES` generically; adding a
55style needs no other code change, only `bundle exec rake
56assets:regenerate_variants` to backfill existing assets. The URL contract
57(`/system/uploads/:id/:style/:filename`) is stable and safe to parse
58directly (the numeric id is always the second path segment) rather than
59reconstructed through model methods.
60
61## Aggregator / shortcode system
62`[aggregate ...]` in page body text is parsed by
63`ContentHelper#aggregate?` into an options hash, then executed by
64`Page.aggregate`. Only `tags`, `children` (`"direct"`/`"all"`, combined
65with the current node), `order_by`/`order_direction`, and `limit` are
66actually consumed. Only the first `[aggregate ...]` in a given body is
67ever processed (`aggregate?` does a single, non-global match).
68
69## TinyMCE integration
70`extended_valid_elements` must define a parent element and its commonly-
71nested children symmetrically and explicitly together — leaving one
72element on the schema's default rule while the other is explicitly
73redefined can cause content loss on save, even when the child would be
74perfectly valid under the untouched default alone. `valid_classes` denies
75all classes by default (`'*': ''`); anything meant to render needs an
76explicit per-element allowance. `content_style` injects CSS into the
77editing iframe directly — the app's real public stylesheet is never loaded
78there, so anything that needs to render correctly while editing needs its
79own rule supplied this way. Constrained-input features (fixed placement
80choices, for instance) are built as custom toolbar buttons with their own
81dialog and `editor.insertContent()`, not the native image dialog or
82`file_picker_callback` — those bring their own resize/edit affordances that
83would undermine a genuinely fixed set of choices.
84
85## Testing
86Shared test-only convenience methods (that need to operate across many
87model/controller test files) live directly on `ActiveSupport::TestCase`,
88in `test/test_helper.rb` — the framework's own designated extension point
89for exactly this, not a monkey-patch of a production model class.
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index bff1733..9c5d228 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -147,7 +147,7 @@ class NodesController < ApplicationController
147 end 147 end
148 148
149 def publish 149 def publish
150 @node.publish_draft! 150 @node.publish_draft!(current_user)
151 flash[:notice] = "Draft has been published" 151 flash[:notice] = "Draft has been published"
152 redirect_to node_path(@node) 152 redirect_to node_path(@node)
153 end 153 end
diff --git a/app/controllers/page_translations_controller.rb b/app/controllers/page_translations_controller.rb
index 38a7c4f..be4f488 100644
--- a/app/controllers/page_translations_controller.rb
+++ b/app/controllers/page_translations_controller.rb
@@ -63,6 +63,9 @@ class PageTranslationsController < ApplicationController
63 draft.translations.where(:locale => @locale).delete_all 63 draft.translations.where(:locale => @locale).delete_all
64 draft.reload 64 draft.reload
65 65
66 NodeAction.record!(:node => @node, :page => draft, :user => current_user,
67 :action => "translation_destroy", :locale => @locale)
68
66 flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent." 69 flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent."
67 redirect_to node_path(@node) 70 redirect_to node_path(@node)
68 rescue LockedByAnotherUser => e 71 rescue LockedByAnotherUser => e
diff --git a/app/models/node.rb b/app/models/node.rb
index 311c5c0..b41df06 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -170,10 +170,12 @@ class Node < ApplicationRecord
170 self.autosave.destroy 170 self.autosave.destroy
171 self.autosave_id = nil 171 self.autosave_id = nil
172 self.save! 172 self.save!
173 NodeAction.record!(:node => self, :user => current_user, :action => "discard_autosave")
173 elsif self.draft && self.head 174 elsif self.draft && self.head
174 self.draft.destroy 175 self.draft.destroy
175 self.draft_id = nil 176 self.draft_id = nil
176 self.save! 177 self.save!
178 NodeAction.record!(:node => self, :user => current_user, :action => "destroy_draft")
177 end 179 end
178 180
179 self.unlock! unless self.draft 181 self.unlock! unless self.draft
@@ -188,43 +190,50 @@ class Node < ApplicationRecord
188 end 190 end
189 end 191 end
190 192
191 def publish_draft! 193 def publish_draft! current_user = nil
192 # Return nil if nothing to publish and no staged changes 194 # Return nil if nothing to publish and no staged changes
193 return nil unless self.draft || staged_slug || staged_parent_id 195 return nil unless self.draft || staged_slug || staged_parent_id
194 196
195 if self.draft 197 ActiveRecord::Base.transaction do
196 self.head = self.draft 198 if self.draft
197 self.head.published_at ||= Time.now 199 previous_title = self.head ? Globalize.with_locale(I18n.default_locale) { self.head.title } : nil
198 self.head.save! 200 self.head = self.draft
199 self.draft = nil 201 self.head.published_at ||= Time.now
200 end 202 self.head.save!
201 203 self.draft = nil
202 if staged_slug && (staged_slug != slug)
203 self.slug = staged_slug
204 self.staged_slug = nil
205 end
206 204
207 if staged_parent_id && (staged_parent_id != parent_id) 205 new_title = Globalize.with_locale(I18n.default_locale) { self.head.title }
208 new_parent = Node.find(staged_parent_id) 206 NodeAction.record!(:node => self, :page => self.head, :user => current_user,
207 :action => "publish", :from => previous_title, :to => new_title)
208 end
209 209
210 if new_parent == self || self.descendants.include?(new_parent) 210 if staged_slug && (staged_slug != slug)
211 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" 211 self.slug = staged_slug
212 self.staged_slug = nil
212 end 213 end
213 214
214 self.staged_parent_id = nil 215 if staged_parent_id && (staged_parent_id != parent_id)
215 self.save! 216 new_parent = Node.find(staged_parent_id)
216 self.move_to_child_of(new_parent) 217
217 else 218 if new_parent == self || self.descendants.include?(new_parent)
218 unless self.save 219 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants"
219 raise ActiveRecord::RecordInvalid.new(self) 220 end
221
222 self.staged_parent_id = nil
223 self.save!
224 self.move_to_child_of(new_parent)
225 else
226 unless self.save
227 raise ActiveRecord::RecordInvalid.new(self)
228 end
220 end 229 end
221 end
222 230
223 self.reload 231 self.reload
224 self.update_unique_name 232 self.update_unique_name
225 self.send(:update_unique_names_of_children) 233 self.send(:update_unique_names_of_children)
226 self.unlock! 234 self.unlock!
227 self 235 self
236 end
228 end 237 end
229 238
230 def restore_revision! revision 239 def restore_revision! revision
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
new file mode 100644
index 0000000..f32b5b7
--- /dev/null
+++ b/app/models/node_action.rb
@@ -0,0 +1,33 @@
1class NodeAction < ApplicationRecord
2 belongs_to :node, optional: true
3 belongs_to :page, optional: true
4 belongs_to :user, optional: true
5
6 validates :action, presence: true
7 validates :occurred_at, presence: true
8
9 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra)
10 create!(
11 :node => node,
12 :page => page,
13 :user => user,
14 :action => action,
15 :locale => locale,
16 :occurred_at => Time.now,
17 :metadata => {
18 "username" => user&.login,
19 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
20 node&.head&.title || node&.draft&.title
21 },
22 }.merge(extra.stringify_keys)
23 )
24 end
25
26 def actor_name
27 metadata["username"] || "unknown"
28 end
29
30 def subject_name
31 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
32 end
33end
diff --git a/app/views/admin/_recent_changes.html.erb b/app/views/admin/_recent_changes.html.erb
deleted file mode 100644
index 88b5e93..0000000
--- a/app/views/admin/_recent_changes.html.erb
+++ /dev/null
@@ -1,24 +0,0 @@
1<h2>Recent Changes</h2>
2<div id="draft_list">
3 <table>
4 <tr class="header">
5 <th>Title</th>
6 <th>path</th>
7 <th>user</th>
8 <th>date</th>
9 <th></th>
10 </tr>
11 <% @recent_changes.each do |node| %>
12 <tr>
13 <td><%= truncated_title_for_node node %></td>
14 <td><%= node.unique_name %></td>
15 <td><%= node.draft.user.login rescue "" %></td>
16 <td><%= node.updated_at.to_fs(:db) %></td>
17 <td class="actions">
18 <%= link_to 'Show', node_path(node) %>
19 <%= link_to "Revisions", revision_path(:id => node.id) %>
20 </td>
21 </tr>
22 <% end %>
23 </table>
24</div> \ No newline at end of file
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index bdb555e..a9c0512 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -43,17 +43,7 @@
43 <div class="dashboard_widget"> 43 <div class="dashboard_widget">
44 <h3>Recent changes</h3> 44 <h3>Recent changes</h3>
45 <ul> 45 <ul>
46 <% @recent_changes.each do |node| %> 46 <%= render partial: "nodes/recent_change_item", collection: @recent_changes, as: :node %>
47 <li>
48 <div>
49 <%= link_to title_for_node(node), node_path(node) %>
50 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
51 </div>
52 <span class="dashboard_widget_meta">
53 <%= (editor = node_head_editor(node)) ? t("published_by", editor: editor) : t("published") %>, <%= relative_time_phrase(node.head.updated_at) %>
54 </span>
55 </li>
56 <% end %>
57 </ul> 47 </ul>
58 <%= link_to "See all recent changes →", recent_nodes_path %> 48 <%= link_to "See all recent changes →", recent_nodes_path %>
59 </div> 49 </div>
diff --git a/app/views/nodes/_recent_change_item.html.erb b/app/views/nodes/_recent_change_item.html.erb
new file mode 100644
index 0000000..754a775
--- /dev/null
+++ b/app/views/nodes/_recent_change_item.html.erb
@@ -0,0 +1,9 @@
1<li>
2 <div>
3 <%= link_to title_for_node(node), node_path(node) %>
4 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
5 </div>
6 <span class="dashboard_widget_meta">
7 <%= (editor = node_head_editor(node)) ? t("last_edited_by", editor: editor) : t("last_edited") %>, <%= relative_time_phrase(node.head.updated_at) %>
8 </span>
9</li>
diff --git a/app/views/nodes/recent.html.erb b/app/views/nodes/recent.html.erb
index 3602775..d256253 100644
--- a/app/views/nodes/recent.html.erb
+++ b/app/views/nodes/recent.html.erb
@@ -1,3 +1,7 @@
1<h1>Recently changed</h1> 1<h1>Recently changed</h1>
2 2
3<%= render 'node_list' %> 3<%= will_paginate @nodes %>
4<ul id="recent_changes_full_list">
5 <%= render partial: "nodes/recent_change_item", collection: @nodes, as: :node %>
6</ul>
7<%= will_paginate @nodes %>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 550ccb3..74cd183 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -16,6 +16,8 @@ de:
16 open_today: "Das Chaos, lokal und heute offen" 16 open_today: "Das Chaos, lokal und heute offen"
17 published: "veröffentlicht" 17 published: "veröffentlicht"
18 published_by: "veröffentlicht durch %{editor}" 18 published_by: "veröffentlicht durch %{editor}"
19 last_edited: "zuletzt bearbeitet"
20 last_edited_by: "zuletzt bearbeitet durch %{editor}"
19 editor_self: "du" 21 editor_self: "du"
20 publisher_self: "dich" 22 publisher_self: "dich"
21 date: 23 date:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 73271ed..c52280d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -17,6 +17,8 @@ en:
17 open_today: "Open today" 17 open_today: "Open today"
18 published: "published" 18 published: "published"
19 published_by: "published by %{editor}" 19 published_by: "published by %{editor}"
20 last_edited: "last edited"
21 last_edited_by: "last edited by %{editor}"
20 publisher_self: "you" 22 publisher_self: "you"
21 editor_self: "you" 23 editor_self: "you"
22 24
diff --git a/db/migrate/20260714233414_create_node_actions.rb b/db/migrate/20260714233414_create_node_actions.rb
new file mode 100644
index 0000000..6c18285
--- /dev/null
+++ b/db/migrate/20260714233414_create_node_actions.rb
@@ -0,0 +1,19 @@
1class CreateNodeActions < ActiveRecord::Migration[8.1]
2 def change
3 create_table :node_actions do |t|
4 t.references :node, foreign_key: { on_delete: :nullify }
5 t.references :page, foreign_key: { on_delete: :nullify }
6 t.references :user, foreign_key: { on_delete: :nullify }
7 t.string :action, null: false
8 t.string :locale
9 t.string :inferred_from
10 t.jsonb :metadata, null: false
11 t.datetime :occurred_at, null: false
12
13 t.timestamps
14 end
15
16 add_index :node_actions, [:node_id, :occurred_at]
17 add_index :node_actions, :action
18 end
19end
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index b5b8707..5f0b165 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -1121,6 +1121,25 @@ div#draft_list table td.actions a {
1121 font-size: 0.9em; 1121 font-size: 0.9em;
1122} 1122}
1123 1123
1124#recent_changes_full_list {
1125 list-style: none;
1126 padding: 0;
1127 margin: 0;
1128}
1129
1130#recent_changes_full_list li {
1131 padding: 0.75rem 0;
1132 border-bottom: 1px solid #eee;
1133}
1134
1135#recent_changes_full_list li:last-child {
1136 border-bottom: none;
1137}
1138
1139#recent_changes_full_list li > div {
1140 margin-bottom: 0.25rem;
1141}
1142
1124/* ============================================================ 1143/* ============================================================
1125 Search widgets 1144 Search widgets
1126 ============================================================ */ 1145 ============================================================ */
diff --git a/test/controllers/page_translations_controller_test.rb b/test/controllers/page_translations_controller_test.rb
index 8fa732f..82f8bce 100644
--- a/test/controllers/page_translations_controller_test.rb
+++ b/test/controllers/page_translations_controller_test.rb
@@ -82,4 +82,19 @@ class PageTranslationsControllerTest < ActionController::TestCase
82 assert_equal page_count_before, node.pages.count 82 assert_equal page_count_before, node.pages.count
83 assert_equal "in progress", Globalize.with_locale(:en) { node.autosave.title } 83 assert_equal "in progress", Globalize.with_locale(:en) { node.autosave.title }
84 end 84 end
85
86 test "destroy logs a translation_destroy NodeAction" do
87 login_as :quentin
88 node = Node.root.children.create!(:slug => "translation_destroy_log_test")
89 Globalize.with_locale(:en) { node.draft.update!(:title => "English title") }
90
91 delete :destroy, params: { :node_id => node.id, :translation_locale => "en" }
92
93 action = NodeAction.last
94 assert_equal node, action.node
95 assert_equal "en", action.locale
96 assert_equal "translation_destroy", action.action
97 assert_equal users(:quentin), action.user
98 assert_redirected_to node_path(node)
99 end
85end 100end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index 959387d..022fff6 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -621,4 +621,87 @@ class NodeTest < ActiveSupport::TestCase
621 assert_equal "Edited directly on autosave", Globalize.with_locale(:en) { autosave.title } 621 assert_equal "Edited directly on autosave", Globalize.with_locale(:en) { autosave.title }
622 assert_equal "v2", Globalize.with_locale(:de) { autosave.title } 622 assert_equal "v2", Globalize.with_locale(:de) { autosave.title }
623 end 623 end
624
625 test "publish_draft! logs a NodeAction crediting the actual publisher" do
626 node = Node.root.children.create!(:slug => "publish_log_test")
627 node.draft.update!(:title => "Version one")
628
629 node.publish_draft!(@user1)
630
631 action = NodeAction.last
632 assert_equal node, action.node
633 assert_equal node.head, action.page
634 assert_equal @user1, action.user
635 assert_equal "publish", action.action
636 end
637
638 test "publish_draft! called with no user logs no actor, not a guessed one" do
639 node = Node.root.children.create!(:slug => "publish_log_no_user_test")
640 node.draft.update!(:title => "Version one")
641
642 node.publish_draft!
643
644 action = NodeAction.last
645 assert_nil action.user
646 assert_nil action.metadata["username"]
647 end
648
649 test "publish_draft! with nothing pending creates no NodeAction" do
650 node = Node.root.children.create!(:slug => "publish_log_noop_test")
651 node.publish_draft!
652 count_before = NodeAction.count
653
654 result = node.publish_draft!
655
656 assert_nil result
657 assert_equal count_before, NodeAction.count
658 end
659
660 test "revert! logs discard_autosave for an in-progress autosave" do
661 node = create_node_with_published_page
662 node.lock_for_editing!(@user1)
663 node.autosave!({:title => "in progress"}, @user1)
664
665 node.revert!(@user1)
666
667 action = NodeAction.last
668 assert_equal node, action.node
669 assert_equal @user1, action.user
670 assert_equal "discard_autosave", action.action
671 end
672
673 test "revert! logs destroy_draft for a draft with a head behind it" do
674 node = create_node_with_published_page
675 find_or_create_draft(node, @user1)
676
677 node.revert!(@user1)
678
679 action = NodeAction.last
680 assert_equal node, action.node
681 assert_equal @user1, action.user
682 assert_equal "destroy_draft", action.action
683 end
684
685 test "revert! with nothing to revert logs nothing" do
686 node = create_node_with_published_page
687 node.lock_for_editing!(@user1)
688 count_before = NodeAction.count
689
690 node.revert!(@user1)
691
692 assert_equal count_before, NodeAction.count
693 end
694
695 test "publish_draft! records the title's from/to in metadata" do
696 node = create_node_with_published_page
697 Globalize.with_locale(:de) { node.head.update!(:title => "Original Title") }
698 find_or_create_draft(node, @user1)
699 Globalize.with_locale(:de) { node.draft.update!(:title => "New Title") }
700
701 node.publish_draft!(@user1)
702
703 action = NodeAction.last
704 assert_equal "Original Title", action.metadata["from"]
705 assert_equal "New Title", action.metadata["to"]
706 end
624end 707end