Class: ArticleRevision
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- ArticleRevision
- Includes:
- Models::Auditable
- Defined in:
- app/models/article_revision.rb
Overview
== Schema Information
Table name: article_revisions
Database name: primary
id :bigint not null, primary key
change_notes :text
description :text
article_data :jsonb not null
base_content_digest :string
has_toc :boolean default(FALSE), not null
inline_js :text
meta_description :string
meta_keywords :string
revision_number :integer default(1), not null
solution :text
subject :string not null
title :string
toc_selector :string
created_at :datetime not null
updated_at :datetime not null
article_id :bigint not null
author_id :bigint
review_assignee_id :bigint
reviewer_id :bigint
review_state :string default("recorded"), not null
review_notes :text
reviewed_at :datetime
submitted_at :datetime
Indexes
index_article_revisions_on_article_id_and_revision_number (article_id,revision_number) UNIQUE
index_article_revisions_on_author_id (author_id)
Foreign Keys
fk_rails_... (article_id => articles.id)
Defined Under Namespace
Classes: Approval
Constant Summary collapse
- TECHNICAL_SUPPORT_REVIEWER_ROLES =
Technical support reviewer roles.
%w[technical_support_rep technical_support_manager].freeze
- CONTENT_FIELDS =
Content fields that are stored in revisions
%i[ subject title solution description meta_description meta_keywords inline_js has_toc toc_selector ].freeze
- EDITABLE_FIELDS =
Editable fields.
(CONTENT_FIELDS + %i[change_notes review_assignee_id]).freeze
- ARTICLE_ATTRIBUTE_FIELDS =
Article attribute fields.
%i[ department objective problem_code warranty_parts warranty_labor serial_number_low_range serial_number_high_range time_required sales support ].freeze
- ARTICLE_ASSOCIATION_FIELDS =
Article association fields.
%i[product_line_ids product_category_ids item_ids tags].freeze
- ARTICLE_DATA_FIELDS =
Article data fields.
(ARTICLE_ATTRIBUTE_FIELDS + ARTICLE_ASSOCIATION_FIELDS).freeze
- CONTENT_LINK_DATA_FIELD =
Content link data field.
:content_links- NO_CONTEXT =
No context.
Object.new.freeze
- REVIEW_DATA_FIELDS =
Review data fields.
(ARTICLE_DATA_FIELDS + [CONTENT_LINK_DATA_FIELD]).freeze
- BOOLEAN_ARTICLE_DATA_FIELDS =
Boolean article data fields.
%i[warranty_parts warranty_labor sales support].freeze
- INTEGER_ARTICLE_DATA_FIELDS =
Integer article data fields.
%i[serial_number_low_range serial_number_high_range time_required].freeze
- PAGE_EDITABLE_FIELDS =
Page editable fields.
%i[id headline content position _destroy].freeze
- PARAM_FIELDS =
Param fields.
[ *EDITABLE_FIELDS, *ARTICLE_ATTRIBUTE_FIELDS, { product_line_ids: [], product_category_ids: [], item_ids: [], tags: [] }, { pages_attributes: [PAGE_EDITABLE_FIELDS] } ].freeze
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#change_notes ⇒ Object
readonly
Validates change notes, pending review.
-
#revision_number ⇒ Object
readonly
Validates revision number, article id.
-
#subject ⇒ Object
readonly
Validates subject.
Belongs to collapse
-
#article ⇒ Article
The article this record belongs to.
-
#author ⇒ Employee?
The author this record belongs to.
-
#review_assignee ⇒ Employee?
The review assignee this record belongs to.
-
#reviewer ⇒ Employee?
The reviewer this record belongs to.
Methods included from Models::Auditable
Has many collapse
-
#pages ⇒ ActiveRecord::Relation<Page>
The associated pages.
Class Method Summary collapse
-
.applied ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are applied.
-
.chronological ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are chronological.
-
.content_link_key(target_type:, target_id:, link_type:) ⇒ String
Returns the stable identity used for one proposed related-material link.
-
.open_for_review ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are open for review.
-
.reverse_chronological ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are reverse chronological.
-
.technical_support_reviewer?(employee) ⇒ Boolean
Returns whether an employee may review Technical Article content.
Instance Method Summary collapse
-
#article_data_captured? ⇒ Boolean
Whether the record article data captured.
-
#article_data_for_application ⇒ Object
Article data for application.
-
#content_attributes ⇒ Object
Returns a hash of content fields for copying to another revision.
-
#content_link_data ⇒ Array<Hash>
Returns the normalized ordered related-material proposal.
-
#content_links_captured? ⇒ Boolean
Returns whether this revision explicitly snapshots related materials.
-
#current? ⇒ Boolean
Returns true when this is the newest revision that has actually been applied to the Article.
-
#page_attributes ⇒ Object
Page attributes.
-
#past? ⇒ Boolean
Returns true if this is a past (historical) revision.
-
#reject_by!(reviewer:, notes:) ⇒ Object
Reject by, raising on failure.
-
#reorder_content_links!(keys) ⇒ Array<Hash>
Applies a drag-and-drop order to one complete link-type group.
-
#request_changes_by!(reviewer:, notes:) ⇒ Object
Request changes by, raising on failure.
-
#restorable? ⇒ Boolean
Whether the record restorable.
-
#stage_content_link!(target:, link_type:, context: NO_CONTEXT, created_by_type: 'manual') ⇒ Hash
Adds or updates one related material in this draft without changing the live Article graph.
-
#unstage_content_link!(key: nil, id: nil) ⇒ Hash
Removes one related material from this draft without touching its live row.
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#change_notes ⇒ Object (readonly)
Validates change notes, pending review.
Validations (if => #pending_review? ):
124 |
# File 'app/models/article_revision.rb', line 124 validates :change_notes, presence: true, if: :pending_review? |
#revision_number ⇒ Object (readonly)
Validates revision number, article id.
Validations:
- Presence
- Uniqueness ({ scope: :article_id })
122 |
# File 'app/models/article_revision.rb', line 122 validates :revision_number, presence: true, uniqueness: { scope: :article_id } |
#subject ⇒ Object (readonly)
Validates subject.
Validations:
120 |
# File 'app/models/article_revision.rb', line 120 validates :subject, presence: true |
Class Method Details
.applied ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are applied. Active Record Scope
133 |
# File 'app/models/article_revision.rb', line 133 scope :applied, -> { where(review_state: %w[recorded approved]) } |
.chronological ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are chronological. Active Record Scope
131 |
# File 'app/models/article_revision.rb', line 131 scope :chronological, -> { order(:revision_number) } |
.content_link_key(target_type:, target_id:, link_type:) ⇒ String
Returns the stable identity used for one proposed related-material link.
Database IDs are intentionally excluded so approval can reconcile the
proposal with the existing live ContentLink row.
154 155 156 |
# File 'app/models/article_revision.rb', line 154 def self.content_link_key(target_type:, target_id:, link_type:) [target_type, target_id.to_i, link_type].join(':') end |
.open_for_review ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are open for review. Active Record Scope
134 |
# File 'app/models/article_revision.rb', line 134 scope :open_for_review, -> { where(review_state: %w[draft pending_review]) } |
.reverse_chronological ⇒ ActiveRecord::Relation<ArticleRevision>
A relation of ArticleRevisions that are reverse chronological. Active Record Scope
132 |
# File 'app/models/article_revision.rb', line 132 scope :reverse_chronological, -> { order(revision_number: :desc) } |
.technical_support_reviewer?(employee) ⇒ Boolean
Returns whether an employee may review Technical Article content.
142 143 144 |
# File 'app/models/article_revision.rb', line 142 def self.technical_support_reviewer?(employee) employee&.account&.has_role?(TECHNICAL_SUPPORT_REVIEWER_ROLES) || false end |
Instance Method Details
#article ⇒ Article
Returns the article this record belongs to.
101 |
# File 'app/models/article_revision.rb', line 101 belongs_to :article |
#article_data_captured? ⇒ Boolean
Returns whether the record article data captured.
230 231 232 |
# File 'app/models/article_revision.rb', line 230 def article_data_captured? article_data.to_h['_captured'] == true end |
#article_data_for_application ⇒ Object
Article data for application.
235 236 237 238 239 |
# File 'app/models/article_revision.rb', line 235 def article_data_for_application return {} unless article_data_captured? ARTICLE_DATA_FIELDS.index_with { |field| public_send(field) } end |
#author ⇒ Employee?
Returns the author this record belongs to.
103 |
# File 'app/models/article_revision.rb', line 103 belongs_to :author, class_name: 'Employee', optional: true |
#content_attributes ⇒ Object
Returns a hash of content fields for copying to another revision
220 221 222 |
# File 'app/models/article_revision.rb', line 220 def content_attributes CONTENT_FIELDS.index_with { |field| send(field) } end |
#content_link_data ⇒ Array<Hash>
Returns the normalized ordered related-material proposal. An older open
draft falls back to the current Article graph until its first staged edit.
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'app/models/article_revision.rb', line 253 def content_link_data values = if content_links_captured? article_data.to_h[CONTENT_LINK_DATA_FIELD.to_s] else article.revision_content_link_data end entries = Array(values).map { |value| normalize_content_link_entry(value) } ordered_entries = entries.each_with_index .sort_by { |entry, index| [entry['position'] || Float::INFINITY, index] } .map(&:first) next_position = next_content_link_position(ordered_entries) ordered_entries.each do |entry| next if entry['position'] entry['position'] = next_position next_position += 1 end ordered_entries end |
#content_links_captured? ⇒ Boolean
Returns whether this revision explicitly snapshots related materials.
Older historical revisions predate this key and must not remove live links.
245 246 247 |
# File 'app/models/article_revision.rb', line 245 def content_links_captured? article_data.to_h.key?(CONTENT_LINK_DATA_FIELD.to_s) end |
#current? ⇒ Boolean
Returns true when this is the newest revision that has actually been
applied to the Article. Open and rejected proposals never become current.
200 201 202 203 204 205 206 207 |
# File 'app/models/article_revision.rb', line 200 def current? return false unless review_state.in?(%w[recorded approved]) !self.class.applied .where(article_id: article_id) .where(self.class.arel_table[:revision_number].gt(revision_number)) .exists? end |
#page_attributes ⇒ Object
Page attributes.
225 226 227 |
# File 'app/models/article_revision.rb', line 225 def page_attributes pages.map { |page| page.attributes.slice('headline', 'content', 'position') } end |
#pages ⇒ ActiveRecord::Relation<Page>
Returns the associated pages.
109 110 111 112 113 |
# File 'app/models/article_revision.rb', line 109 has_many :pages, -> { order(:position) }, class_name: 'ArticleRevisionPage', dependent: :destroy, inverse_of: :article_revision |
#past? ⇒ Boolean
Returns true if this is a past (historical) revision
210 211 212 |
# File 'app/models/article_revision.rb', line 210 def past? review_state.in?(%w[recorded approved]) && !current? end |
#reject_by!(reviewer:, notes:) ⇒ Object
Reject by, raising on failure.
371 372 373 374 |
# File 'app/models/article_revision.rb', line 371 def reject_by!(reviewer:, notes:) assign_review_decision(reviewer:, notes:) reject! end |
#reorder_content_links!(keys) ⇒ Array<Hash>
Applies a drag-and-drop order to one complete link-type group. A complete
snapshot may also be supplied for callers that intentionally set global
order. Group reorders retain the position slots occupied by that group.
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 |
# File 'app/models/article_revision.rb', line 341 def reorder_content_links!(keys) with_lock do require_draft_for_content_links! entries = content_link_data.map(&:dup) requested_keys = Array(keys).map(&:to_s).compact_blank.uniq return content_link_data if requested_keys.empty? known_keys = entries.to_set { |entry| entry['key'] } unknown_keys = requested_keys.reject { |key| known_keys.include?(key) } raise_content_link_error!("Unknown related materials: #{unknown_keys.join(', ')}") if unknown_keys.any? requested_entries = requested_keys.map { |key| entries.find { |entry| entry['key'] == key } } reorder_staged_content_link_entries!(entries, requested_entries) write_content_link_data!(entries) content_link_data end end |
#request_changes_by!(reviewer:, notes:) ⇒ Object
Request changes by, raising on failure.
363 364 365 366 |
# File 'app/models/article_revision.rb', line 363 def request_changes_by!(reviewer:, notes:) assign_review_decision(reviewer:, notes:) request_changes! end |
#restorable? ⇒ Boolean
Returns whether the record restorable.
215 216 217 |
# File 'app/models/article_revision.rb', line 215 def restorable? past? end |
#review_assignee ⇒ Employee?
Returns the review assignee this record belongs to.
105 |
# File 'app/models/article_revision.rb', line 105 belongs_to :review_assignee, class_name: 'Employee', optional: true |
#reviewer ⇒ Employee?
Returns the reviewer this record belongs to.
107 |
# File 'app/models/article_revision.rb', line 107 belongs_to :reviewer, class_name: 'Employee', optional: true |
#stage_content_link!(target:, link_type:, context: NO_CONTEXT, created_by_type: 'manual') ⇒ Hash
This method acquires a row lock, which reloads the revision. Save any
pending attribute changes before staging a related material.
Adds or updates one related material in this draft without changing the
live Article graph.
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 |
# File 'app/models/article_revision.rb', line 285 def stage_content_link!(target:, link_type:, context: NO_CONTEXT, created_by_type: 'manual') with_lock do require_draft_for_content_links! entries = content_link_data.map(&:dup) key = self.class.content_link_key( target_type: target.class.base_class.name, target_id: target.id, link_type: ) entry = entries.find { |candidate| candidate['key'] == key } attributes = staged_content_link_attributes( entry:, entries:, target:, link_type:, context:, created_by_type: ) validate_staged_content_link!(target:, attributes:) entry ? entry.replace(attributes) : entries << attributes write_content_link_data!(entries) attributes end end |
#unstage_content_link!(key: nil, id: nil) ⇒ Hash
Removes one related material from this draft without touching its live row.
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 |
# File 'app/models/article_revision.rb', line 317 def unstage_content_link!(key: nil, id: nil) with_lock do require_draft_for_content_links! entries = content_link_data.map(&:dup) entry = if key.present? entries.find { |candidate| candidate['key'] == key.to_s } elsif id.to_i.positive? entries.find { |candidate| candidate['id'].to_i == id.to_i } end raise_content_link_error!('Related material is not part of this revision') unless entry entries.delete(entry) write_content_link_data!(entries) entry end end |