From 8cb25833deaec5ab3db282c314ea0a3309ec3a44 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 19 Feb 2026 17:05:25 +0100 Subject: [PATCH] Add working hours and non working days for users --- app/models/user.rb | 6 + app/models/user_non_working_day.rb | 47 +++++ app/models/user_working_hours.rb | 72 +++++++ config/initializers/permissions.rb | 8 + config/locales/en.yml | 19 ++ .../20260219151850_add_user_working_hours.rb | 21 ++ ...0260219152519_add_user_non_working_days.rb | 15 ++ .../factories/user_non_working_day_factory.rb | 36 ++++ spec/factories/user_working_hours_factory.rb | 44 ++++ spec/models/user_non_working_day_spec.rb | 116 ++++++++++ spec/models/user_working_hours_spec.rb | 199 ++++++++++++++++++ 11 files changed, 583 insertions(+) create mode 100644 app/models/user_non_working_day.rb create mode 100644 app/models/user_working_hours.rb create mode 100644 db/migrate/20260219151850_add_user_working_hours.rb create mode 100644 db/migrate/20260219152519_add_user_non_working_days.rb create mode 100644 spec/factories/user_non_working_day_factory.rb create mode 100644 spec/factories/user_working_hours_factory.rb create mode 100644 spec/models/user_non_working_day_spec.rb create mode 100644 spec/models/user_working_hours_spec.rb diff --git a/app/models/user.rb b/app/models/user.rb index 6f2f75a890a..ffdcbe1c02b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -60,6 +60,12 @@ class User < Principal has_one :rss_token, class_name: "::Token::RSS", dependent: :destroy has_many :api_tokens, class_name: "::Token::API", dependent: :destroy has_many :oauth_client_tokens, dependent: :destroy + has_many :working_hours, class_name: "UserWorkingHours", + dependent: :destroy, + inverse_of: :user + has_many :non_working_days, class_name: "UserNonWorkingDay", + dependent: :destroy, + inverse_of: :user # The user might have one invitation token has_one :invitation_token, class_name: "::Token::Invitation", dependent: :destroy diff --git a/app/models/user_non_working_day.rb b/app/models/user_non_working_day.rb new file mode 100644 index 00000000000..1ec9def46af --- /dev/null +++ b/app/models/user_non_working_day.rb @@ -0,0 +1,47 @@ +# 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 UserNonWorkingDay < ApplicationRecord + belongs_to :user, inverse_of: :non_working_days + + validates :date, presence: true, uniqueness: { scope: :user_id } + + scope :for_year, ->(year) { where(date: Date.new(year, 1, 1)..Date.new(year, 12, 31)) } + + scope :for_user, ->(user) { where(user:) } + + scope :visible, ->(user = User.current) do + if user.allowed_globally?(:manage_working_times) + all + else + where(user:) + end + end +end diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb new file mode 100644 index 00000000000..8b30bacce20 --- /dev/null +++ b/app/models/user_working_hours.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. +#++ + +class UserWorkingHours < ApplicationRecord + belongs_to :user, inverse_of: :working_hours + + validates :valid_from, presence: true + validates :monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 24 * 60 } + validates :availability_factor, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } + + scope :for_user, ->(user) { where(user:) } + + scope :past, -> { where(valid_from: ...Date.current).order(valid_from: :desc) } + scope :upcoming, -> { where(valid_from: Date.current..).order(valid_from: :asc) } + + def self.valid_for_date(date) + where(valid_from: ..date).order(valid_from: :desc).first + end + + def self.current + valid_for_date(Date.current) + end + + scope :visible, ->(user = User.current) do + if user.allowed_globally?(:manage_working_times) + all + else + where(user:) + end + end + + %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| + define_method("#{day}_hours") do + public_send(day) / 60.0 + end + + define_method("#{day}_hours=") do |hours| + public_send("#{day}=", (hours.to_f * 60).round) + end + end +end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 777e2247c47..75233ee0080 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -286,6 +286,14 @@ Rails.application.reloader.to_prepare do {}, permissible_on: :project_query, require: :loggedin + + map.permission :manage_own_working_times, + {}, + permissible_on: :global + + map.permission :manage_working_times, + {}, + permissible_on: :global end map.project_module :work_package_tracking, order: 90 do |wpt| diff --git a/config/locales/en.yml b/config/locales/en.yml index 3e1a60979b7..537511d2594 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1748,6 +1748,25 @@ en: users/invitation/form_model: principal_type: "Invitation type" id_or_email: "Name or email address" + user_non_working_days: + date: "Date" + user_working_hours: + valid_from: "Valid from" + monday: "Monday" + monday_hours: "Monday hours" + tuesday: "Tuesday" + tuesday_hours: "Tuesday hours" + wednesday: "Wednesday" + wednesday_hours: "Wednesday hours" + thursday: "Thursday" + thursday_hours: "Thursday hours" + friday: "Friday" + friday_hours: "Friday hours" + saturday: "Saturday" + saturday_hours: "Saturday hours" + sunday: "Sunday" + sunday_hours: "Sunday hours" + availability_factor: "Availability factor" version: effective_date: "Finish date" sharing: "Sharing" diff --git a/db/migrate/20260219151850_add_user_working_hours.rb b/db/migrate/20260219151850_add_user_working_hours.rb new file mode 100644 index 00000000000..79cbd3e4b6b --- /dev/null +++ b/db/migrate/20260219151850_add_user_working_hours.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddUserWorkingHours < ActiveRecord::Migration[8.1] + def change + create_table :user_working_hours do |t| + t.references :user, null: false, foreign_key: true + + t.date :valid_from, null: false, index: true + t.integer :monday, null: false + t.integer :tuesday, null: false + t.integer :wednesday, null: false + t.integer :thursday, null: false + t.integer :friday, null: false + t.integer :saturday, null: false + t.integer :sunday, null: false + t.integer :availability_factor, null: false, default: 100 + + t.timestamps + end + end +end diff --git a/db/migrate/20260219152519_add_user_non_working_days.rb b/db/migrate/20260219152519_add_user_non_working_days.rb new file mode 100644 index 00000000000..0822a66eedb --- /dev/null +++ b/db/migrate/20260219152519_add_user_non_working_days.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddUserNonWorkingDays < ActiveRecord::Migration[8.1] + def change + create_table :user_non_working_days do |t| + t.references :user, null: false, foreign_key: true + + t.date :date, null: false, index: true + + t.timestamps + + t.index %i[user_id date], unique: true + end + end +end diff --git a/spec/factories/user_non_working_day_factory.rb b/spec/factories/user_non_working_day_factory.rb new file mode 100644 index 00000000000..45ba851eb87 --- /dev/null +++ b/spec/factories/user_non_working_day_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 :user_non_working_day do + user + sequence(:date) { |n| Date.current + n.days } + end +end diff --git a/spec/factories/user_working_hours_factory.rb b/spec/factories/user_working_hours_factory.rb new file mode 100644 index 00000000000..301d73b81f5 --- /dev/null +++ b/spec/factories/user_working_hours_factory.rb @@ -0,0 +1,44 @@ +# 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 :user_working_hours do + user + sequence(:valid_from) { |n| Date.current + n.days } + monday { 480 } + tuesday { 480 } + wednesday { 480 } + thursday { 480 } + friday { 480 } + saturday { 0 } + sunday { 0 } + availability_factor { 100 } + end +end diff --git a/spec/models/user_non_working_day_spec.rb b/spec/models/user_non_working_day_spec.rb new file mode 100644 index 00000000000..7ba88e448ed --- /dev/null +++ b/spec/models/user_non_working_day_spec.rb @@ -0,0 +1,116 @@ +# 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 UserNonWorkingDay do + subject(:non_working_day) { build(:user_non_working_day) } + + describe "validations" do + it { is_expected.to be_valid } + + it { is_expected.to validate_presence_of(:date) } + + it "validates uniqueness of date scoped to user" do + existing = create(:user_non_working_day) + duplicate = build(:user_non_working_day, user: existing.user, date: existing.date) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:date]).to be_present + end + + it "allows the same date for different users" do + existing = create(:user_non_working_day) + other_user = create(:user) + other = build(:user_non_working_day, user: other_user, date: existing.date) + + expect(other).to be_valid + end + end + + describe ".for_year" do + let(:user) { create(:user) } + let!(:day_in_year) { create(:user_non_working_day, user:, date: Date.new(2025, 6, 15)) } + let!(:day_at_start) { create(:user_non_working_day, user:, date: Date.new(2025, 1, 1)) } + let!(:day_at_end) { create(:user_non_working_day, user:, date: Date.new(2025, 12, 31)) } + let!(:day_outside_year) { create(:user_non_working_day, user:, date: Date.new(2024, 12, 31)) } + + it "returns records within the given year" do + expect(described_class.for_user(user).for_year(2025)).to contain_exactly(day_in_year, day_at_start, day_at_end) + end + + it "excludes records outside the given year" do + expect(described_class.for_user(user).for_year(2025)).not_to include(day_outside_year) + end + end + + describe ".for_user" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_day) { create(:user_non_working_day, user:) } + let!(:other_day) { create(:user_non_working_day, user: other_user) } + + it "returns only records for the given user" do + expect(described_class.for_user(user)).to contain_exactly(user_day) + end + + it "excludes records for other users" do + expect(described_class.for_user(user)).not_to include(other_day) + end + end + + describe ".visible" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_day) { create(:user_non_working_day, user:) } + let!(:other_day) { create(:user_non_working_day, user: other_user) } + + context "when the viewer has :manage_working_times permission" do + let(:viewer) { create(:user, global_permissions: [:manage_working_times]) } + + it "returns all records" do + expect(described_class.visible(viewer)).to contain_exactly(user_day, other_day) + end + end + + context "when the viewer has no special permissions" do + let(:viewer) { create(:user) } + let!(:viewer_day) { create(:user_non_working_day, user: viewer) } + + it "returns only their own records" do + expect(described_class.visible(viewer)).to contain_exactly(viewer_day) + end + + it "excludes other users' records" do + expect(described_class.visible(viewer)).not_to include(user_day, other_day) + end + end + end +end diff --git a/spec/models/user_working_hours_spec.rb b/spec/models/user_working_hours_spec.rb new file mode 100644 index 00000000000..bd51ab0358e --- /dev/null +++ b/spec/models/user_working_hours_spec.rb @@ -0,0 +1,199 @@ +# 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 UserWorkingHours do + subject(:working_hours) { build(:user_working_hours) } + + describe "validations" do + it { is_expected.to be_valid } + + it { is_expected.to validate_presence_of(:valid_from) } + + %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| + it { is_expected.to validate_presence_of(day) } + + it do + expect(subject).to validate_numericality_of(day).only_integer + .is_greater_than_or_equal_to(0) + .is_less_than_or_equal_to(24 * 60) + end + end + + it { is_expected.to validate_presence_of(:availability_factor) } + + it do + expect(subject).to validate_numericality_of(:availability_factor).only_integer + .is_greater_than_or_equal_to(0) + .is_less_than_or_equal_to(100) + end + end + + describe "hours accessors" do + subject(:working_hours) { build(:user_working_hours, monday: 480, tuesday: 90, wednesday: 0) } + + %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| + describe "##{day}_hours" do + it "returns the minutes value converted to hours" do + working_hours.public_send("#{day}=", 150) + expect(working_hours.public_send("#{day}_hours")).to eq(2.5) + end + end + + describe "##{day}_hours=" do + it "stores the hours value converted to minutes" do + working_hours.public_send("#{day}_hours=", 7.5) + expect(working_hours.public_send(day)).to eq(450) + end + + it "rounds fractional minutes" do + working_hours.public_send("#{day}_hours=", 1.0 / 3) + expect(working_hours.public_send(day)).to eq(20) + end + end + end + + it "returns 8.0 hours for a full work day of 480 minutes" do + expect(working_hours.monday_hours).to eq(8.0) + end + + it "returns 1.5 hours for 90 minutes" do + expect(working_hours.tuesday_hours).to eq(1.5) + end + + it "returns 0.0 for a non-working day" do + expect(working_hours.wednesday_hours).to eq(0.0) + end + end + + describe ".valid_for_date" do + let(:user) { create(:user) } + let!(:old_hours) { create(:user_working_hours, user:, valid_from: 30.days.ago) } + let!(:recent_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) } + let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) } + + it "returns the most recent record valid on the given date" do + expect(described_class.for_user(user).valid_for_date(Date.current)).to eq(recent_hours) + end + + it "returns the correct record for a past date" do + expect(described_class.for_user(user).valid_for_date(20.days.ago.to_date)).to eq(old_hours) + end + + it "returns nil when no record is valid for the given date" do + expect(described_class.for_user(user).valid_for_date(31.days.ago.to_date)).to be_nil + end + + it "does not return future records" do + expect(described_class.for_user(user).valid_for_date(Date.current)).not_to eq(future_hours) + end + end + + describe ".current" do + let(:user) { create(:user) } + let!(:past_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) } + let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) } + + it "returns the currently valid record" do + expect(described_class.for_user(user).current).to eq(past_hours) + end + + it "does not return future records" do + expect(described_class.for_user(user).current).not_to eq(future_hours) + end + end + + describe ".past" do + let(:user) { create(:user) } + let!(:older_hours) { create(:user_working_hours, user:, valid_from: 20.days.ago) } + let!(:recent_past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) } + let!(:future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) } + + it "returns records with valid_from before today" do + expect(described_class.for_user(user).past).to contain_exactly(older_hours, recent_past_hours) + end + + it "orders results descending by valid_from" do + expect(described_class.for_user(user).past).to eq([recent_past_hours, older_hours]) + end + + it "excludes future records" do + expect(described_class.for_user(user).past).not_to include(future_hours) + end + end + + describe ".upcoming" do + let(:user) { create(:user) } + let!(:past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) } + let!(:near_future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) } + let!(:far_future_hours) { create(:user_working_hours, user:, valid_from: 20.days.from_now) } + + it "returns records with valid_from from today onwards" do + expect(described_class.for_user(user).upcoming).to contain_exactly(near_future_hours, far_future_hours) + end + + it "orders results ascending by valid_from" do + expect(described_class.for_user(user).upcoming).to eq([near_future_hours, far_future_hours]) + end + + it "excludes past records" do + expect(described_class.for_user(user).upcoming).not_to include(past_hours) + end + end + + describe ".visible" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_hours) { create(:user_working_hours, user:) } + let!(:other_hours) { create(:user_working_hours, user: other_user) } + + context "when the viewer has :manage_working_times permission" do + let(:viewer) { create(:user, global_permissions: [:manage_working_times]) } + + it "returns all records" do + expect(described_class.visible(viewer)).to contain_exactly(user_hours, other_hours) + end + end + + context "when the viewer has no special permissions" do + let(:viewer) { create(:user) } + let!(:viewer_hours) { create(:user_working_hours, user: viewer) } + + it "returns only their own records" do + expect(described_class.visible(viewer)).to contain_exactly(viewer_hours) + end + + it "excludes other users' records" do + expect(described_class.visible(viewer)).not_to include(user_hours, other_hours) + end + end + end +end