Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
78bda4a
Create table to track relationship between Parent and Child docs
ChrisBAshton May 7, 2026
c5ef7b8
Define ParentChildRelationship class
ChrisBAshton May 7, 2026
d9a3685
Create ChildDocument and ParentDocument concerns on StandardEdition
ChrisBAshton May 7, 2026
697631d
Add process_associations_after_save on Parent edition creation
ChrisBAshton May 8, 2026
7d099aa
Add 'allowed_child_document_types_of' method
ChrisBAshton May 8, 2026
40c1b23
Add route / controller / view for choosing child document type
ChrisBAshton May 8, 2026
fb34782
Add association between parent/child doc on child doc creation
ChrisBAshton May 8, 2026
1fe0ca2
Add Topical Event About Page config (and reference it from parent)
ChrisBAshton May 7, 2026
38a2628
Add `allows_child_documents?` method, driven by config
ChrisBAshton May 8, 2026
5448676
Send 'parent' link when child document type is configured to do so
ChrisBAshton May 8, 2026
18a12ee
Enforce either base_path_prefix OR is_child_document property
ChrisBAshton May 8, 2026
fb6d226
Infer child document's base_path_prefix from the parent doc
ChrisBAshton May 8, 2026
d5750a3
Add child document creation journey end-to-end
ChrisBAshton May 7, 2026
bd1b96d
Rule 1: New child drafts
ChrisBAshton May 12, 2026
1fee4c0
Add 'new_child_documents' helper method
ChrisBAshton May 12, 2026
49c0992
Ensure deleting a child draft removes it from parent
ChrisBAshton May 12, 2026
3b7a3b4
Rule 2: Prevent orphaned children
ChrisBAshton May 12, 2026
07be10a
Rule 3: Child publishability
ChrisBAshton May 12, 2026
3c0a9d8
Rule 4: Parent visibility ceiling
ChrisBAshton May 13, 2026
5b242f7
Add validation to ensure child can't be linked to multiple parents
ChrisBAshton May 21, 2026
f474298
Move Parent/Child relationship logic to StandardEditionsController
ChrisBAshton May 21, 2026
62b6b42
Rename can_be_published? to parent_allows_publishing?
ChrisBAshton May 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ def actions
add_create_action
add_edit_action
add_submit_action
add_unschedule_action
add_schedule_action
add_publish_action
unless @edition.is_child_document? && !@edition.parent_allows_publishing?
add_unschedule_action
add_schedule_action
add_publish_action
end
add_reject_action
add_destroy_action
add_unwithdraw_action
Expand Down
24 changes: 24 additions & 0 deletions app/controllers/admin/child_documents_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class Admin::ChildDocumentsController < Admin::EditionsController
rescue_from ConfigurableDocumentType::NotFoundError, with: :render_not_found

def choose_type
@permitted_child_document_types = ConfigurableDocumentType.allowed_child_document_types_of(StandardEdition.find(params[:parent_edition_id])).select { |type| can?(current_user, type) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings for parent and child could fall out of sync across schemas (human error). Could add specific validation to ensure settings match, or use parent's setting, or fall back to parent's when child setting nil.

Do we have any validation here to say that the orgs passed into this setting, for restriction, should also be listed in the org association?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAGNI at the moment. We've agreed to let organisations be set individually per parent/child. Out of scope for this PR

@lauraghiorghisor-tw lauraghiorghisor-tw May 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh - I would have thought the implication is the opposite. We said we would allow setting any orgs on child but we won't allow setting access limiting on child. This bit here is more about access so my assumption would be that the org restriction should be based on parent (if access limiting is too)? But we did not touch specifically on org-based access limitations in the session 🤔


render_not_found if @permitted_child_document_types.empty?
end

private

def edition_class
StandardEdition
end

def new_edition_params
# Set the configurable document type for new editions based on the value from the query parameter submitted with the 'choose_type' form
super[:configurable_document_type].blank? ? super.merge(configurable_document_type: params[:configurable_document_type]) : super
end

def render_not_found
render "admin/errors/not_found", status: :not_found
end
end
6 changes: 6 additions & 0 deletions app/controllers/admin/edition_workflow_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def publish
else
redirect_to admin_edition_path(@edition), alert: edition_publisher.failure_reason
end
rescue WhitehallError => e
redirect_to admin_edition_path(@edition), alert: e.message
end

def confirm_force_publish
Expand All @@ -94,6 +96,8 @@ def force_publish
else
redirect_to admin_edition_path(@edition), alert: edition_publisher.failure_reason
end
rescue WhitehallError => e
redirect_to admin_edition_path(@edition), alert: e.message
end

def confirm_unpublish
Expand All @@ -110,6 +114,8 @@ def unpublish
flash.now[:alert] = message if @unpublishing.errors.blank?
render :confirm_unpublish
end
rescue WhitehallError => e
redirect_to admin_edition_path(@edition), alert: e.message
end

def confirm_unwithdraw; end
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/admin/editions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ def destroy
else
redirect_to admin_edition_path(@edition), alert: edition_deleter.failure_reason
end
rescue WhitehallError => e
redirect_to admin_edition_path(@edition), alert: e.message
end

def update_bypass_id
Expand Down
41 changes: 41 additions & 0 deletions app/controllers/admin/standard_editions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,32 @@ def apply_change_type
end
end

# TODO: should this transaction complexity be encapsulated in an EditionService call?
# That seems to be how we handle this sort of thing elsewhere.
def create
success =
if updater.can_perform?
begin
ActiveRecord::Base.transaction do
@edition.save!
attach_to_parent_if_this_is_a_child!
updater.perform!
end
true
rescue ActiveRecord::RecordInvalid => e
@edition.errors.merge!(e.record.errors)
false
end
end

if success
redirect_to show_or_edit_path, saved_confirmation_notice
else
build_edition_dependencies
render :new
end
end

def update
@edition.current_tab_context = @current_tab_context
super
Expand Down Expand Up @@ -92,4 +118,19 @@ def show_or_edit_path
def render_not_found
render "admin/errors/not_found", status: :not_found
end

def attach_to_parent_if_this_is_a_child!
parent_id = params[:parent_edition_id].presence
return unless parent_id

parent = Edition.find(parent_id)
enforce_permission!(:update, parent)

parent.with_lock do
ParentChildRelationship.create!(
parent_edition: parent,
child_document: @edition.document,
)
end
end
end
41 changes: 41 additions & 0 deletions app/models/concerns/standard_edition/child_document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module StandardEdition::ChildDocument
extend ActiveSupport::Concern

class IncompatibleParentState < ::WhitehallError; end

included do
has_one :parent_relationship,
class_name: "ParentChildRelationship",
foreign_key: :child_document_id,
primary_key: :document_id,
inverse_of: :child_document

has_one :parent_edition,
Comment thread
ChrisBAshton marked this conversation as resolved.
Comment thread
ChrisBAshton marked this conversation as resolved.
through: :parent_relationship,
source: :parent_edition

before_update :ensure_publishing_is_allowed!, if: :publishing?
end

def is_child_document?
parent_edition.present?
end

def parent_allows_publishing?
return true unless is_child_document?

parent_edition.document.live_edition&.state == "published" && !parent_edition.pre_publication?
Comment thread
ChrisBAshton marked this conversation as resolved.
end

private

def publishing?
will_save_change_to_state? && state == "published"
end

def ensure_publishing_is_allowed!
return if parent_allows_publishing?

raise IncompatibleParentState, "Unable to publish child document"
end
end
80 changes: 80 additions & 0 deletions app/models/concerns/standard_edition/parent_document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
module StandardEdition::ParentDocument
extend ActiveSupport::Concern

class UnableToDelete < ::WhitehallError; end
class UnableToWithdraw < ::WhitehallError; end
class UnableToUnpublish < ::WhitehallError; end

class Trait < Edition::Traits::Trait
def process_associations_after_save(new_edition)
ParentChildRelationship
.where(parent_edition_id: @edition.id)
.find_each do |relationship|
ParentChildRelationship.create!(
parent_edition_id: new_edition.id,
child_document_id: relationship.child_document_id,
)
end
end
end

included do
has_many :child_relationships,
class_name: "ParentChildRelationship",
foreign_key: :parent_edition_id,
inverse_of: :parent_edition,
dependent: :destroy
Comment thread
ChrisBAshton marked this conversation as resolved.

has_many :child_documents,
lambda {
joins(:editions)
.merge(Edition.all) # applies default scope (i.e. excludes deleted)
Comment thread
ChrisBAshton marked this conversation as resolved.
.distinct
},
through: :child_relationships,
source: :child_document

before_update :ensure_no_new_child_documents!, if: :deleting?
before_update :ensure_no_children_more_visible_than_parent!, if: :will_save_change_to_state?

add_trait Trait
end

def allows_child_documents?
Comment thread
ChrisBAshton marked this conversation as resolved.
(type_instance.settings["allowed_child_document_types"] || []).count.positive?
end

def is_parent_document?
child_documents.any?
end

def child_editions
Edition.where(id: child_documents.select(:latest_edition_id))
end

def new_child_documents
child_documents.where(live_edition_id: nil)
end

private

def deleting?
will_save_change_to_state? && state == "deleted"
end

def ensure_no_new_child_documents!
if allows_child_documents? && new_child_documents.any?
raise UnableToDelete, "This document cannot be deleted while it has child documents that have never been published. Delete the draft child documents first."
end
end

def ensure_no_children_more_visible_than_parent!
if state == "unpublished" && child_editions.any? { |child| child.state.in?(%w[published withdrawn]) }
raise UnableToUnpublish, "This document cannot be unpublished while it has child documents that are published or withdrawn. Unpublish the child documents first."
end

if state == "withdrawn" && child_editions.any? { |child| child.state == "published" }
raise UnableToWithdraw, "This document cannot be withdrawn while it has child documents that are published. Withdraw the child documents first."
end
end
end
5 changes: 5 additions & 0 deletions app/models/configurable_document_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ def self.where_group(group)
all.filter { |t| t.settings["configurable_document_group"] == group }
end

def self.allowed_child_document_types_of(parent_edition)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one!

allowed_child_document_types = find(parent_edition.configurable_document_type).settings["allowed_child_document_types"]
allowed_child_document_types.map { |config| find(config["document_type"]) }
end

def self.convertible_from(current_type_key)
available_types = []
if (group = ConfigurableDocumentType.find(current_type_key).settings["configurable_document_group"])
Expand Down
5 changes: 5 additions & 0 deletions app/models/configurable_document_types/topical_event.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@
"publishing_api_schema_name": "topical_event",
"publishing_api_document_type": "topical_event",
"rendering_app": "frontend",
"allowed_child_document_types": [
{
Comment thread
ChrisBAshton marked this conversation as resolved.
"document_type": "topical_event_about_page"
}
],
"images": {
"enabled": true,
"usages": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"key": "topical_event_about_page",
"title": "Topical event 'About' page",
"description": "???",
"forms": {
"documents": {
"fields": {
"body": {
"title": "Body",
"description": "The main content of the page",
"required": true,
"block": "govspeak",
"attribute_path": ["block_content", "body"],
"translatable": true
}
}
}
},
"schema": {
"attributes": {
"body": {
"type": "string"
}
},
"validations": {
"presence": {
"attributes": ["body"]
},
"length": {
"attributes": ["body"],
"maximum": 16777215
},
"safe_html": {
"attributes": ["body"]
},
"no_footnotes_allowed": {
"attributes": ["body"]
},
"embedded_contacts_exist": {
"attributes": ["body"]
}
}
},
"presenters": {
"publishing_api": {
"details": {
"body": "govspeak"
},
"links": [
"parent"
]
}
},
"settings": {
"is_child_document": true,
"publishing_api_schema_name": "topical_event_about_page",
"publishing_api_document_type": "topical_event_about_page",
"rendering_app": "frontend",
"images": {
"enabled": false
},
"send_change_history": false,
"file_attachments_enabled": false,
"organisations": null,
"backdating_enabled": false,
"history_mode_enabled": false,
"taxon_required": false,
"translations_enabled": true
}
}
12 changes: 12 additions & 0 deletions app/models/edition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ def path_name
to_model.class.name.underscore
end

def allows_child_documents?
false
end

def has_been_tagged?
api_response = Services.publishing_api.get_expanded_links(content_id, with_drafts: false)

Expand Down Expand Up @@ -249,6 +253,14 @@ def is_latest_edition?
document.latest_edition == self
end

def is_child_document?
false
end

def is_parent_document?
false
end

def all_nation_applicability_selected?
true
end
Expand Down
Loading