From 3466daba5e8eaf1e2d1b7b563a2313d3520df6fc Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 17:26:21 +0200 Subject: [PATCH] Add methods to resolve the user filter to users --- .../resource_allocations/base_contract.rb | 1 + .../app/models/resource_allocation.rb | 8 +++ .../factories/resource_allocation_factory.rb | 30 +++++--- .../spec/models/resource_allocation_spec.rb | 72 +++++++++++++++++++ 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/modules/resource_management/app/contracts/resource_allocations/base_contract.rb b/modules/resource_management/app/contracts/resource_allocations/base_contract.rb index 0b0faf8efab..e92cf20cfa8 100644 --- a/modules/resource_management/app/contracts/resource_allocations/base_contract.rb +++ b/modules/resource_management/app/contracts/resource_allocations/base_contract.rb @@ -40,6 +40,7 @@ module ResourceAllocations attribute :end_date attribute :allocated_time attribute :user_filter + attribute :filter_name validate :user_allowed_to_allocate diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 2031875be11..c71ef279194 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -94,6 +94,14 @@ class ResourceAllocation < ApplicationRecord principal_id.present? end + def candidate_query + UserQuery.new.tap do |query| + user_filter.each do |filter| + query.where(filter.field, filter.operator, filter.values) + end + end + end + def allocated_hours return if allocated_time.nil? diff --git a/modules/resource_management/spec/factories/resource_allocation_factory.rb b/modules/resource_management/spec/factories/resource_allocation_factory.rb index 4a5098dc348..e099f05f117 100644 --- a/modules/resource_management/spec/factories/resource_allocation_factory.rb +++ b/modules/resource_management/spec/factories/resource_allocation_factory.rb @@ -44,6 +44,7 @@ FactoryBot.define do trait :with_user_filter do principal { nil } + filter_name { "Full stack Developer (DE-EN)" } transient do job_title_custom_field do UserCustomField.find_by(name: "Job title") || @@ -51,17 +52,28 @@ FactoryBot.define do name: "Job title", possible_values: ["Developer", "Designer", "Project Manager", "Product Manager"]) end + spoken_language_custom_field do + UserCustomField.find_by(name: "Spoken language") || + create(:user_custom_field, :list, + name: "Spoken language", + multi_value: true, + possible_values: %w[German English French Spanish Italian Dutch Portuguese Polish]) + end end + # Build real UserQuery filter objects (not hashes): the serialization + # coder dumps via `filter.field`, so it only accepts filter instances. + # The filter matches developers who speak German or English ("DE-EN"), + # leaving the other languages as non-matching values to test against. user_filter do - cf = job_title_custom_field - developer_option = cf.custom_options.find_by(value: "Developer") - [ - { - "attribute" => cf.column_name, - "operator" => "=", - "values" => [developer_option.id.to_s] - } - ] + job_title = job_title_custom_field + language = spoken_language_custom_field + developer_option = job_title.custom_options.find_by(value: "Developer") + language_options = language.custom_options.where(value: %w[German English]) + + query = UserQuery.new + query.where(job_title.column_name, "=", [developer_option.id.to_s]) + query.where(language.column_name, "=", language_options.map { |option| option.id.to_s }) + query.filters end end end diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index a6d9154dabf..cdd7ff2b46f 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -379,5 +379,77 @@ RSpec.describe ResourceAllocation do allocation = create(:resource_allocation, entity: work_package, principal: owner) expect(allocation.reload.user_filter).to eq([]) end + + it "round-trips the custom-field filters from the :with_user_filter trait" do + allocation = create(:resource_allocation, :with_user_filter, entity: work_package) + + filters = allocation.reload.user_filter + expect(filters.size).to eq(2) + + job_title = UserCustomField.find_by(name: "Job title") + language = UserCustomField.find_by(name: "Spoken language") + + job_title_filter = filters.find { |f| f.name.to_s == job_title.column_name } + language_filter = filters.find { |f| f.name.to_s == language.column_name } + + expect(job_title_filter.operator).to eq("=") + expect(job_title_filter.values).to eq(job_title.custom_options.where(value: "Developer").pluck(:id).map(&:to_s)) + + # "is (OR)" — matches users speaking German or English. + expect(language_filter.operator).to eq("=") + expect(language_filter.values) + .to match_array(language.custom_options.where(value: %w[German English]).pluck(:id).map(&:to_s)) + end + end + + describe "matching users with the :with_user_filter criteria" do + shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } + shared_let(:work_package) { create(:work_package, project:) } + + # Materializes the "Job title" and "Spoken language" custom fields and the + # Developer + (German OR English) filter. + shared_let(:allocation) { create(:resource_allocation, :with_user_filter, entity: work_package) } + shared_let(:job_title) { UserCustomField.find_by(name: "Job title") } + shared_let(:language) { UserCustomField.find_by(name: "Spoken language") } + + def option_id(custom_field, value) + custom_field.custom_options.find_by(value:).id + end + + def user_with(job_title_value, *languages) + create(:user).tap do |user| + user.custom_field_values = { + job_title.id => option_id(job_title, job_title_value), + language.id => languages.map { |spoken| option_id(language, spoken) } + } + user.save!(validate: false) + end + end + + shared_let(:german_developer) { user_with("Developer", "German") } + shared_let(:english_developer) { user_with("Developer", "English") } + shared_let(:bilingual_developer) { user_with("Developer", "French", "English") } + shared_let(:french_developer) { user_with("Developer", "French") } + shared_let(:german_designer) { user_with("Designer", "German") } + + describe "#candidate_query" do + # `UserQuery#results` is scoped to what the current user may see. + current_user { create(:admin) } + + it "is a UserQuery carrying the stored filter criteria" do + query = allocation.candidate_query + + expect(query).to be_a(UserQuery) + expect(query.filters.map { |f| f.name.to_s }) + .to contain_exactly(job_title.column_name, language.column_name) + end + + it "resolves to developers speaking German or English (is (OR)), and excludes the rest" do + results = allocation.candidate_query.results + + expect(results).to include(german_developer, english_developer, bilingual_developer) + expect(results).not_to include(french_developer, german_designer) + end + end end end