From 411ce816f51c7ab042570af7d12cef2214874127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 May 2026 19:54:29 +0200 Subject: [PATCH 1/3] Implement meeting outcomes API https://community.openproject.org/work_packages/75393 --- .../meeting_outcome_collection_model.yml | 39 +++ .../schemas/meeting_outcome_model.yml | 69 ++++ .../schemas/meeting_outcome_write_model.yml | 26 ++ docs/api/apiv3/openapi-spec.yml | 10 + .../paths/meeting_agenda_item_outcome.yml | 190 +++++++++++ .../paths/meeting_agenda_item_outcomes.yml | 118 +++++++ .../agenda_items_by_meeting_api.rb | 2 + .../meeting_agenda_item_representer.rb | 21 +- .../meeting_outcome_collection_representer.rb | 38 +++ .../meeting_outcome_payload_representer.rb | 41 +++ .../meeting_outcome_representer.rb | 78 +++++ .../outcomes_by_agenda_item_api.rb | 69 ++++ .../lib/open_project/meeting/engine.rb | 8 + .../agenda_items_by_meeting_resource_spec.rb | 11 +- .../outcomes_by_agenda_item_resource_spec.rb | 294 ++++++++++++++++++ 15 files changed, 1012 insertions(+), 2 deletions(-) create mode 100644 docs/api/apiv3/components/schemas/meeting_outcome_collection_model.yml create mode 100644 docs/api/apiv3/components/schemas/meeting_outcome_model.yml create mode 100644 docs/api/apiv3/components/schemas/meeting_outcome_write_model.yml create mode 100644 docs/api/apiv3/paths/meeting_agenda_item_outcome.yml create mode 100644 docs/api/apiv3/paths/meeting_agenda_item_outcomes.yml create mode 100644 modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_collection_representer.rb create mode 100644 modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_payload_representer.rb create mode 100644 modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_representer.rb create mode 100644 modules/meeting/lib/api/v3/meeting_outcomes/outcomes_by_agenda_item_api.rb create mode 100644 modules/meeting/spec/requests/api/v3/meeting_outcomes/outcomes_by_agenda_item_resource_spec.rb diff --git a/docs/api/apiv3/components/schemas/meeting_outcome_collection_model.yml b/docs/api/apiv3/components/schemas/meeting_outcome_collection_model.yml new file mode 100644 index 000000000000..f265f9748c99 --- /dev/null +++ b/docs/api/apiv3/components/schemas/meeting_outcome_collection_model.yml @@ -0,0 +1,39 @@ +# Schema: MeetingOutcomeCollectionModel +--- +type: object +required: + - _type + - count + - total + - _embedded + - _links +properties: + _type: + type: string + enum: + - Collection + count: + type: integer + total: + type: integer + _embedded: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: './meeting_outcome_model.yml' + _links: + type: object + required: + - self + properties: + self: + allOf: + - $ref: './link.yml' + - description: |- + The link to this outcome collection resource + + **Resource**: MeetingOutcomeCollection diff --git a/docs/api/apiv3/components/schemas/meeting_outcome_model.yml b/docs/api/apiv3/components/schemas/meeting_outcome_model.yml new file mode 100644 index 000000000000..cf83960c6982 --- /dev/null +++ b/docs/api/apiv3/components/schemas/meeting_outcome_model.yml @@ -0,0 +1,69 @@ +# Schema: MeetingOutcomeModel +--- +type: object +required: + - _type + - id + - kind + - createdAt + - updatedAt +properties: + _type: + type: string + enum: + - MeetingOutcome + id: + type: integer + description: Identifier of this outcome + minimum: 1 + kind: + type: string + description: |- + The type of this outcome. Possible values: + + - *information*: an informational outcome with notes + - *work_package*: an outcome linked to a work package + enum: + - information + - work_package + notes: + $ref: "./formattable.yml" + createdAt: + type: string + format: date-time + description: Time of creation. + updatedAt: + type: string + format: date-time + description: Time of the most recent change. + _links: + type: object + properties: + self: + allOf: + - $ref: './link.yml' + - description: |- + This outcome + + **Resource**: MeetingOutcome + author: + allOf: + - $ref: './link.yml' + - description: |- + The user who created this outcome + + **Resource**: User + agendaItem: + allOf: + - $ref: './link.yml' + - description: |- + The agenda item this outcome belongs to + + **Resource**: MeetingAgendaItem + workPackage: + allOf: + - $ref: './link.yml' + - description: |- + The linked work package (for work_package type outcomes) + + **Resource**: WorkPackage diff --git a/docs/api/apiv3/components/schemas/meeting_outcome_write_model.yml b/docs/api/apiv3/components/schemas/meeting_outcome_write_model.yml new file mode 100644 index 000000000000..a29655891bba --- /dev/null +++ b/docs/api/apiv3/components/schemas/meeting_outcome_write_model.yml @@ -0,0 +1,26 @@ +# Schema: MeetingOutcomeWriteModel +--- +type: object +properties: + kind: + type: string + description: |- + The type of this outcome. Possible values: + + - *information*: an informational outcome with notes + - *work_package*: an outcome linked to a work package + enum: + - information + - work_package + notes: + $ref: "./formattable.yml" + _links: + type: object + properties: + workPackage: + allOf: + - $ref: "./link.yml" + - description: |- + The linked work package (for work_package type outcomes) + + **Resource**: WorkPackage diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 8abeb68cf5f6..c47cf84d2add 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -275,6 +275,10 @@ paths: "$ref": "./paths/meeting_agenda_items.yml" "/api/v3/meetings/{meeting_id}/agenda_items/{id}": "$ref": "./paths/meeting_agenda_item.yml" + "/api/v3/meetings/{meeting_id}/agenda_items/{agenda_item_id}/outcomes": + "$ref": "./paths/meeting_agenda_item_outcomes.yml" + "/api/v3/meetings/{meeting_id}/agenda_items/{agenda_item_id}/outcomes/{id}": + "$ref": "./paths/meeting_agenda_item_outcome.yml" "/api/v3/meetings/{id}/attachments": "$ref": "./paths/meeting_attachments.yml" "/api/v3/meetings/{id}/form": @@ -892,6 +896,12 @@ components: "$ref": "./components/schemas/meeting_agenda_item_write_model.yml" MeetingAgendaItemCollectionModel: "$ref": "./components/schemas/meeting_agenda_item_collection_model.yml" + MeetingOutcomeCollectionModel: + "$ref": "./components/schemas/meeting_outcome_collection_model.yml" + MeetingOutcomeModel: + "$ref": "./components/schemas/meeting_outcome_model.yml" + MeetingOutcomeWriteModel: + "$ref": "./components/schemas/meeting_outcome_write_model.yml" MeetingCollectionModel: "$ref": "./components/schemas/meeting_collection_model.yml" MeetingModel: diff --git a/docs/api/apiv3/paths/meeting_agenda_item_outcome.yml b/docs/api/apiv3/paths/meeting_agenda_item_outcome.yml new file mode 100644 index 000000000000..00a6172cd06d --- /dev/null +++ b/docs/api/apiv3/paths/meeting_agenda_item_outcome.yml @@ -0,0 +1,190 @@ +# /api/v3/meetings/{meeting_id}/agenda_items/{agenda_item_id}/outcomes/{id} +--- +get: + summary: Get a meeting outcome + operationId: get_meeting_outcome + tags: + - Meetings + description: Retrieve an individual outcome of a meeting agenda item. + parameters: + - description: Meeting identifier + example: 1 + in: path + name: meeting_id + required: true + schema: + type: integer + - description: Agenda item identifier + example: 1 + in: path + name: agenda_item_id + required: true + schema: + type: integer + - description: Outcome identifier + example: 1 + in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/meeting_outcome_model.yml" + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the outcome, agenda item, or meeting does not exist or the client does not have sufficient permissions. + +patch: + summary: Update a meeting outcome + operationId: update_meeting_outcome + tags: + - Meetings + description: Updates the given meeting outcome. + parameters: + - description: Meeting identifier + example: 1 + in: path + name: meeting_id + required: true + schema: + type: integer + - description: Agenda item identifier + example: 1 + in: path + name: agenda_item_id + required: true + schema: + type: integer + - description: Outcome identifier + example: 1 + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "../components/schemas/meeting_outcome_write_model.yml" + responses: + '200': + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/meeting_outcome_model.yml" + '400': + $ref: "../components/responses/invalid_request_body.yml" + '403': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** manage outcomes + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the outcome, agenda item, or meeting does not exist. + '406': + $ref: "../components/responses/missing_content_type.yml" + '415': + $ref: "../components/responses/unsupported_media_type.yml" + '422': + description: |- + Returned if: + + * a constraint for a property was violated (`PropertyConstraintViolation`) + +delete: + summary: Delete a meeting outcome + operationId: delete_meeting_outcome + tags: + - Meetings + description: Deletes the outcome. + parameters: + - description: Meeting identifier + example: 1 + in: path + name: meeting_id + required: true + schema: + type: integer + - description: Agenda item identifier + example: 1 + in: path + name: agenda_item_id + required: true + schema: + type: integer + - description: Outcome identifier + example: 1 + in: path + name: id + required: true + schema: + type: integer + responses: + '204': + description: Returned if the outcome was successfully deleted + '403': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** manage outcomes + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the outcome, agenda item, or meeting does not exist. diff --git a/docs/api/apiv3/paths/meeting_agenda_item_outcomes.yml b/docs/api/apiv3/paths/meeting_agenda_item_outcomes.yml new file mode 100644 index 000000000000..13f5b8f66bea --- /dev/null +++ b/docs/api/apiv3/paths/meeting_agenda_item_outcomes.yml @@ -0,0 +1,118 @@ +# /api/v3/meetings/{meeting_id}/agenda_items/{agenda_item_id}/outcomes +--- +get: + summary: List meeting outcomes + operationId: list_meeting_outcomes + tags: + - Meetings + description: Lists all outcomes for the given meeting agenda item. + parameters: + - description: Meeting identifier + example: 1 + in: path + name: meeting_id + required: true + schema: + type: integer + - description: Agenda item identifier + example: 1 + in: path + name: agenda_item_id + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/meeting_outcome_collection_model.yml" + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the agenda item or meeting does not exist or the client does not have sufficient permissions to see it. + + **Required permission:** view meetings + +post: + summary: Create meeting outcome + operationId: create_meeting_outcome + tags: + - Meetings + description: Creates a new outcome for the given meeting agenda item. + parameters: + - description: Meeting identifier + example: 1 + in: path + name: meeting_id + required: true + schema: + type: integer + - description: Agenda item identifier + example: 1 + in: path + name: agenda_item_id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "../components/schemas/meeting_outcome_write_model.yml" + responses: + '201': + description: Created + content: + application/hal+json: + schema: + $ref: "../components/schemas/meeting_outcome_model.yml" + '400': + $ref: "../components/responses/invalid_request_body.yml" + '403': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** manage outcomes + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the agenda item or meeting does not exist or the client does not have sufficient permissions to see it. + '406': + $ref: "../components/responses/missing_content_type.yml" + '415': + $ref: "../components/responses/unsupported_media_type.yml" + '422': + description: |- + Returned if: + + * a constraint for a property was violated (`PropertyConstraintViolation`) diff --git a/modules/meeting/lib/api/v3/meeting_agenda_items/agenda_items_by_meeting_api.rb b/modules/meeting/lib/api/v3/meeting_agenda_items/agenda_items_by_meeting_api.rb index 7b1e6747eda2..c9b1f484bc56 100644 --- a/modules/meeting/lib/api/v3/meeting_agenda_items/agenda_items_by_meeting_api.rb +++ b/modules/meeting/lib/api/v3/meeting_agenda_items/agenda_items_by_meeting_api.rb @@ -57,6 +57,8 @@ class AgendaItemsByMeetingAPI < ::API::OpenProjectAPI patch &::API::V3::Utilities::Endpoints::Update.new(model: MeetingAgendaItem).mount delete &::API::V3::Utilities::Endpoints::Delete.new(model: MeetingAgendaItem).mount + + mount ::API::V3::MeetingOutcomes::OutcomesByAgendaItemAPI end end end diff --git a/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb b/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb index 9cc86852ece4..16d41286ba69 100644 --- a/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb +++ b/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb @@ -37,7 +37,10 @@ class MeetingAgendaItemRepresenter < ::API::Decorators::Single include API::Decorators::FormattableProperty include ::API::Caching::CachedRepresenter - self.to_eager_load = %i[author presenter work_package meeting_section meeting] + self.to_eager_load = [ + :author, :presenter, :work_package, :meeting_section, :meeting, + { outcomes: %i[author work_package] } + ] self_link id_attribute: ->(*) { [represented.meeting_id, represented.id] }, title_getter: ->(*) { represented.title } @@ -90,6 +93,22 @@ class MeetingAgendaItemRepresenter < ::API::Decorators::Single } } + associated_resources :outcomes, + getter: ->(*) { + represented.outcomes.map do |outcome| + ::API::V3::MeetingOutcomes::MeetingOutcomeRepresenter.new(outcome, current_user:) + end + }, + link: ->(*) { + represented.outcomes.map do |outcome| + { + href: api_v3_paths + .meeting_agenda_item_outcome(represented.meeting_id, represented.id, outcome.id), + title: outcome.id.to_s + } + end + } + date_time_property :created_at date_time_property :updated_at diff --git a/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_collection_representer.rb b/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_collection_representer.rb new file mode 100644 index 000000000000..8e4f46a0bf88 --- /dev/null +++ b/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_collection_representer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module MeetingOutcomes + class MeetingOutcomeCollectionRepresenter < ::API::Decorators::UnpaginatedCollection + end + end + end +end diff --git a/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_payload_representer.rb b/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_payload_representer.rb new file mode 100644 index 000000000000..f9866a468b9c --- /dev/null +++ b/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_payload_representer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module MeetingOutcomes + class MeetingOutcomePayloadRepresenter < MeetingOutcomeRepresenter + include ::API::Utilities::PayloadRepresenter + + cached_representer disabled: true + end + end + end +end diff --git a/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_representer.rb b/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_representer.rb new file mode 100644 index 000000000000..153353f19fef --- /dev/null +++ b/modules/meeting/lib/api/v3/meeting_outcomes/meeting_outcome_representer.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module MeetingOutcomes + class MeetingOutcomeRepresenter < ::API::Decorators::Single + include API::Decorators::LinkedResource + include API::Decorators::DateProperty + include API::Decorators::FormattableProperty + include ::API::Caching::CachedRepresenter + + self.to_eager_load = [{ meeting_agenda_item: :meeting }, :author, :work_package] + + self_link path: :meeting_agenda_item_outcome, + id_attribute: ->(*) { + [represented.meeting_agenda_item.meeting_id, represented.meeting_agenda_item_id, represented.id] + }, + title_getter: ->(*) { represented.id.to_s } + + property :id + + property :kind + + formattable_property :notes + + associated_resource :author, + v3_path: :user, + representer: ::API::V3::Users::UserRepresenter, + skip_render: ->(*) { represented.author_id.nil? } + + link :agendaItem do + { + href: api_v3_paths.meeting_agenda_item(represented.meeting_agenda_item.meeting_id, + represented.meeting_agenda_item_id), + title: represented.meeting_agenda_item.title + } + end + + associated_visible_resource :work_package + + date_time_property :created_at + date_time_property :updated_at + + def _type + "MeetingOutcome" + end + end + end + end +end diff --git a/modules/meeting/lib/api/v3/meeting_outcomes/outcomes_by_agenda_item_api.rb b/modules/meeting/lib/api/v3/meeting_outcomes/outcomes_by_agenda_item_api.rb new file mode 100644 index 000000000000..9ab379fad829 --- /dev/null +++ b/modules/meeting/lib/api/v3/meeting_outcomes/outcomes_by_agenda_item_api.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module MeetingOutcomes + class OutcomesByAgendaItemAPI < ::API::OpenProjectAPI + resources :outcomes do + get do + outcomes = @meeting_agenda_item.outcomes.includes(:author, :work_package, :meeting_agenda_item) + + MeetingOutcomeCollectionRepresenter.new(outcomes, + self_link: api_v3_paths + .meeting_agenda_item_outcomes(@meeting.id, @meeting_agenda_item.id), + current_user:) + end + + post(&::API::V3::Utilities::Endpoints::Create + .new(model: MeetingOutcome, + params_modifier: ->(params) { + params + .except(:meeting_agenda_item, :meeting_agenda_item_id) + .merge(meeting_agenda_item: @meeting_agenda_item) + }) + .mount) + + route_param :outcome_id, type: Integer, desc: "Outcome ID" do + after_validation do + @meeting_outcome = @meeting_agenda_item.outcomes.find(declared_params[:outcome_id]) + end + + get &::API::V3::Utilities::Endpoints::Show.new(model: MeetingOutcome).mount + + patch &::API::V3::Utilities::Endpoints::Update.new(model: MeetingOutcome).mount + + delete &::API::V3::Utilities::Endpoints::Delete.new(model: MeetingOutcome).mount + end + end + end + end + end +end diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 2da1ac11dbdb..c6012bc5099b 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -244,6 +244,14 @@ class Engine < ::Rails::Engine "#{meeting(meeting_id)}/agenda_items/#{id}" end + add_api_path :meeting_agenda_item_outcomes do |meeting_id, agenda_item_id| + "#{meeting_agenda_item(meeting_id, agenda_item_id)}/outcomes" + end + + add_api_path :meeting_agenda_item_outcome do |meeting_id, agenda_item_id, id| + "#{meeting_agenda_item_outcomes(meeting_id, agenda_item_id)}/#{id}" + end + add_api_path :meeting_sections do |meeting_id| "#{meeting(meeting_id)}/sections" end diff --git a/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb b/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb index d91a0db1f5b1..5d5bdb395745 100644 --- a/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb +++ b/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb @@ -44,6 +44,7 @@ let(:meeting) { create(:meeting, project:, author: current_user) } let!(:section) { create(:meeting_section, meeting:) } let!(:agenda_item) { create(:meeting_agenda_item, meeting:, meeting_section: section, author: current_user) } + let!(:outcome) { create(:meeting_outcome, meeting_agenda_item: agenda_item, author: current_user, notes: "Embedded outcome") } before do login_as current_user @@ -64,6 +65,10 @@ expect(last_response.body) .to have_json_size(1) .at_path("_embedded/elements") + + expect(last_response.body) + .to have_json_size(1) + .at_path("_embedded/elements/0/_embedded/outcomes") end context "without view_meetings permission" do @@ -128,6 +133,10 @@ expect(last_response.body) .to be_json_eql(agenda_item.id.to_json) .at_path("id") + + expect(last_response.body) + .to have_json_size(1) + .at_path("_embedded/outcomes") end context "with an item from another meeting" do @@ -158,7 +167,7 @@ it "renders the work package link as undisclosed" do expect(last_response.body) - .to be_json_eql(::API::V3::URN_UNDISCLOSED.to_json) + .to be_json_eql(API::V3::URN_UNDISCLOSED.to_json) .at_path("_links/workPackage/href") end end diff --git a/modules/meeting/spec/requests/api/v3/meeting_outcomes/outcomes_by_agenda_item_resource_spec.rb b/modules/meeting/spec/requests/api/v3/meeting_outcomes/outcomes_by_agenda_item_resource_spec.rb new file mode 100644 index 000000000000..dc0b3d6419aa --- /dev/null +++ b/modules/meeting/spec/requests/api/v3/meeting_outcomes/outcomes_by_agenda_item_resource_spec.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Meeting Outcomes sub-resource", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, enabled_module_names: %w[meetings work_package_tracking]) } + + let(:permissions) { %i[view_meetings manage_outcomes] } + let(:current_user) do + create(:user, member_with_permissions: { project => permissions }) + end + let(:meeting) { create(:meeting, project:, author: current_user, state: :in_progress) } + let!(:section) { create(:meeting_section, meeting:) } + let!(:agenda_item) { create(:meeting_agenda_item, meeting:, meeting_section: section, author: current_user) } + let!(:outcome) { create(:meeting_outcome, meeting_agenda_item: agenda_item, author: current_user, notes: "Initial outcome") } + + before do + login_as current_user + end + + describe "GET /api/v3/meetings/:meeting_id/agenda_items/:agenda_item_id/outcomes" do + let(:path) { api_v3_paths.meeting_agenda_item_outcomes(meeting.id, agenda_item.id) } + + before { get path } + + it "returns 200 and lists outcomes" do + expect(last_response).to have_http_status(:ok) + + expect(last_response.body) + .to be_json_eql("Collection".to_json) + .at_path("_type") + + expect(last_response.body) + .to have_json_size(1) + .at_path("_embedded/elements") + end + + context "with an agenda item from another meeting" do + let(:other_meeting) { create(:meeting, project:, author: current_user) } + let(:path) { api_v3_paths.meeting_agenda_item_outcomes(other_meeting.id, agenda_item.id) } + + it "returns 404" do + expect(last_response).to have_http_status(:not_found) + end + end + + context "without view_meetings permission" do + let(:permissions) { [] } + + it "returns 404" do + expect(last_response).to have_http_status(:not_found) + end + end + + context "when an outcome 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) } + let!(:outcome) do + create(:meeting_outcome, + meeting_agenda_item: agenda_item, + author: current_user, + kind: :work_package, + work_package: private_work_package, + notes: nil) + end + + it "does not embed the inaccessible work package" do + expect(last_response.body).not_to have_json_path("_embedded/elements/0/_embedded/workPackage") + end + + it "renders the work package link as undisclosed" do + expect(last_response.body) + .to be_json_eql(API::V3::URN_UNDISCLOSED.to_json) + .at_path("_embedded/elements/0/_links/workPackage/href") + end + end + end + + describe "POST /api/v3/meetings/:meeting_id/agenda_items/:agenda_item_id/outcomes" do + let(:path) { api_v3_paths.meeting_agenda_item_outcomes(meeting.id, agenda_item.id) } + let(:body) do + { + kind: "information", + notes: { raw: "Outcome created via API" } + }.to_json + end + + subject(:response) { post path, body } + + it "responds with 201" do + expect(response).to have_http_status(:created) + end + + it "creates the outcome" do + response + expect(agenda_item.outcomes.find_by(notes: "Outcome created via API")).to be_present + end + + it "returns the created outcome" do + expect(response.body) + .to be_json_eql("MeetingOutcome".to_json) + .at_path("_type") + + expect(response.body) + .to be_json_eql("Outcome created via API".to_json) + .at_path("notes/raw") + end + + context "without manage_outcomes permission" do + let(:permissions) { %i[view_meetings] } + + it "returns 403" do + expect(response).to have_http_status(:forbidden) + end + end + + context "when creating a work package outcome" do + let(:permissions) { %i[view_meetings manage_outcomes view_work_packages] } + let(:work_package) { create(:work_package, project:) } + let(:body) do + { + kind: "work_package", + _links: { + workPackage: { + href: api_v3_paths.work_package(work_package.id) + } + } + }.to_json + end + + it "creates the linked outcome", :aggregate_failures do + expect { response }.to change(agenda_item.outcomes, :count).by(1) + expect(response).to have_http_status(:created) + + expect(response.body) + .to be_json_eql(api_v3_paths.work_package(work_package.id).to_json) + .at_path("_links/workPackage/href") + + created_outcome = agenda_item.outcomes.order(:id).last + expect(created_outcome.kind).to eq("work_package") + expect(created_outcome.work_package).to eq(work_package) + end + end + + context "when creating a work package outcome linked to an inaccessible work package" do + let(:permissions) { %i[view_meetings manage_outcomes view_work_packages] } + let(:private_project) { create(:project, public: false) } + let(:private_work_package) { create(:work_package, project: private_project) } + let(:body) do + { + kind: "work_package", + _links: { + workPackage: { + href: api_v3_paths.work_package(private_work_package.id) + } + } + }.to_json + end + + it "does not create the outcome", :aggregate_failures do + expect { response }.not_to change(agenda_item.outcomes, :count) + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe "GET /api/v3/meetings/:meeting_id/agenda_items/:agenda_item_id/outcomes/:id" do + let(:path) { api_v3_paths.meeting_agenda_item_outcome(meeting.id, agenda_item.id, outcome.id) } + + before { get path } + + it "returns 200 and the outcome" do + expect(last_response).to have_http_status(:ok) + + expect(last_response.body) + .to be_json_eql("MeetingOutcome".to_json) + .at_path("_type") + + expect(last_response.body) + .to be_json_eql(outcome.id.to_json) + .at_path("id") + end + + context "with an outcome from another agenda item" do + let(:other_agenda_item) { create(:meeting_agenda_item, meeting:, meeting_section: section, author: current_user) } + let(:path) { api_v3_paths.meeting_agenda_item_outcome(meeting.id, other_agenda_item.id, outcome.id) } + + it "returns 404" do + expect(last_response).to have_http_status(:not_found) + end + end + + context "when the outcome 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) } + let!(:outcome) do + create(:meeting_outcome, + meeting_agenda_item: agenda_item, + author: current_user, + kind: :work_package, + work_package: private_work_package, + notes: nil) + end + + it "renders the work package link as undisclosed", :aggregate_failures do + expect(last_response).to have_http_status(:ok) + + expect(last_response.body) + .to be_json_eql(API::V3::URN_UNDISCLOSED.to_json) + .at_path("_links/workPackage/href") + + expect(last_response.body).not_to have_json_path("_embedded/workPackage") + + end + end + end + + describe "PATCH /api/v3/meetings/:meeting_id/agenda_items/:agenda_item_id/outcomes/:id" do + let(:path) { api_v3_paths.meeting_agenda_item_outcome(meeting.id, agenda_item.id, outcome.id) } + let(:body) do + { + notes: { raw: "Updated outcome" } + }.to_json + end + + subject(:response) { patch path, body } + + it "updates the outcome", :aggregate_failures do + expect(response).to have_http_status(:ok) + expect(outcome.reload.notes).to eq("Updated outcome") + end + + context "without manage_outcomes permission" do + let(:permissions) { %i[view_meetings] } + + it "returns 403" do + expect(response).to have_http_status(:forbidden) + end + end + end + + describe "DELETE /api/v3/meetings/:meeting_id/agenda_items/:agenda_item_id/outcomes/:id" do + let(:path) { api_v3_paths.meeting_agenda_item_outcome(meeting.id, agenda_item.id, outcome.id) } + + before { delete path } + + subject { last_response } + + context "with required permissions" do + it "deletes the outcome", :aggregate_failures do + expect(subject.status).to eq 204 + expect(MeetingOutcome).not_to exist(outcome.id) + end + end + + context "without manage_outcomes permission" do + let(:permissions) { %i[view_meetings] } + + it_behaves_like "unauthorized access" + end + end +end From 44ee0283590e321c24d69e131d58e22b1ff7aa3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 May 2026 20:08:25 +0200 Subject: [PATCH 2/3] Add missing contract_actions for meetings and outcomes --- .../meeting/lib/open_project/meeting/engine.rb | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index c6012bc5099b..c7894770d413 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -50,7 +50,8 @@ class Engine < ::Rails::Engine work_package_meetings_tab: %i[index count], recurring_meetings: %i[index show new create download_ics] }, - permissible_on: :project + permissible_on: :project, + contract_actions: { meetings: %i[read] } permission :create_meetings, { meetings: %i[new create copy new_dialog fetch_timezone fetch_templates], @@ -74,7 +75,8 @@ class Engine < ::Rails::Engine }, permissible_on: :project, dependencies: :view_meetings, - require: :member + require: :member, + contract_actions: { meetings: %i[update] } permission :delete_meetings, { meetings: %i[delete_dialog destroy], @@ -82,7 +84,8 @@ class Engine < ::Rails::Engine }, permissible_on: :project, dependencies: :view_meetings, - require: :member + require: :member, + contract_actions: { meetings: %i[destroy] } permission :send_meeting_invites_and_outcomes, { meetings: %i[notify icalendar] }, permissible_on: :project, @@ -99,14 +102,16 @@ class Engine < ::Rails::Engine }, permissible_on: :project, # TODO: Change this to :meeting when MeetingRoles are available dependencies: :view_meetings, - require: :member + require: :member, + contract_actions: { meeting_agenda_items: %i[create update destroy] } permission :manage_outcomes, { meeting_outcomes: %i[new cancel_new create edit cancel_edit update destroy] }, permissible_on: :project, dependencies: :view_meetings, - require: :member + require: :member, + contract_actions: { meeting_outcomes: %i[create update destroy] } end Redmine::Search.map do |search| From 5e67a9764ad6e13305f76ad1e6e1a6b9a67a9e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sat, 30 May 2026 07:48:48 +0200 Subject: [PATCH 3/3] Embed outcomes at the agenda item collection level --- .../meeting_agenda_item_collection_representer.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_collection_representer.rb b/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_collection_representer.rb index 949509f04370..23b858fa36fa 100644 --- a/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_collection_representer.rb +++ b/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_collection_representer.rb @@ -32,6 +32,18 @@ module API module V3 module MeetingAgendaItems class MeetingAgendaItemCollectionRepresenter < ::API::Decorators::UnpaginatedCollection + # Force `embed_links` on the elements so that their associated resources + # (most notably the outcomes) are embedded by default, mirroring the + # single agenda item endpoint. All of these associations are already + # eager loaded by the element representer, so this adds no extra queries. + collection :elements, + getter: ->(*) { + represented.map do |model| + element_decorator.create(model, current_user:, embed_links: true) + end + }, + exec_context: :decorator, + embedded: true end end end