summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/action_participant.rb4
-rw-r--r--app/models/asset.rb33
-rw-r--r--app/models/asset_license.rb15
-rw-r--r--app/models/concerns/file_attachment.rb52
-rw-r--r--app/models/node.rb89
-rw-r--r--app/models/node_action.rb27
-rw-r--r--app/models/page.rb26
-rw-r--r--app/models/related_asset.rb9
8 files changed, 206 insertions, 49 deletions
diff --git a/app/models/action_participant.rb b/app/models/action_participant.rb
new file mode 100644
index 0000000..f179a48
--- /dev/null
+++ b/app/models/action_participant.rb
@@ -0,0 +1,4 @@
1class ActionParticipant < ApplicationRecord
2 belongs_to :node_action
3 belongs_to :subject, :polymorphic => true
4end
diff --git a/app/models/asset.rb b/app/models/asset.rb
index aca0ee8..73970e2 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -1,12 +1,43 @@
1class Asset < ApplicationRecord 1class Asset < ApplicationRecord
2 IMAGE_CONTENT_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"]
3 PDF_CONTENT_TYPE = "application/pdf"
2 4
3 include FileAttachment 5 include FileAttachment
4 6
5 has_many :related_assets, :dependent => :destroy 7 has_many :related_assets, :dependent => :destroy
6 has_many :pages, :through => :related_assets 8 has_many :pages, :through => :related_assets
7 9
8 scope :images, -> { where(:upload_content_type => ["image/gif", "image/jpeg", "image/png", "image/webp"]) } 10 scope :images, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES) }
9 scope :documents, -> { where(:upload_content_type => ["application/pdf", "text/plain", "text/rtf"]) } 11 scope :documents, -> { where(:upload_content_type => ["application/pdf", "text/plain", "text/rtf"]) }
10 scope :audio, -> { where(:upload_content_type => ["audio/mpeg", "audio/x-m4a", "audio/wav", "audio/x-wav"]) } 12 scope :audio, -> { where(:upload_content_type => ["audio/mpeg", "audio/x-m4a", "audio/wav", "audio/x-wav"]) }
13 scope :pdfs, -> { where(:upload_content_type => PDF_CONTENT_TYPE) }
11 14
15 scope :headline_eligible, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES + [PDF_CONTENT_TYPE]) }
16
17 validates :license_key, inclusion: { in: -> { AssetLicense.keys } }, allow_blank: true
18
19 def image?
20 IMAGE_CONTENT_TYPES.include?(upload_content_type)
21 end
22
23 def pdf?
24 upload_content_type == PDF_CONTENT_TYPE
25 end
26
27 def has_credit?
28 creator.present? || source_url.present? || license_key.present?
29 end
30
31 def show_credit?
32 image? && has_credit?
33 end
34
35 # Nodes whose current lifecycle rows (head, draft or autosave) carry
36 # this asset. Historical revisions also hold RelatedAsset rows; they
37 # are excluded by construction here, since nothing points at them.
38 def attached_nodes
39 page_ids = related_assets.select(:page_id)
40 Node.where("head_id IN (:ids) OR draft_id IN (:ids) OR autosave_id IN (:ids)",
41 :ids => page_ids).distinct
42 end
12end 43end
diff --git a/app/models/asset_license.rb b/app/models/asset_license.rb
new file mode 100644
index 0000000..ca66d40
--- /dev/null
+++ b/app/models/asset_license.rb
@@ -0,0 +1,15 @@
1class AssetLicense
2 Entry = Struct.new(:key, :url, :requires_attribution, :style, keyword_init: true)
3
4 DICTIONARY = YAML.load_file(Rails.root.join("config", "asset_licenses.yml")).freeze
5
6 def self.keys
7 DICTIONARY.keys
8 end
9
10 def self.find(key)
11 entry = DICTIONARY[key.to_s] if key.present?
12 return nil unless entry
13 Entry.new(key: key.to_s, url: entry["url"], requires_attribution: entry["requires_attribution"], style: entry["style"])
14 end
15end
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index 3c09456..6221312 100644
--- a/app/models/concerns/file_attachment.rb
+++ b/app/models/concerns/file_attachment.rb
@@ -31,6 +31,7 @@ module FileAttachment
31 31
32 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze 32 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze
33 VECTOR_CONTENT_TYPES = %w[image/svg+xml].freeze 33 VECTOR_CONTENT_TYPES = %w[image/svg+xml].freeze
34 RASTERIZED_CONTENT_TYPES = %w[application/pdf].freeze
34 DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES 35 DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES
35 36
36 included do 37 included do
@@ -52,29 +53,49 @@ module FileAttachment
52 build_upload_proxy 53 build_upload_proxy
53 end 54 end
54 55
56 def has_variant?(style)
57 upload_file_name.present? && File.exist?(file_path(style))
58 end
59
60 def previewable?
61 has_variant?(:medium)
62 end
63
64 def variant_filename(style)
65 return upload_file_name if style == :original || !RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
66 File.basename(upload_file_name, ".*") + ".png"
67 end
68
55 private 69 private
56 70
57 def build_upload_proxy 71 def build_upload_proxy
58 @upload = UploadProxy.new(self) 72 @upload = UploadProxy.new(self)
59 end 73 end
60 74
75 class_methods do
76 def upload_root
77 Rails.env.test? ? Rails.root.join("tmp", "test_uploads") : Rails.root.join("public", "system", "uploads")
78 end
79 end
80
81 def upload_root
82 self.class.upload_root
83 end
84
61 def process_upload 85 def process_upload
62 return unless @pending_upload 86 return unless @pending_upload
63 uploaded_file = @pending_upload 87 uploaded_file = @pending_upload
64 @pending_upload = nil 88 @pending_upload = nil
65 89
66 old_dir = Rails.root.join("public", "system", "uploads", id.to_s) 90 old_dir = upload_root.join(id.to_s)
91
67 FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir) 92 FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir)
68 93
69 original_path = file_path(:original) 94 original_path = file_path(:original)
70 FileUtils.mkdir_p(File.dirname(original_path)) 95 FileUtils.mkdir_p(File.dirname(original_path))
71 FileUtils.cp(uploaded_file.tempfile.path, original_path) 96 FileUtils.cp(uploaded_file.tempfile.path, original_path)
72 97
73 if IMAGE_CONTENT_TYPES.include?(upload_content_type) 98 generate_all_variants(original_path)
74 generate_variants(original_path)
75 elsif VECTOR_CONTENT_TYPES.include?(upload_content_type)
76 generate_svg_variants(original_path)
77 end
78 end 99 end
79 100
80 def generate_variants(original_path) 101 def generate_variants(original_path)
@@ -93,16 +114,23 @@ module FileAttachment
93 end 114 end
94 end 115 end
95 116
117 def generate_all_variants(original_path)
118 if IMAGE_CONTENT_TYPES.include?(upload_content_type)
119 generate_variants(original_path)
120 elsif VECTOR_CONTENT_TYPES.include?(upload_content_type)
121 generate_svg_variants(original_path)
122 elsif RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
123 generate_variants("#{original_path}[0]")
124 end
125 end
126
96 def delete_upload_files 127 def delete_upload_files
97 dir = Rails.root.join("public", "system", "uploads", id.to_s) 128 dir = upload_root.join(id.to_s)
98 FileUtils.rm_rf(dir) if Dir.exist?(dir) 129 FileUtils.rm_rf(dir) if Dir.exist?(dir)
99 end 130 end
100 131
101 def file_path(style) 132 def file_path(style)
102 Rails.root.join( 133 upload_root.join(id.to_s, style.to_s, variant_filename(style)).to_s
103 "public", "system", "uploads",
104 id.to_s, style.to_s, upload_file_name
105 ).to_s
106 end 134 end
107 135
108 def sanitize_filename(filename) 136 def sanitize_filename(filename)
@@ -119,7 +147,7 @@ module FileAttachment
119 147
120 def url(style = :original) 148 def url(style = :original)
121 return "" if @record.upload_file_name.blank? 149 return "" if @record.upload_file_name.blank?
122 "/system/uploads/#{@record.id}/#{style}/#{@record.upload_file_name}" 150 "/system/uploads/#{@record.id}/#{style}/#{@record.variant_filename(style)}"
123 end 151 end
124 152
125 def content_type 153 def content_type
diff --git a/app/models/node.rb b/app/models/node.rb
index a440c2f..7a93e79 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -4,7 +4,14 @@ class Node < ApplicationRecord
4 4
5 # Associations 5 # Associations
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7
8 # Entries where this node is the primary subject (fast-path column).
9 # For a *complete* history -- including subtree trash/destroy entries
10 # recorded at an ancestor -- use participated_actions instead.
7 has_many :node_actions, :dependent => :nullify 11 has_many :node_actions, :dependent => :nullify
12 has_many :action_participations, :class_name => "ActionParticipant", :as => :subject
13 has_many :participated_actions, :through => :action_participations, :source => :node_action
14
8 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true 15 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
9 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true 16 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
10 # Autosave pages carry no node_id, so has_many :pages does not cover 17 # Autosave pages carry no node_id, so has_many :pages does not cover
@@ -312,14 +319,14 @@ class Node < ApplicationRecord
312 was_published = head_id.present? 319 was_published = head_id.present?
313 final_published_at = head&.published_at 320 final_published_at = head&.published_at
314 321
315 demoted = 0 322 subtree = [self] + descendants.to_a
316 ([self] + descendants.to_a).each do |node| 323 demoted_nodes = subtree.select do |node|
317 next unless node.head_id 324 next false unless node.head_id
318 former = node.head 325 former = node.head
319 node.head = nil 326 node.head = nil
320 node.draft_id = former.id if node.draft_id.nil? 327 node.draft_id = former.id if node.draft_id.nil?
321 node.save! 328 node.save!
322 demoted += 1 329 true
323 end 330 end
324 331
325 self.reload 332 self.reload
@@ -332,9 +339,10 @@ class Node < ApplicationRecord
332 metadata = { :path => { "from" => path_before, "to" => unique_name } } 339 metadata = { :path => { "from" => path_before, "to" => unique_name } }
333 metadata[:was_published] = true if was_published 340 metadata[:was_published] = true if was_published
334 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at 341 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at
335 metadata[:demoted_heads] = demoted if demoted > 0 342 metadata[:demoted_heads] = demoted_nodes.size if demoted_nodes.any?
336 343
337 NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata) 344 NodeAction.record!(:participants => subtree, :user => current_user,
345 :action => "trash", **metadata)
338 self 346 self
339 end 347 end
340 end 348 end
@@ -382,7 +390,8 @@ class Node < ApplicationRecord
382 metadata = { :path => unique_name } 390 metadata = { :path => unique_name }
383 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1 391 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1
384 392
385 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata) 393 NodeAction.record!(:participants => doomed, :user => current_user,
394 :action => "destroy", **metadata)
386 doomed.each(&:destroy!) 395 doomed.each(&:destroy!)
387 end 396 end
388 end 397 end
@@ -442,8 +451,65 @@ class Node < ApplicationRecord
442 end 451 end
443 end 452 end
444 453
454 # Attaches an asset to every current lifecycle row -- head, draft and
455 # autosave -- that does not already carry it. Attachments are page-
456 # scoped content (RelatedAsset belongs_to :page; drafts and autosaves
457 # are wholesale clones), so attaching to a single layer is how an
458 # attachment gets silently lost when another layer replaces it at
459 # publish or save. This is the out-of-band counterpart to the
460 # in-editor attach UI; it refuses when someone else holds the editing
461 # lock. Attaching to a head row changes the public page immediately,
462 # by design -- same reasoning as formalizing an already-existing
463 # editorial link.
464 #
465 # headline is a node-level decision: the flag is set on the newly
466 # created joins only when no current row has a headline yet and the
467 # asset is eligible; otherwise the asset is attached plain and the
468 # result says why, so the caller can point the editor at the star in
469 # the editor instead.
470 #
471 # Returns { :attached => n, :already => n,
472 # :headline => nil | :set | :kept_existing | :not_eligible }
473 def attach_asset! asset, user:, headline: false
474 if in_trash? || trash_node?
475 raise ActiveRecord::RecordInvalid.new(self), "Cannot attach assets to a node in the Trash"
476 end
477
478 if lock_owner && lock_owner != user
479 raise(
480 LockedByAnotherUser,
481 "Page is locked by another user who is working on it! " \
482 "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
483 )
484 end
485
486 rows = [head, draft, autosave].compact
487 to_attach = rows.reject { |row| row.related_assets.exists?(:asset_id => asset.id) }
488
489 headline_state =
490 if headline && to_attach.any?
491 if !(asset.image? || asset.pdf?)
492 :not_eligible
493 elsif rows.any? { |row| row.headline_asset.present? }
494 :kept_existing
495 else
496 :set
497 end
498 end
499
500 ActiveRecord::Base.transaction do
501 to_attach.each do |row|
502 row.related_assets.create!(:asset => asset, :headline => headline_state == :set)
503 end
504 end
505
506 { :attached => to_attach.size,
507 :already => rows.size - to_attach.size,
508 :headline => headline_state }
509 end
510
445 def title 511 def title
446 head ? head.title : draft.title 512 editable_page&.title
447 end 513 end
448 514
449 def update_unique_names? 515 def update_unique_names?
@@ -533,13 +599,6 @@ class Node < ApplicationRecord
533 throw :abort 599 throw :abort
534 end 600 end
535 601
536 def self.recently_changed
537 includes(:head).where(
538 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
539 Time.now, Time.now - 14.days
540 ).order("pages.updated_at desc").references(:head)
541 end
542
543 protected 602 protected
544 def lock_for! current_user 603 def lock_for! current_user
545 self.lock_owner = current_user 604 self.lock_owner = current_user
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 13bd5ba..8a3dd8b 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -3,6 +3,8 @@ class NodeAction < ApplicationRecord
3 belongs_to :page, optional: true 3 belongs_to :page, optional: true
4 belongs_to :user, optional: true 4 belongs_to :user, optional: true
5 5
6 has_many :action_participants, :dependent => :destroy
7
6 validates :action, presence: true 8 validates :action, presence: true
7 validates :occurred_at, presence: true 9 validates :occurred_at, presence: true
8 10
@@ -81,10 +83,15 @@ class NodeAction < ApplicationRecord
81 # This log records; it does not undo. No IP, session, or user 83 # This log records; it does not undo. No IP, session, or user
82 # agent, ever. Success only. 84 # agent, ever. Success only.
83 85
84 def self.record!(node:, action:, user: nil, page: nil, locale: nil, 86 def self.record!(node: nil, participants: [], action:, user: nil, page: nil,
85 occurred_at: nil, inferred_from: nil, **extra) 87 locale: nil, occurred_at: nil, inferred_from: nil, **extra)
88 participants = participants.presence || [node].compact
89 raise ArgumentError, "NodeAction.record! needs at least one participant" if participants.empty?
90
91 primary_node = node || (participants.first if participants.first.is_a?(Node))
92
86 create!( 93 create!(
87 :node => node, 94 :node => primary_node,
88 :page => page, 95 :page => page,
89 :user => user, 96 :user => user,
90 :action => action, 97 :action => action,
@@ -94,10 +101,12 @@ class NodeAction < ApplicationRecord
94 :metadata => { 101 :metadata => {
95 "username" => user&.login, 102 "username" => user&.login,
96 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { 103 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
97 node&.head&.title || node&.draft&.title 104 primary_node&.head&.title || primary_node&.draft&.title
98 }, 105 },
99 }.merge(extra.stringify_keys) 106 }.merge(extra.stringify_keys)
100 ) 107 ).tap do |na|
108 participants.each { |subject| na.action_participants.create!(:subject => subject) }
109 end
101 end 110 end
102 111
103 # Computes the publish-entry diff between an outgoing head and the 112 # Computes the publish-entry diff between an outgoing head and the
@@ -166,4 +175,12 @@ class NodeAction < ApplicationRecord
166 def subject_name 175 def subject_name
167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node" 176 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
168 end 177 end
178
179 def diff_link_params
180 prev = NodeAction.where(node_id: node_id, action: "publish")
181 .where("id < ?", id)
182 .order(id: :desc).first
183 return nil unless prev&.page && page
184 { start_revision: prev.page.revision, end_revision: page.revision }
185 end
169end 186end
diff --git a/app/models/page.rb b/app/models/page.rb
index f33d88d..817e3bd 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -220,7 +220,12 @@ class Page < ApplicationRecord
220 end 220 end
221 221
222 # Clone asset references 222 # Clone asset references
223 self.assets = page.assets 223 self.related_assets.delete_all
224 page.related_assets.each do |related|
225 self.related_assets.create!(:asset_id => related.asset_id,
226 :position => related.position,
227 :headline => related.headline)
228 end
224 229
225 self.save 230 self.save
226 end 231 end
@@ -287,6 +292,10 @@ class Page < ApplicationRecord
287 end 292 end
288 end 293 end
289 294
295 def headline_asset
296 related_assets.find_by(headline: true)&.asset
297 end
298
290 # Returns true if a page has translations where one of them is significantly 299 # Returns true if a page has translations where one of them is significantly
291 # older than the other. 300 # older than the other.
292 # Takes the I18n.default locale and a second :locale to test if the 301 # Takes the I18n.default locale and a second :locale to test if the
@@ -314,21 +323,6 @@ class Page < ApplicationRecord
314 end 323 end
315 end 324 end
316 325
317 def update_assets image_ids
318
319 transaction do
320 self.related_assets.delete_all
321
322 if image_ids
323 image_ids.each_with_index do |id, index|
324 asset = Asset.find(id)
325 self.related_assets.create!(:asset_id => asset.id, :position => index+1)
326 end
327 end
328 end
329
330 end
331
332 # Installs (or re-installs) the trigger that keeps page_translations' 326 # Installs (or re-installs) the trigger that keeps page_translations'
333 # search_vector in sync. Idempotent, safe to call on every boot. 327 # search_vector in sync. Idempotent, safe to call on every boot.
334 # search_vector is populated by a raw Postgres trigger, not anything 328 # search_vector is populated by a raw Postgres trigger, not anything
diff --git a/app/models/related_asset.rb b/app/models/related_asset.rb
index 8f16460..8f8d49c 100644
--- a/app/models/related_asset.rb
+++ b/app/models/related_asset.rb
@@ -5,4 +5,13 @@ class RelatedAsset < ApplicationRecord
5 acts_as_list :scope => :page_id 5 acts_as_list :scope => :page_id
6 6
7 default_scope -> { order("position ASC") } 7 default_scope -> { order("position ASC") }
8
9 validate :headline_only_for_images
10
11 private
12
13 def headline_only_for_images
14 return unless asset
15 errors.add(:headline, "can only be set on image or PDF assets") if headline? && !(asset.image? || asset.pdf?)
16 end
8end 17end