Class: ArticleRevision

Inherits:
ApplicationRecord show all
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_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

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

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

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#change_notesObject (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_numberObject (readonly)

Validates revision number, article id.

Validations:



122
# File 'app/models/article_revision.rb', line 122

validates :revision_number, presence: true, uniqueness: { scope: :article_id }

#subjectObject (readonly)

Validates subject.

Validations:



120
# File 'app/models/article_revision.rb', line 120

validates :subject, presence: true

Class Method Details

.appliedActiveRecord::Relation<ArticleRevision>

A relation of ArticleRevisions that are applied. Active Record Scope

Returns:

See Also:



133
# File 'app/models/article_revision.rb', line 133

scope :applied, -> { where(review_state: %w[recorded approved]) }

.chronologicalActiveRecord::Relation<ArticleRevision>

A relation of ArticleRevisions that are chronological. Active Record Scope

Returns:

See Also:



131
# File 'app/models/article_revision.rb', line 131

scope :chronological, -> { order(:revision_number) }

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.

Parameters:

  • target_type (String)

    base Active Record class name

  • target_id (Integer)

    target record ID

  • link_type (String)

    ContentLink relationship type

Returns:

  • (String)

    stable proposal key



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_reviewActiveRecord::Relation<ArticleRevision>

A relation of ArticleRevisions that are open for review. Active Record Scope

Returns:

See Also:



134
# File 'app/models/article_revision.rb', line 134

scope :open_for_review, -> { where(review_state: %w[draft pending_review]) }

.reverse_chronologicalActiveRecord::Relation<ArticleRevision>

A relation of ArticleRevisions that are reverse chronological. Active Record Scope

Returns:

See Also:



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.

Parameters:

  • employee (Employee, nil)

    proposed review assignee

Returns:

  • (Boolean)

    whether the employee has a Technical Support role



142
143
144
# File 'app/models/article_revision.rb', line 142

def self.technical_support_reviewer?(employee)
  employee&.&.has_role?(TECHNICAL_SUPPORT_REVIEWER_ROLES) || false
end

Instance Method Details

#articleArticle

Returns the article this record belongs to.

Returns:

  • (Article)

    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.

Returns:

  • (Boolean)

    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_applicationObject

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

#authorEmployee?

Returns the author this record belongs to.

Returns:

  • (Employee, nil)

    the author this record belongs to



103
# File 'app/models/article_revision.rb', line 103

belongs_to :author, class_name: 'Employee', optional: true

#content_attributesObject

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

Returns the normalized ordered related-material proposal. An older open
draft falls back to the current Article graph until its first staged edit.

Returns:

  • (Array<Hash>)

    ordinary ContentLink proposal rows



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

Returns whether this revision explicitly snapshots related materials.
Older historical revisions predate this key and must not remove live links.

Returns:

  • (Boolean)

    whether approval may reconcile ContentLink rows



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.

Returns:

  • (Boolean)


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_attributesObject

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

#pagesActiveRecord::Relation<Page>

Returns the associated pages.

Returns:

  • (ActiveRecord::Relation<Page>)

    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

Returns:

  • (Boolean)


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.

Parameters:

  • reviewer (Object)

    the reviewer

  • notes (Object)

    the notes



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.

Parameters:

  • keys (Array<String>)

    stable keys in requested order

Returns:

  • (Array<Hash>)

    updated proposal rows

Raises:

  • (ActiveRecord::RecordInvalid)

    when an unknown key is supplied



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.

Parameters:

  • reviewer (Object)

    the reviewer

  • notes (Object)

    the notes



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.

Returns:

  • (Boolean)

    whether the record restorable



215
216
217
# File 'app/models/article_revision.rb', line 215

def restorable?
  past?
end

#review_assigneeEmployee?

Returns the review assignee this record belongs to.

Returns:

  • (Employee, nil)

    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

#reviewerEmployee?

Returns the reviewer this record belongs to.

Returns:

  • (Employee, nil)

    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

Note:

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.

Parameters:

  • target (ApplicationRecord)

    related content record

  • link_type (String)

    ContentLink relationship type

  • context (String, nil, Object) (defaults to: NO_CONTEXT)

    editorial explanation; omission preserves an existing value

  • created_by_type (String) (defaults to: 'manual')

    manual or Sunny attribution

Returns:

  • (Hash)

    staged related-material row

Raises:

  • (ActiveRecord::RecordInvalid)

    when the draft or link is invalid



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.

Parameters:

  • key (String, nil) (defaults to: nil)

    stable related-material key

  • id (Integer, nil) (defaults to: nil)

    existing ContentLink ID

Returns:

  • (Hash)

    removed proposal row

Raises:

  • (ActiveRecord::RecordInvalid)

    when the draft or identifier is invalid



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