diff --git a/app/models/concerns/labelable.rb b/app/models/concerns/labelable.rb new file mode 100644 index 000000000000..b0ca71fd92b7 --- /dev/null +++ b/app/models/concerns/labelable.rb @@ -0,0 +1,40 @@ +# 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 Labelable + extend ActiveSupport::Concern + + included do + has_many :labelings, as: :labelable, dependent: :delete_all + has_many :labels, -> { order(:id) }, through: :labelings + + scope :labeled_with, ->(label) { joins(:labelings).where(labelings: { label_id: label }) } + end +end diff --git a/app/models/label.rb b/app/models/label.rb new file mode 100644 index 000000000000..c6c2b8fb2809 --- /dev/null +++ b/app/models/label.rb @@ -0,0 +1,45 @@ +# 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. +#++ + +class Label < ApplicationRecord + belongs_to :author, class_name: "User" + has_many :labelings, dependent: :delete_all + + scope :with_usage_count, -> { + left_joins(:labelings) + .select("labels.*, COUNT(labelings.id) AS usage_count") + .group(:id) + } + + validates :name, + presence: true, + uniqueness: { case_sensitive: false }, + length: { maximum: 255 } +end diff --git a/app/models/labeling.rb b/app/models/labeling.rb new file mode 100644 index 000000000000..aded799ea5ef --- /dev/null +++ b/app/models/labeling.rb @@ -0,0 +1,36 @@ +# 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. +#++ + +class Labeling < ApplicationRecord + belongs_to :label + belongs_to :labelable, polymorphic: true + + validates :label_id, uniqueness: { scope: %i[labelable_type labelable_id] } +end diff --git a/app/models/work_package.rb b/app/models/work_package.rb index fe89e473c45e..6dccbcfc3281 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -48,6 +48,7 @@ class WorkPackage < ApplicationRecord include ::Scopes::Scoped include HasMembers include Remindable + include Labelable include OpenProject::Journal::AttachmentHelper diff --git a/config/initializers/replace_references_service.rb b/config/initializers/replace_references_service.rb index e24b5aedc475..2331572249f5 100644 --- a/config/initializers/replace_references_service.rb +++ b/config/initializers/replace_references_service.rb @@ -40,6 +40,7 @@ "PersistedQuery" => :principal_id, "PersistedView" => :principal_id, "::Doorkeeper::Application" => :owner_id, + "Label" => :author_id, "Message" => :author_id, "News" => :author_id, "::Notification" => :actor_id, diff --git a/db/migrate/20260914120000_create_labels.rb b/db/migrate/20260914120000_create_labels.rb new file mode 100644 index 000000000000..1e995581eb4f --- /dev/null +++ b/db/migrate/20260914120000_create_labels.rb @@ -0,0 +1,50 @@ +# 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. +#++ + +class CreateLabels < ActiveRecord::Migration[8.1] + def change + create_table :labels do |t| + t.string :name, null: false + t.references :author, null: false, foreign_key: { to_table: :users } + t.timestamps + end + add_index :labels, "LOWER(name)", unique: true, name: "index_labels_on_LOWER_name" + + create_table :labelings do |t| + t.references :label, null: false, foreign_key: { on_delete: :cascade }, index: false + t.references :labelable, polymorphic: true, null: false, index: false + t.timestamps + end + add_index :labelings, %i[labelable_type labelable_id label_id], + unique: true, + name: "index_labelings_on_labelable_and_label" + add_index :labelings, :label_id + end +end diff --git a/modules/bim/app/services/bim/bcf/issues/create_service.rb b/modules/bim/app/services/bim/bcf/issues/create_service.rb index 01f99ed8aafb..a13184a290b6 100644 --- a/modules/bim/app/services/bim/bcf/issues/create_service.rb +++ b/modules/bim/app/services/bim/bcf/issues/create_service.rb @@ -70,13 +70,13 @@ def use_work_package(links:, params:) ::WorkPackages::UpdateService .new(user:, model: work_package) - .call(**params) + .call(**params.except(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES)) end def create_work_package(params) ::WorkPackages::CreateService .new(user:) - .call(**params) + .call(**params.except(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES)) end def work_package_identifier_from_links(links) diff --git a/modules/bim/app/services/bim/bcf/issues/update_service.rb b/modules/bim/app/services/bim/bcf/issues/update_service.rb index 2a095becd4fa..f34f161b98cd 100644 --- a/modules/bim/app/services/bim/bcf/issues/update_service.rb +++ b/modules/bim/app/services/bim/bcf/issues/update_service.rb @@ -36,7 +36,7 @@ def before_perform(service_result) .new(model: model.work_package, user:, contract_class: ::WorkPackages::UpdateContract) - .call(**params) + .call(**params.except(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES)) if wp_call.success? self.params = params.slice(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES) diff --git a/spec/factories/label_factory.rb b/spec/factories/label_factory.rb new file mode 100644 index 000000000000..1877cb842e4e --- /dev/null +++ b/spec/factories/label_factory.rb @@ -0,0 +1,36 @@ +# 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. +#++ + +FactoryBot.define do + factory :label do + sequence(:name) { "Label #{it}" } + author factory: :user + end +end diff --git a/spec/factories/labeling_factory.rb b/spec/factories/labeling_factory.rb new file mode 100644 index 000000000000..7cb8fe4a2944 --- /dev/null +++ b/spec/factories/labeling_factory.rb @@ -0,0 +1,36 @@ +# 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. +#++ + +FactoryBot.define do + factory :labeling do + label + labelable factory: :work_package + end +end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb new file mode 100644 index 000000000000..a9fbb5351fbf --- /dev/null +++ b/spec/models/label_spec.rb @@ -0,0 +1,73 @@ +# 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" + +RSpec.describe Label do + describe "validations" do + subject { build(:label) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.to validate_uniqueness_of(:name).case_insensitive } + it { is_expected.to belong_to(:author).class_name("User") } + + it "is backed by a case-insensitive unique index" do + existing = create(:label, name: "hello") + duplicate = { name: "HELLO", author_id: existing.author_id, created_at: Time.current, updated_at: Time.current } + + expect { described_class.insert_all!([duplicate]) } + .to raise_error(ActiveRecord::RecordNotUnique) + end + end + + describe ".with_usage_count" do + it "counts the labelings of each label, including unused ones" do + used = create(:label) + unused = create(:label) + create_list(:labeling, 2, label: used) + + counts = described_class.with_usage_count.index_by(&:id).transform_values(&:usage_count) + + expect(counts).to eq(used.id => 2, unused.id => 0) + end + end + + describe "#destroy" do + it "removes its labelings" do + label = create(:label) + labeling = create(:labeling, label:) + + label.destroy! + + expect(Labeling.where(id: labeling.id)).not_to exist + end + end +end diff --git a/spec/models/labeling_spec.rb b/spec/models/labeling_spec.rb new file mode 100644 index 000000000000..6bc845cad17e --- /dev/null +++ b/spec/models/labeling_spec.rb @@ -0,0 +1,52 @@ +# 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" + +RSpec.describe Labeling do + let(:label) { create(:label) } + let(:work_package) { create(:work_package) } + + it "is valid with a label and a labelable" do + expect(build(:labeling, label:, labelable: work_package)).to be_valid + end + + it "rejects the same label twice on one labelable" do + create(:labeling, label:, labelable: work_package) + + expect(build(:labeling, label:, labelable: work_package)).not_to be_valid + end + + it "allows the same label on different labelables" do + create(:labeling, label:, labelable: work_package) + + expect(build(:labeling, label:, labelable: create(:work_package))).to be_valid + end +end diff --git a/spec/models/work_package/work_package_labels_spec.rb b/spec/models/work_package/work_package_labels_spec.rb new file mode 100644 index 000000000000..fc9daa1fbaf3 --- /dev/null +++ b/spec/models/work_package/work_package_labels_spec.rb @@ -0,0 +1,73 @@ +# 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" + +RSpec.describe WorkPackage, "labels" do + let(:work_package) { create(:work_package) } + let!(:lower_label) { create(:label, name: "apple") } + let!(:higher_label) { create(:label, name: "zebra") } + + before do + work_package.labels << higher_label + work_package.labels << lower_label + end + + it "returns labels in id order, also when preloaded" do + preloaded = described_class.where(id: work_package.id).includes(:labels).first + + expect(work_package.reload.labels).to eq([lower_label, higher_label]) + expect(preloaded.labels).to eq([lower_label, higher_label]) + end + + it "deletes its labelings but keeps the labels when destroyed" do + work_package.destroy! + + expect(Labeling.where(labelable: work_package)).not_to exist + expect(Label.where(id: [lower_label.id, higher_label.id]).count).to eq(2) + end + + it "drops a deleted label from its labels" do + lower_label.destroy! + + expect(work_package.reload.labels).to eq([higher_label]) + end + + describe ".labeled_with" do + it "returns only the work packages carrying the label" do + other = create(:work_package) + other.labels << lower_label + create(:work_package) + + expect(described_class.labeled_with(lower_label)).to contain_exactly(work_package, other) + expect(described_class.labeled_with(higher_label)).to contain_exactly(work_package) + end + end +end diff --git a/spec/services/principals/replace_references_service_call_integration_spec.rb b/spec/services/principals/replace_references_service_call_integration_spec.rb index 32365ef8da5f..05d212f1b8b4 100644 --- a/spec/services/principals/replace_references_service_call_integration_spec.rb +++ b/spec/services/principals/replace_references_service_call_integration_spec.rb @@ -120,6 +120,12 @@ end end + context "with Label" do + it_behaves_like "rewritten record", + :label, + :author_id + end + context "with CustomValue" do shared_let(:version) { create(:version) }