Add working hours and non working days for users

This commit is contained in:
Klaus Zanders
2026-02-19 17:05:25 +01:00
parent 43dba8a306
commit 8cb25833de
11 changed files with 583 additions and 0 deletions
+6
View File
@@ -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
+47
View File
@@ -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
+72
View File
@@ -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
+8
View File
@@ -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|
+19
View File
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
+116
View File
@@ -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
+199
View File
@@ -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