Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/models/whitehall_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Base class for Whitehall-specific domain errors.
#
# We rescue WhitehallError in places where we want to handle
# expected business/application failures gracefully, without also
# swallowing unexpected framework or programming errors.
#
# Avoid rescuing StandardError directly unless we genuinely intend
# to catch any application exception, including Rails, ActiveRecord,
# or Ruby runtime errors.
#
# Any error inheriting from WhitehallError is considered part of
# Whitehall's intentional public error surface.
class WhitehallError < StandardError
end
230 changes: 209 additions & 21 deletions app/services/standard_edition_migrator.rb
Original file line number Diff line number Diff line change
@@ -1,41 +1,229 @@
class StandardEditionMigrator
def initialize(scope:)
@scope = scope
def self.preview_migration(...)
new.preview_migration(...)
end

def preview
if document_scope?
total_editions = @scope.sum { |doc| Edition.unscoped.where(document: doc).count }
{ unique_documents: @scope.count, total_editions: total_editions }
else
{ unique_records: @scope.count }
def self.diff_content_payloads(...)
new.diff_content_payloads(...)
end

def self.diff_links_payloads(...)
new.diff_links_payloads(...)
end

def self.create_new_document(...)
new.create_new_document(...)
end

def self.migrate_existing_document(...)
new.migrate_existing_document(...)
end

def self.bulk_enqueue_migration(...)
new.bulk_enqueue_migration(...)
end

def preview_migration(legacy_record, recipe)
if legacy_record.is_a?(Edition)
raise "An Edition was passed. You must pass the Document instead (so that we can migrate all of its Editions)"
end

# If passed an Editionable legacy model, let's preview migrating only the latest edition.
if legacy_record.is_a?(Document)
legacy_record = legacy_record.editions.last
end

compare_payloads(legacy_record, recipe)
end

def diff_content_payloads(recipe:, old_content: nil, new_content: nil)
diff_values(
recipe.ignore_legacy_content_fields(old_content.deep_dup),
recipe.ignore_new_content_fields(new_content.deep_dup),
).to_s
end

def diff_links_payloads(recipe:, old_links: nil, new_links: nil)
diff_values(
recipe.ignore_legacy_links(old_links.deep_dup),
recipe.ignore_new_links(new_links.deep_dup),
).to_s
end

def create_new_document(legacy_record, recipe, raise_if_payloads_differ: true)
document = Document.new(document_type: "StandardEdition", content_id: legacy_record.content_id)

ActiveRecord::Base.transaction do
recipe_instance = recipe.new
raise "Cannot pass a Document to create_new_document" if legacy_record.is_a?(Document)

# Whitehall's in-house 'AuditTrail' is used to populate the timeline in the sidebar
robot_user = User.find_by(name: "Scheduled Publishing Robot")
AuditTrail.acting_as(robot_user) do
edition = build_edition(recipe_instance, legacy_record, raise_if_payloads_differ)
edition.document = document

# Save without validation first, to get all interdependent artefacts
# persisted. Then re-save with validation applied - rolling back if
# a validation error is raised.
[false, true].each do |validate|
edition.save!(validate:)
recipe_instance.save_artefacts!(edition:, validate:)
document.save!(validate:)
end

EditorialRemark.create!(
edition: edition,
body: recipe_instance.editorial_remark,
author: robot_user,
)
end
end

document
end

def migrate_existing_document(document, recipe, raise_if_payloads_differ: true)
ActiveRecord::Base.transaction do
recipe_instance = recipe.new
raise "Cannot pass a non-Document to migrate_existing_document" unless document.is_a?(Document)

editions_to_update = Edition.unscoped.where(document: document)
document.update_column(:document_type, "StandardEdition")

# Whitehall's in-house 'AuditTrail' is used to populate the timeline in the sidebar
robot_user = User.find_by(name: "Scheduled Publishing Robot")

AuditTrail.acting_as(robot_user) do
# Update each edition in-place
editions_to_update.each_with_index do |legacy_edition, index|
edition = build_edition(recipe_instance, legacy_edition, raise_if_payloads_differ)

# Convert the legacy edition instance type in-memory to StandardEdition
legacy_edition = legacy_edition.becomes!(StandardEdition)
# Overwrite the legacy edition's attributes with the new edition's attributes
# (except for id and document_id - we want to retain the original IDs, not the
# new ones generated by build_edition)
legacy_edition.assign_attributes(edition.attributes.except("id", "document_id"))
legacy_edition.auth_bypass_id ||= edition.auth_bypass_id || [] #  can't be null

# Save without validation first, to get all interdependent artefacts
# persisted. Then re-save with validation applied - rolling back if
# a validation error is raised.
[false, true].each do |validate|
legacy_edition.save!(validate:)
recipe_instance.save_artefacts!(edition: legacy_edition, validate:)
end

# Add an editorial remark to the last edition only
next unless index == editions_to_update.size - 1

EditorialRemark.create!(
edition: legacy_edition,
body: recipe_instance.editorial_remark,
author: robot_user,
)
end
end
end
document
end

def migrate!(republish: false, compare_payloads: true)
@scope.each do |record|
def bulk_enqueue_migration(legacy_records, recipe_class, migration_method:, raise_if_payloads_differ: true)
legacy_records.each do |legacy_record|
StandardEditionMigratorJob.perform_async(
record.id,
{ "republish" => republish, "compare_payloads" => compare_payloads, "model_class" => model_class_name },
legacy_record.id,
{
"model_class" => legacy_record.class.name,
"recipe_class" => recipe_class.name,
"migration_method" => migration_method,
"raise_if_payloads_differ" => raise_if_payloads_differ,
},
)
end
end

def self.recipe_for(model)
if model.is_a?(Edition)
raise "No migration recipe defined for Edition type #{model.type}"
private

def build_edition(recipe_instance, legacy_edition, raise_if_payloads_differ)
return recipe_instance.build_edition(legacy_edition) unless raise_if_payloads_differ

old_presenter = recipe_instance.legacy_presenter.new(legacy_edition)
edition = recipe_instance.build_edition(legacy_edition)
diff = diff_content_payloads(
recipe: recipe_instance,
old_content: old_presenter.content,
new_content: PublishingApi::StandardEditionPresenter.new(edition).content,
) + diff_links_payloads(
recipe: recipe_instance,
old_links: old_presenter.links,
new_links: PublishingApi::StandardEditionPresenter.new(edition).links,
)
if diff.present?
raise <<~ERROR
The legacy and new edition payloads differ after normalisation.

#{diff}
ERROR
end

raise "No migration recipe defined for #{model.class.name}"
edition
end

private
def compare_payloads(legacy_record, recipe)
# Grab the payloads from the old presenter _before_ we do any mutation, to ensure we're comparing against the original payload
old_presenter = recipe.new.legacy_presenter.new(legacy_record)
old_content = old_presenter.content
old_links = old_presenter.links

standard_edition = recipe.new.build_edition(legacy_record)
new_presenter = PublishingApi::StandardEditionPresenter.new(standard_edition)

def model_class_name
@scope.model.name
<<~OUTPUT
OLD PAYLOAD
===CONTENT
#{JSON.pretty_generate(old_content)}
===LINKS
#{JSON.pretty_generate(old_links)}

NEW PAYLOAD
===CONTENT
#{JSON.pretty_generate(new_presenter.content)}
===LINKS
#{JSON.pretty_generate(new_presenter.links)}

NORMALISED DIFF
===CONTENT
#{diff_content_payloads(
old_content: old_content,
new_content: new_presenter.content,
recipe: recipe.new,
)}
===LINKS
#{diff_links_payloads(
old_links: old_links,
new_links: new_presenter.links,
recipe: recipe.new,
)}
OUTPUT
end

def diff_values(left_val, right_val)
# Newlines required otherwise Diffy appends "\\ No newline at end of file" to the output
left = "#{JSON.pretty_generate(deep_sort(left_val))}\n"
right = "#{JSON.pretty_generate(deep_sort(right_val))}\n"
Diffy::Diff.new(left, right, context: 5, color: true)
end

def document_scope?
model_class_name == "Document"
def deep_sort(obj)
case obj
when Hash
obj.keys.sort.index_with { |k| deep_sort(obj[k]) }
when Array
a = obj.map { |v| deep_sort(v) }
a.sort_by { |v| JSON.generate(v) }
else
obj
end
end
end
48 changes: 48 additions & 0 deletions app/services/standard_edition_migrator/base_recipe.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
class StandardEditionMigrator::BaseRecipe
def legacy_presenter
raise NotImplementedError, "Subclasses must implement legacy_presenter!"
end

def build_edition(record)
raise NotImplementedError, "Subclasses must implement build_edition!"
end

def save_artefacts!(validate:, edition:)
# This is where the Recipe can handle saving any associated artefacts (e.g. Features, Organisations, etc.).
(@artefacts_to_save || []).each do |artefact|
# Translations etc need to be associated with the edition before they can be saved
if artefact.respond_to?(:edition_id=)
artefact.edition_id = edition.id
end
artefact.save!(validate: validate)
end
end

def editorial_remark
"Migrated to StandardEdition"
end

###
# The below methods aren't used in Edition creation - they're used only for payload normalisation for comparison purposes
###

def ignore_legacy_content_fields(content)
# Noop
content
end

def ignore_new_content_fields(content)
# Noop
content
end

def ignore_legacy_links(links)
# Noop
links
end

def ignore_new_links(links)
# Noop
links
end
end
Loading