Fix meeting backlog section exposing API

This commit is contained in:
Oliver Günther
2026-06-08 15:52:51 +02:00
parent 7b5403f1be
commit 58dfa111ba
12 changed files with 235 additions and 7 deletions
@@ -23,6 +23,10 @@ properties:
position:
type: integer
description: The position of the section within the meeting.
backlog:
type: boolean
description: |-
Whether this section is the meeting's backlog. Backlog sections are read-only via the API.
createdAt:
type: string
format: date-time
+14 -3
View File
@@ -5,7 +5,12 @@ get:
operationId: get_meeting_section
tags:
- Meetings
description: Retrieve an individual meeting section.
description: |-
Retrieve an individual meeting section.
The backlog section can be retrieved via this endpoint even though it is not part of the regular
`meeting.sections` association. Its `_links.meeting` points to the owning meeting (the series
template for recurring meeting occurrences).
parameters:
- description: Section identifier
example: 1
@@ -34,7 +39,10 @@ patch:
operationId: update_meeting_section
tags:
- Meetings
description: Updates the given meeting section.
description: |-
Updates the given meeting section.
The backlog section cannot be updated and will return `422 Unprocessable Entity`.
parameters:
- description: Section identifier
example: 1
@@ -88,7 +96,10 @@ delete:
operationId: delete_meeting_section
tags:
- Meetings
description: Deletes the meeting section.
description: |-
Deletes the meeting section and all its agenda items.
The backlog section cannot be deleted and will return `422 Unprocessable Entity`.
parameters:
- description: Section identifier
example: 1
@@ -5,7 +5,13 @@ get:
operationId: get_meeting_section_by_meeting
tags:
- Meetings
description: Retrieve an individual section of a meeting.
description: |-
Retrieve an individual section of a meeting.
The backlog section can be retrieved via this nested endpoint even though it is not part of the
regular `meeting.sections` association. Its `_links.self` and `_links.meeting` point to the
canonical flat section resource and the owning meeting (the series template for recurring
meeting occurrences).
parameters:
- description: Meeting identifier
example: 1
@@ -5,7 +5,11 @@ get:
operationId: list_meeting_sections
tags:
- Meetings
description: Lists all sections for the given meeting.
description: |-
Lists all sections for the given meeting, including the backlog section as the last element.
The backlog section is read-only and its `_links.meeting` points to the owning meeting (the
series template for recurring meeting occurrences, otherwise the meeting itself).
parameters:
- description: Meeting identifier
example: 1
@@ -32,5 +32,11 @@ module MeetingSections
include EditableItem
delete_permission :manage_agendas
validate :backlog_not_deletable
def backlog_not_deletable
errors.add :base, :error_readonly if model.backlog?
end
end
end
@@ -30,6 +30,7 @@
module MeetingSections
class UpdateContract < BaseContract
validate :user_allowed_to_edit
validate :backlog_not_editable
# We allow an empty title internally via create to mark an untitled/implicit section
# but users should not be able to update it with an empty title through this contract
@@ -44,5 +45,9 @@ module MeetingSections
errors.add :base, :error_unauthorized
end
end
def backlog_not_editable
errors.add :base, :error_readonly if model.backlog?
end
end
end
@@ -46,6 +46,8 @@ module API
property :position
property :backlog
associated_resource :meeting,
link: ->(*) {
{
@@ -32,9 +32,17 @@ module API
module V3
module MeetingSections
class SectionsByMeetingAPI < ::API::OpenProjectAPI
helpers do
def find_backlog_section(id)
backlog = @meeting.backlog
backlog if backlog&.id == id
end
end
resources :sections do
get do
sections = @meeting.sections
sections = @meeting.sections.to_a
sections << @meeting.backlog if @meeting.backlog.present?
MeetingSectionCollectionRepresenter.new(sections,
self_link: api_v3_paths.meeting_sections(meeting_id: @meeting.id),
current_user:)
@@ -42,7 +50,9 @@ module API
route_param :section_id, type: Integer, desc: "Section ID" do
after_validation do
@meeting_section = @meeting.sections.find(declared_params[:section_id])
@meeting_section = @meeting.sections.find_by(id: declared_params[:section_id]) ||
find_backlog_section(declared_params[:section_id])
raise API::Errors::NotFound.new unless @meeting_section
end
get &::API::V3::Utilities::Endpoints::Show.new(model: MeetingSection).mount
@@ -62,6 +62,12 @@ RSpec.describe MeetingSections::DeleteContract do
it_behaves_like "contract is valid"
end
context "when the section is the backlog" do
let(:section) { meeting.backlog }
it_behaves_like "contract is invalid", base: :error_readonly
end
end
context "without permission" do
@@ -54,6 +54,12 @@ RSpec.describe MeetingSections::UpdateContract do
it_behaves_like "contract is invalid", base: I18n.t(:text_agenda_item_not_editable_anymore)
end
context "when the section is the backlog" do
let(:section) { meeting.backlog }
it_behaves_like "contract is invalid", base: :error_readonly
end
end
context "without permission" do
@@ -203,6 +203,42 @@ RSpec.describe "API v3 Meeting Agenda Items sub-resource", content_type: :json d
end
end
context "when the agenda item is part of the backlog" do
let(:backlog) { meeting.backlog }
let!(:backlog_agenda_item) do
create(:meeting_agenda_item,
meeting:,
meeting_section: backlog,
author: current_user,
title: "Backlog agenda item")
end
let(:path) { api_v3_paths.meeting_agenda_item(backlog_agenda_item.id, meeting_id: meeting.id) }
it "renders the backlog section correctly" do
expect(last_response).to have_http_status(:ok)
expect(last_response.body)
.to be_json_eql(api_v3_paths.meeting_section(backlog.id).to_json)
.at_path("_links/section/href")
expect(last_response.body)
.to be_json_eql(backlog.title.to_json)
.at_path("_links/section/title")
expect(last_response.body)
.to be_json_eql(backlog.id.to_json)
.at_path("_embedded/section/id")
expect(last_response.body)
.to be_json_eql(true.to_json)
.at_path("_embedded/section/backlog")
expect(last_response.body)
.to be_json_eql(api_v3_paths.meeting(meeting.id).to_json)
.at_path("_embedded/section/_links/meeting/href")
end
end
context "when the agenda item is linked to a work package in an inaccessible project" do
let(:private_project) { create(:project, public: false) }
let(:private_work_package) { create(:work_package, project: private_project) }
@@ -250,6 +250,138 @@ RSpec.describe "API v3 Meeting Sections sub-resource", content_type: :json do
end
end
describe "backlog section" do
let(:backlog) { meeting.backlog }
describe "GET /api/v3/meetings/:meeting_id/sections" do
let(:path) { api_v3_paths.meeting_sections(meeting_id: meeting.id) }
before { get path }
it "includes the backlog as the last section" do
elements = JSON.parse(last_response.body).dig("_embedded", "elements")
expect(elements.pluck("id")).to eq([section.id, backlog.id])
expect(elements.last).to include("backlog" => true)
expect(elements.first).to include("backlog" => false)
end
end
describe "GET /api/v3/meetings/:meeting_id/sections/:id" do
let(:path) { api_v3_paths.meeting_section(backlog.id, meeting_id: meeting.id) }
before { get path }
it "returns the backlog with canonical links" do
expect(last_response).to have_http_status(:ok)
expect(last_response.body)
.to be_json_eql(backlog.id.to_json)
.at_path("id")
expect(last_response.body)
.to be_json_eql(true.to_json)
.at_path("backlog")
expect(last_response.body)
.to be_json_eql(api_v3_paths.meeting_section(backlog.id).to_json)
.at_path("_links/self/href")
expect(last_response.body)
.to be_json_eql(api_v3_paths.meeting(meeting.id).to_json)
.at_path("_links/meeting/href")
end
end
describe "PATCH /api/v3/meeting_sections/:id" do
let(:path) { api_v3_paths.meeting_section(backlog.id) }
let(:original_title) { backlog.title }
let(:body) { { title: "Updated Backlog Title" }.to_json }
subject(:response) { patch path, body }
it "responds with 422 and does not change the title" do
expect(response).to have_http_status(:unprocessable_entity)
expect(backlog.reload.title).to eq(original_title)
end
end
describe "DELETE /api/v3/meeting_sections/:id" do
let(:path) { api_v3_paths.meeting_section(backlog.id) }
before { delete path }
it "responds with 422 and keeps the backlog" do
expect(last_response).to have_http_status(:unprocessable_entity)
expect(MeetingSection).to exist(backlog.id)
end
end
end
describe "backlog section for a recurring meeting occurrence" do
let(:recurring_meeting) { create(:recurring_meeting, project:, author: current_user) }
let(:template) { recurring_meeting.template }
let(:occurrence) do
create(:recurring_meeting_occurrence, recurring_meeting:, project:, author: current_user)
end
let!(:occurrence_section) { create(:meeting_section, meeting: occurrence, title: "Occurrence Section") }
let(:backlog) { template.backlog }
describe "GET /api/v3/meetings/:meeting_id/sections" do
let(:path) { api_v3_paths.meeting_sections(meeting_id: occurrence.id) }
before { get path }
it "includes the backlog with canonical links to the template meeting" do
elements = JSON.parse(last_response.body).dig("_embedded", "elements")
backlog_element = elements.find { |element| element["id"] == backlog.id }
expect(elements.pluck("id")).to eq([occurrence_section.id, backlog.id])
expect(backlog_element).to include("backlog" => true)
expect(backlog_element.dig("_links", "self", "href"))
.to eq(api_v3_paths.meeting_section(backlog.id))
expect(backlog_element.dig("_links", "meeting", "href"))
.to eq(api_v3_paths.meeting(template.id))
end
end
describe "GET /api/v3/meetings/:meeting_id/sections/:id" do
let(:path) { api_v3_paths.meeting_section(backlog.id, meeting_id: occurrence.id) }
before { get path }
it "returns the backlog with canonical links to the template meeting" do
expect(last_response).to have_http_status(:ok)
expect(last_response.body)
.to be_json_eql(api_v3_paths.meeting_section(backlog.id).to_json)
.at_path("_links/self/href")
expect(last_response.body)
.to be_json_eql(api_v3_paths.meeting(template.id).to_json)
.at_path("_links/meeting/href")
end
end
describe "agenda item section link consistency" do
let!(:agenda_item) do
create(:meeting_agenda_item,
meeting: occurrence,
meeting_section: backlog,
author: current_user,
title: "Backlog item")
end
let(:path) { api_v3_paths.meeting_section(backlog.id) }
before { get path }
it "resolves the section referenced by the agenda item" do
expect(last_response).to have_http_status(:ok)
expect(last_response.body).to be_json_eql(backlog.id.to_json).at_path("id")
end
end
end
describe "DELETE /api/v3/meeting_sections/:id" do
let(:path) { api_v3_paths.meeting_section(section.id) }