diff --git a/app/models/queries/custom_fields/hierarchy/item_query.rb b/app/models/queries/custom_fields/hierarchy/item_query.rb new file mode 100644 index 000000000000..a1bd47a6eceb --- /dev/null +++ b/app/models/queries/custom_fields/hierarchy/item_query.rb @@ -0,0 +1,42 @@ +#-- 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 Queries + module CustomFields + module Hierarchy + class ItemQuery + include ::Queries::BaseQuery + include ::Queries::UnpersistedQuery + + def self.model + CustomField::Hierarchy::Item + end + end + end + end +end diff --git a/app/services/custom_fields/hierarchy/hierarchical_item_service.rb b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb index e0274491d148..49b5f882da13 100644 --- a/app/services/custom_fields/hierarchy/hierarchical_item_service.rb +++ b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb @@ -112,11 +112,23 @@ def reorder_item(item:, new_sort_order:) Success() end - def soft_delete_item(item) + def soft_delete_item(item:) # Soft delete the item and children raise NotImplementedError end + def hashed_subtree(item:, depth:) + if depth >= 0 + Success(item.hash_tree(limit_depth: depth + 1)) + else + Success(item.hash_tree) + end + end + + def descendant_of?(item:, parent:) + item.descendant_of?(parent) ? Success() : Failure() + end + private def create_root_item(custom_field) diff --git a/config/locales/en.yml b/config/locales/en.yml index c47247097270..04cb1b4cbb6e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -295,19 +295,25 @@ en: dry_validation: errors: + integer?: "must be an integer" + filled?: "must be filled" + greater_or_equal_zero: "must be greater or equal to 0" + not_found: "not found" rules: + item: + root_item: "cannot be a root item" + not_persisted: "must be an already existing item" label: - filled?: "must be filled" not_unique: "must be unique within the same hierarchy level" short: not_unique: "must be unique within the same hierarchy level" - item: - root_item: "cannot be a root item" - not_persisted: "must be an already existing item" + parent: + not_descendant: "must be a descendant of the hierarchy root" rules: + depth: "Depth" + item: "Item" label: "Label" short: "Short" - item: "Item" parent: "Parent" global_search: @@ -1599,7 +1605,7 @@ en: create_new_page: "Wiki page" date: - abbr_day_names: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + abbr_day_names: [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ] abbr_month_names: [ ~, diff --git a/docs/api/apiv3/components/examples/hierarchy_item_collection_filtered_response.yml b/docs/api/apiv3/components/examples/hierarchy_item_collection_filtered_response.yml new file mode 100644 index 000000000000..f56cebe3018d --- /dev/null +++ b/docs/api/apiv3/components/examples/hierarchy_item_collection_filtered_response.yml @@ -0,0 +1,62 @@ +description: |- + Filtered response of a hierarchy structure starting at a specific parent and restricting the depth. +value: + _type: Collection + total: 3 + count: 3 + _embedded: + elements: + - _type: HierarchyItem + id: 1338 + label: Stormtroopers + short: ST + depth: 1 + _links: + self: + href: /api/v3/custom_field_items/1338 + name: Stormtroopers + parent: + href: /api/v3/custom_field_items/1337 + name: null + children: + - href: /api/v3/custom_field_items/1340 + name: ST-377 + - href: /api/v3/custom_field_items/1341 + name: ST-422 + - _type: HierarchyItem + id: 1340 + label: ST-377 + short: null + depth: 2 + _links: + self: + href: /api/v3/custom_field_items/1340 + name: ST-377 + parent: + href: /api/v3/custom_field_items/1338 + name: Stormtroopers + children: + - href: /api/v3/custom_field_items/1480 + name: ST-377-200 + - href: /api/v3/custom_field_items/1481 + name: ST-377-201 + - _type: HierarchyItem + id: 1341 + label: ST-422 + short: null + depth: 2 + _links: + self: + href: /api/v3/custom_field_items/1341 + name: ST-422 + parent: + href: /api/v3/custom_field_items/1338 + name: Stormtroopers + children: + - href: /api/v3/custom_field_items/1580 + name: ST-422-137 + - href: /api/v3/custom_field_items/1581 + name: ST-422-138 + _links: + self: + href: /api/v3/custom_field/42/items?parent=1338&depth=1 diff --git a/docs/api/apiv3/components/examples/hierarchy_item_collection_response.yml b/docs/api/apiv3/components/examples/hierarchy_item_collection_response.yml new file mode 100644 index 000000000000..0fd692dd746e --- /dev/null +++ b/docs/api/apiv3/components/examples/hierarchy_item_collection_response.yml @@ -0,0 +1,51 @@ +description: |- + Simple response of a hierarchy structure starting at root. +value: + _type: Collection + total: 37 + count: 37 + _embedded: + elements: + - _type: HierarchyItem + id: 1337 + label: null + short: null + depth: 0 + _links: + self: + href: /api/v3/custom_field_items/1337 + name: null + parent: + href: null + children: + - href: /api/v3/custom_field_items/1338 + name: Stormtroopers + - href: /api/v3/custom_field_items/1339 + name: Dark Troopers + - _type: HierarchyItem + id: 1338 + label: Stormtroopers + short: ST + depth: 1 + _links: + self: + href: /api/v3/custom_field_items/1338 + name: Stormtroopers + parent: + href: /api/v3/custom_field_items/1337 + name: null + children: + - href: /api/v3/custom_field_items/1340 + name: ST-377 + - href: /api/v3/custom_field_items/1341 + name: ST-422 + - _type: HierarchyItem + _hint: hierarchy item shortened for brevity + id: 1340 + label: ST-377 + short: null + depth: 2 + - _hint: hierarchy item shortened for brevity + _links: + self: + href: /api/v3/custom_field/42/items diff --git a/docs/api/apiv3/components/examples/hierarchy_item_response.yml b/docs/api/apiv3/components/examples/hierarchy_item_response.yml new file mode 100644 index 000000000000..17568d048ca7 --- /dev/null +++ b/docs/api/apiv3/components/examples/hierarchy_item_response.yml @@ -0,0 +1,20 @@ +description: |- + Simple response of a single item of a custom field of type hierarchy. +value: + _type: HierarchyItem + id: 1338 + label: Stormtroopers + short: ST + depth: 1 + _links: + self: + href: /api/v3/custom_field_items/1338 + name: Stormtroopers + parent: + href: /api/v3/custom_field_items/1337 + name: null + children: + - href: /api/v3/custom_field_items/1340 + name: ST-377 + - href: /api/v3/custom_field_items/1341 + name: ST-422 diff --git a/docs/api/apiv3/components/schemas/hierarchy_item_collection_model.yml b/docs/api/apiv3/components/schemas/hierarchy_item_collection_model.yml new file mode 100644 index 000000000000..c6b2a5894737 --- /dev/null +++ b/docs/api/apiv3/components/schemas/hierarchy_item_collection_model.yml @@ -0,0 +1,30 @@ +# Schema: HierarchyItemCollectionModel +--- +allOf: + - $ref: './collection_model.yml' + - type: object + required: + - _links + - _embedded + properties: + _links: + type: object + required: + - self + properties: + self: + allOf: + - $ref: './link.yml' + - description: |- + This hierarchy item collection + + **Resource**: HierarchyItemCollectionModel + _embedded: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: './hierarchy_item_read_model.yml' diff --git a/docs/api/apiv3/components/schemas/hierarchy_item_read_model.yml b/docs/api/apiv3/components/schemas/hierarchy_item_read_model.yml new file mode 100644 index 000000000000..6b427cddf872 --- /dev/null +++ b/docs/api/apiv3/components/schemas/hierarchy_item_read_model.yml @@ -0,0 +1,46 @@ +# Schema: HierarchyItemReadModel +--- +type: object +properties: + _type: + type: string + enum: + - HierarchyItem + id: + type: integer + description: Hierarchy item identifier + label: + type: string + description: The label of the hierarchy item + short: + type: string + description: The short name of the hierarchy item + depth: + type: integer + description: The hierarchy depth. The root item has a depth of 0. + _links: + type: object + properties: + self: + allOf: + - $ref: './link.yml' + - description: |- + This hierarchy item + + **Resource**: HierarchyItem + parent: + allOf: + - $ref: './link.yml' + - description: |- + The hierarchy item that is the parent of the current hierarchy item + + **Resource**: HierarchyItem + children: + type: array + items: + allOf: + - $ref: './link.yml' + - description: |- + A hierarchy item that is a child of the current hierarchy item + + **Resource**: HierarchyItem diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 9175c3f08b8b..8b08defb7108 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -186,6 +186,10 @@ paths: "$ref": "./paths/custom_action.yml" "/api/v3/custom_actions/{id}/execute": "$ref": "./paths/custom_action_execute.yml" + "/api/v3/custom_fields/{id}/items": + "$ref": "./paths/custom_field_items.yml" + "/api/v3/custom_field_items/{id}": + "$ref": "./paths/custom_field_item.yml" "/api/v3/custom_options/{id}": "$ref": "./paths/custom_option.yml" "/api/v3/days/non_working": @@ -489,6 +493,12 @@ components: $ref: "./components/examples/grid-simple-response.yml" GroupResponse: $ref: "./components/examples/group-response.yml" + HierarchyItemCollectionFilteredResponse: + $ref: "./components/examples/hierarchy_item_collection_filtered_response.yml" + HierarchyItemCollectionResponse: + $ref: "./components/examples/hierarchy_item_collection_response.yml" + HierarchyItemResponse: + $ref: "./components/examples/hierarchy_item_response.yml" MembershipCreateRequestCustomMessage: $ref: "./components/examples/membership-create-request-custom-message.yml" MembershipCreateRequestGlobalRole: @@ -673,6 +683,10 @@ components: "$ref": "./components/schemas/help_text_collection_model.yml" HelpTextModel: "$ref": "./components/schemas/help_text_model.yml" + HierarchyItemCollectionModel: + "$ref": "./components/schemas/hierarchy_item_collection_model.yml" + HierarchyItemReadModel: + "$ref": "./components/schemas/hierarchy_item_read_model.yml" Link: "$ref": "./components/schemas/link.yml" List_actionsModel: diff --git a/docs/api/apiv3/paths/custom_field_item.yml b/docs/api/apiv3/paths/custom_field_item.yml new file mode 100644 index 000000000000..c1ac90dda38b --- /dev/null +++ b/docs/api/apiv3/paths/custom_field_item.yml @@ -0,0 +1,49 @@ +# /api/v3/custom_field_items/{id} +--- +get: + summary: Get a custom field hierarchy item + operationId: get_custom_field_item + description: |- + Retrieves a single custom field item specified by its unique identifier. + parameters: + - name: id + description: The custom field item's unique identifier + in: path + example: '42' + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/hal+json: + schema: + $ref: '../components/schemas/hierarchy_item_read_model.yml' + examples: + 'simple response': + $ref: '../components/examples/hierarchy_item_response.yml' + '403': + description: Returned if the user is not logged in. + 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. + '404': + description: Returned if the custom field item does not exist or the user is not logged in. + 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. diff --git a/docs/api/apiv3/paths/custom_field_items.yml b/docs/api/apiv3/paths/custom_field_items.yml new file mode 100644 index 000000000000..e51e9e3bffe2 --- /dev/null +++ b/docs/api/apiv3/paths/custom_field_items.yml @@ -0,0 +1,87 @@ +# /api/v3/custom_field/{id}/items +--- +get: + summary: Get the custom field hierarchy items + operationId: get_custom_field_items + description: |- + Retrieves the hierarchy of custom fields. + + The hierarchy is a tree structure of hierarchy items. It is represented as a flat list of items, where each item + has a reference to its parent and children. The list is ordered in a depth-first manner. The first item is the + requested parent. If parent was unset, the root item is returned as first element. + + Passing the `depth` query parameter allows to limit the depth of the hierarchy. If the depth is unset, the full + hierarchy tree is returned. If the depth is set to `0`, only the requested parent is returned. Any other positive + integer will return the number of children levels specified by this value. + + This endpoint only returns, if the custom field is of type `hierarchy`. + parameters: + - name: id + description: The custom field's unique identifier + in: path + example: '42' + required: true + schema: + type: integer + - name: parent + description: The identifier of the parent hierarchy item + in: query + example: '1337' + required: false + schema: + type: integer + - name: depth + description: The level of hierarchy depth + in: query + example: '1' + required: false + schema: + type: integer + responses: + '200': + description: OK + content: + application/hal+json: + schema: + $ref: '../components/schemas/hierarchy_item_collection_model.yml' + examples: + 'simple response': + $ref: '../components/examples/hierarchy_item_collection_response.yml' + 'filtered response': + $ref: '../components/examples/hierarchy_item_collection_filtered_response.yml' + '403': + description: Returned if the user is not logged in. + 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. + '404': + description: Returned if the custom field does not exist. + 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. + '422': + description: Returned if the custom field is not of type hierarchy. + content: + application/hal+json: + schema: + $ref: '../components/schemas/error_response.yml' + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:UnprocessableContent + message: The requested custom field resource is of wrong type. diff --git a/lib/api/errors/unprocessable_content.rb b/lib/api/errors/unprocessable_content.rb new file mode 100644 index 000000000000..2b2eaf5c4e9c --- /dev/null +++ b/lib/api/errors/unprocessable_content.rb @@ -0,0 +1,36 @@ +#-- 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 Errors + class UnprocessableContent < ErrorBase + identifier "UnprocessableContent" + code 422 + end + end +end diff --git a/lib/api/v3/custom_fields/custom_fields_api.rb b/lib/api/v3/custom_fields/custom_fields_api.rb new file mode 100644 index 000000000000..a8332356928d --- /dev/null +++ b/lib/api/v3/custom_fields/custom_fields_api.rb @@ -0,0 +1,47 @@ +#-- 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 CustomFields + class CustomFieldsAPI < ::API::OpenProjectAPI + resource :custom_fields do + route_param :id, type: Integer, desc: "Custom Field ID" do + after_validation do + authorize_logged_in + + @custom_field = CustomField.find(params[:id]) + end + + mount API::V3::CustomFields::Hierarchy::ItemsAPI + end + end + end + end + end +end diff --git a/lib/api/v3/custom_fields/hierarchy/get_items_parameter_contract.rb b/lib/api/v3/custom_fields/hierarchy/get_items_parameter_contract.rb new file mode 100644 index 000000000000..3f7f1bab1fb7 --- /dev/null +++ b/lib/api/v3/custom_fields/hierarchy/get_items_parameter_contract.rb @@ -0,0 +1,72 @@ +# 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 CustomFields + module Hierarchy + class GetItemsParameterContract < Dry::Validation::Contract + config.messages.backend = :i18n + + option :hierarchy_root + + params do + optional(:parent).filled(:integer) + optional(:depth).filled(:integer) + end + + rule(:parent) do + next unless key? + + parent_item = ::CustomField::Hierarchy::Item.find_by(id: value) + + if parent_item.nil? + key.failure(:not_found) + elsif persistence_service.descendant_of?(item: parent_item, parent: hierarchy_root).failure? + key.failure(:not_descendant) + end + end + + rule(:depth) do + next unless key? + + key.failure(:greater_or_equal_zero) if value < 0 + end + + private + + def persistence_service + @persistence_service ||= ::CustomFields::Hierarchy::HierarchicalItemService.new + end + end + end + end + end +end diff --git a/lib/api/v3/custom_fields/hierarchy/hierarchy_item_collection_representer.rb b/lib/api/v3/custom_fields/hierarchy/hierarchy_item_collection_representer.rb new file mode 100644 index 000000000000..badb8cad2e77 --- /dev/null +++ b/lib/api/v3/custom_fields/hierarchy/hierarchy_item_collection_representer.rb @@ -0,0 +1,38 @@ +#-- 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 CustomFields + module Hierarchy + class HierarchyItemCollectionRepresenter < ::API::Decorators::UnpaginatedCollection + end + end + end + end +end diff --git a/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb b/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb index bf1c63b26e25..fddbbd2a3f44 100644 --- a/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb +++ b/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb @@ -35,7 +35,36 @@ def _type "HierarchyItem" end + self_link path: :custom_field_item, + title_getter: ->(*) { represented.label } + property :id + + property :label, render_nil: true + + property :short, render_nil: true + + property :depth + + link :parent do + next if represented.root? + + parent = represented.parent + + { + href: api_v3_paths.custom_field_item(parent.id), + title: parent.label + } + end + + links :children do + represented.children.map do |child| + { + href: api_v3_paths.custom_field_item(child.id), + title: child.label + } + end + end end end end diff --git a/lib/api/v3/custom_fields/hierarchy/item_api.rb b/lib/api/v3/custom_fields/hierarchy/item_api.rb new file mode 100644 index 000000000000..d0ce2d51b6ae --- /dev/null +++ b/lib/api/v3/custom_fields/hierarchy/item_api.rb @@ -0,0 +1,53 @@ +#-- 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 CustomFields + module Hierarchy + class ItemAPI < ::API::OpenProjectAPI + resource :custom_field_items do + route_param :id, type: Integer, desc: "Custom Field Item ID" do + after_validation do + authorize_logged_in + + @custom_field_item = CustomField::Hierarchy::Item.find(params[:id]) + end + + get &::API::V3::Utilities::Endpoints::Show + .new(model: CustomField::Hierarchy::Item, + render_representer: HierarchyItemRepresenter, + instance_generator: ->(*) { @custom_field_item }) + .mount + end + end + end + end + end + end +end diff --git a/lib/api/v3/custom_fields/hierarchy/items_api.rb b/lib/api/v3/custom_fields/hierarchy/items_api.rb new file mode 100644 index 000000000000..101e6fca6008 --- /dev/null +++ b/lib/api/v3/custom_fields/hierarchy/items_api.rb @@ -0,0 +1,131 @@ +#-- 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 CustomFields + module Hierarchy + class ItemsAPI < ::API::OpenProjectAPI + include Dry::Monads[:result] + + helpers do + def flatten_tree_hash(hash) + flat_list = [] + queue = [hash] + + # From the service we get a hashed tree like this: + # {:a => {:b => {:c1 => {:d1 => {}}, :c2 => {:d2 => {}}}, :b2 => {}}} + # We flatten it depth first to this result list: + # [:a, :b, :c1, :d1, :c2, :d2, :b2] + + while queue.any? + current = queue.shift + item, children = current.shift + flat_list << item + queue.unshift(current) unless current.empty? + queue.unshift(children) unless children.empty? + end + + flat_list + end + + def item_list(query) + hierarchy_root = get_hierarchy_root(query) + + validation = GetItemsParameterContract.new(hierarchy_root:).call(params) + handle_validation_errors(validation) if validation.failure? + + start_item = ::CustomField::Hierarchy::Item.find_by(id: validation[:parent]) || hierarchy_root + depth = validation[:depth] || -1 + + flat_tree(start_item, depth) + end + + def flat_tree(item, depth) + sub_tree = ::CustomFields::Hierarchy::HierarchicalItemService + .new + .hashed_subtree(item:, depth:) + .either( + ->(value) { value }, + ->(error) do + msg = "#{I18n.t('api_v3.errors.code_500')} #{error}" + raise ::API::Errors::InternalError.new(msg) + end + ) + + flatten_tree_hash(sub_tree) + end + + def get_hierarchy_root(query) + items = query.results.where(custom_field: @custom_field) + if items.count != 1 + msg = "corrupt data found, invalid number of hierarchy roots for custom field" + raise ::API::Errors::InternalError.new(msg) + end + + items.first + end + + def handle_validation_errors(validation) + message = "" + validation.errors(full: true).to_h.each_value do |value| + message += "#{value.join(', ')}\n" + end + + raise ::API::Errors::InvalidQuery.new(message.chomp) + end + end + + resource :items do + after_validation do + unless @custom_field.field_format_hierarchy? + message = "Hierarchy items do only exists for custom fields of type hierarchy." + raise ::API::Errors::UnprocessableContent.new(message) + end + end + + get do + query = ParamsToQueryService.new(::CustomField::Hierarchy::Item, + current_user, + query_class: ::Queries::CustomFields::Hierarchy::ItemQuery) + .call(params) + + unless query.valid? + message = I18n.t("api_v3.errors.missing_or_malformed_parameter", parameter: "filters") + raise ::API::Errors::InvalidQuery.new(message) + end + + self_link = api_v3_paths.custom_field_items(@custom_field.id, params[:parent], params[:depth]) + HierarchyItemCollectionRepresenter.new(item_list(query), self_link:, current_user:) + end + end + end + end + end + end +end diff --git a/lib/api/v3/root.rb b/lib/api/v3/root.rb index d299b19fd7a2..a30041f486f1 100644 --- a/lib/api/v3/root.rb +++ b/lib/api/v3/root.rb @@ -46,11 +46,13 @@ class Root < ::API::OpenProjectAPI mount ::API::V3::Actions::ActionsAPI mount ::API::V3::Activities::ActivitiesAPI mount ::API::V3::Attachments::AttachmentsAPI - mount ::API::V3::Capabilities::CapabilitiesAPI mount ::API::V3::Backups::BackupsAPI + mount ::API::V3::Capabilities::CapabilitiesAPI mount ::API::V3::Categories::CategoriesAPI mount ::API::V3::Configuration::ConfigurationAPI mount ::API::V3::CustomActions::CustomActionsAPI + mount ::API::V3::CustomFields::CustomFieldsAPI + mount ::API::V3::CustomFields::Hierarchy::ItemAPI mount ::API::V3::CustomOptions::CustomOptionsAPI mount ::API::V3::Days::DaysAPI mount ::API::V3::Grids::GridsAPI diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index c3a04f70a83d..1bf20c07963e 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -44,11 +44,13 @@ def self.index(name, path = nil) RequestStore.store[:"cached_#{plural_name}"] ||= "#{root}/#{path}" end end + private_class_method :index def self.show(name, path = name) define_singleton_method(name) { |id| build_path(path, id) } end + private_class_method :show def self.create_form(name) @@ -56,11 +58,13 @@ def self.create_form(name) RequestStore.store[:"cached_create_#{name}_form"] ||= build_path(name, "form") end end + private_class_method :create_form def self.update_form(name) define_singleton_method(:"#{name}_form") { |id| build_path(name, id, "form") } end + private_class_method :update_form def self.schema(name) @@ -68,11 +72,13 @@ def self.schema(name) RequestStore.store[:"cached_#{name}_schema"] ||= build_path(name, "schema") end end + private_class_method :schema def self.build_path(name, *kwargs) [root, name.to_s.pluralize, *kwargs].compact.join("/") end + private_class_method :build_path def self.resources(name, @@ -83,6 +89,7 @@ def self.resources(name, send(method, name) end end + private_class_method :resources # Determining the root_path on every url we want to render is @@ -200,6 +207,24 @@ def self.custom_action_execute(id) "#{custom_action(id)}/execute" end + def self.custom_field(id) + "#{root}/custom_fields/#{id}" + end + + def self.custom_field_item(id) + "#{root}/custom_field_items/#{id}" + end + + def self.custom_field_items(id, parent = nil, depth = nil) + query = { parent:, depth: }.compact_blank.to_query + + if query.present? + "#{custom_field(id)}/items?#{query}" + else + "#{custom_field(id)}/items" + end + end + def self.custom_option(id) "#{root}/custom_options/#{id}" end @@ -511,9 +536,9 @@ def self.wiki_page(id) def self.work_package(id, timestamps: nil) "#{root}/work_packages/#{id}" + \ - if (param_value = timestamps_to_param_value(timestamps)).present? && Array(timestamps).any?(&:historic?) - "?#{{ timestamps: param_value }.to_query}" - end.to_s + if (param_value = timestamps_to_param_value(timestamps)).present? && Array(timestamps).any?(&:historic?) + "?#{{ timestamps: param_value }.to_query}" + end.to_s end def self.work_package_schema(project_id, type_id) diff --git a/spec/lib/api/v3/utilities/resource_link_generator_spec.rb b/spec/lib/api/v3/utilities/resource_link_generator_spec.rb index b9c0f035527c..867e0f3ae498 100644 --- a/spec/lib/api/v3/utilities/resource_link_generator_spec.rb +++ b/spec/lib/api/v3/utilities/resource_link_generator_spec.rb @@ -55,7 +55,7 @@ end it "returns nil for unsupported records" do - record = create(:custom_field) + record = create(:oauth_client_token) expect(subject.make_link(record)).to be_nil end diff --git a/spec/services/custom_fields/hierarchy/hierarchical_item_service_spec.rb b/spec/services/custom_fields/hierarchy/hierarchical_item_service_spec.rb index e1266f3e6497..b75bad74bccb 100644 --- a/spec/services/custom_fields/hierarchy/hierarchical_item_service_spec.rb +++ b/spec/services/custom_fields/hierarchy/hierarchical_item_service_spec.rb @@ -180,7 +180,7 @@ end end - describe "reorder_item" do + describe "#reorder_item" do let!(:lando) { service.insert_item(parent: root, label: "lando").value! } let!(:chewbacca) { service.insert_item(parent: root, label: "AWOOO").value! } @@ -240,4 +240,26 @@ expect(chewbacca.reload.sort_order).to eq(2) end end + + describe "#hashed_subtree" do + let!(:lando) { service.insert_item(parent: root, label: "lando").value! } + let!(:chewbacca) { service.insert_item(parent: root, label: "AWOOO").value! } + let!(:lowbacca) { service.insert_item(parent: chewbacca, label: "ARWWWW").value! } + + it "produces a hash version of the tree" do + subtree = service.hashed_subtree(item: root, depth: -1) + + expect(subtree.value!).to be_a(Hash) + expect(subtree.value![root].size).to eq(3) + expect(subtree.value![root][lando]).to be_empty + expect(subtree.value![root][chewbacca][lowbacca]).to be_empty + end + + it "produces a hash version of a sub tree with limited depth" do + subtree = service.hashed_subtree(item: chewbacca, depth: 0) + + expect(subtree.value!).to be_a(Hash) + expect(subtree.value![chewbacca]).to be_empty + end + end end