commit
e949340f85
@ -0,0 +1,63 @@ |
|||||||
|
#-- copyright |
||||||
|
# OpenProject is an open source project management software. |
||||||
|
# Copyright (C) 2012-2023 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 docs/COPYRIGHT.rdoc for more details. |
||||||
|
#++ |
||||||
|
|
||||||
|
module Notifications |
||||||
|
# Creates date alert jobs for users whose local time is 1:00 am. |
||||||
|
class ScheduleDateAlertsNotificationsJob < Cron::CronJob |
||||||
|
# runs every quarter of an hour, so 00:00, 00:15,..., 15:30, 15:45, 16:00, ... |
||||||
|
self.cron_expression = '*/15 * * * *' |
||||||
|
|
||||||
|
def perform |
||||||
|
return unless EnterpriseToken.allows_to?(:date_alerts) |
||||||
|
|
||||||
|
service = Service.new(times_from_scheduled_to_execution) |
||||||
|
service.call |
||||||
|
end |
||||||
|
|
||||||
|
# Returns times from scheduled execution time to current time in 15 minutes |
||||||
|
# steps. |
||||||
|
# |
||||||
|
# As scheduled execution time can be different from current time by more |
||||||
|
# than 15 minutes when workers are busy, all times at 15 minutes interval |
||||||
|
# between scheduled time and current time need to be considered to match |
||||||
|
# with 1:00am in a time zone. |
||||||
|
def times_from_scheduled_to_execution |
||||||
|
time = scheduled_time |
||||||
|
times = [] |
||||||
|
begin |
||||||
|
times << time |
||||||
|
time += 15.minutes |
||||||
|
end while time < Time.current |
||||||
|
times |
||||||
|
end |
||||||
|
|
||||||
|
def scheduled_time |
||||||
|
self.class.delayed_job.run_at.then { |t| t.change(min: t.min / 15 * 15) } |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,74 @@ |
|||||||
|
#-- copyright |
||||||
|
# OpenProject is an open source project management software. |
||||||
|
# Copyright (C) 2012-2023 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. |
||||||
|
#++ |
||||||
|
|
||||||
|
# Creates date alerts notifications for users whose local time is 1am for the |
||||||
|
# given run_times. |
||||||
|
class Notifications::ScheduleDateAlertsNotificationsJob::Service |
||||||
|
attr_reader :run_times |
||||||
|
|
||||||
|
# @param run_times [Array<DateTime>] the times for which the service is run. |
||||||
|
# Must be multiple of 15 minutes (xx:00, xx:15, xx:30, or xx:45). |
||||||
|
def initialize(run_times) |
||||||
|
@run_times = run_times |
||||||
|
end |
||||||
|
|
||||||
|
def call |
||||||
|
return unless EnterpriseToken.allows_to?(:date_alerts) |
||||||
|
|
||||||
|
users_at_1am_with_notification_settings.find_each do |user| |
||||||
|
Notifications::CreateDateAlertsNotificationsJob.perform_later(user) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def time_zones_covering_1am_local_time |
||||||
|
UserPreferences::UpdateContract |
||||||
|
.assignable_time_zones |
||||||
|
.select { |time_zone| executing_at_1am_for_timezone?(time_zone) } |
||||||
|
.map { |time_zone| time_zone.tzinfo.canonical_zone.name } |
||||||
|
end |
||||||
|
|
||||||
|
def executing_at_1am_for_timezone?(time_zone) |
||||||
|
run_times.any? { |time| is_1am?(time, time_zone) } |
||||||
|
end |
||||||
|
|
||||||
|
def is_1am?(time, time_zone) |
||||||
|
local_time = time.in_time_zone(time_zone) |
||||||
|
local_time.strftime('%H:%M') == '01:00' |
||||||
|
end |
||||||
|
|
||||||
|
def users_at_1am_with_notification_settings |
||||||
|
User |
||||||
|
.with_time_zone(time_zones_covering_1am_local_time) |
||||||
|
.not_locked |
||||||
|
.where("EXISTS (SELECT 1 FROM notification_settings " \ |
||||||
|
"WHERE user_id = users.id AND " \ |
||||||
|
"(overdue IS NOT NULL OR start_date IS NOT NULL OR due_date IS NOT NULL))") |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,37 @@ |
|||||||
|
#-- copyright |
||||||
|
# OpenProject is an open source project management software. |
||||||
|
# Copyright (C) 2012-2023 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 RemoveRenamedDateAlertJob < ActiveRecord::Migration[6.0] |
||||||
|
def up |
||||||
|
# The job has been renamed to Notifications::ScheduleDateAlertsNotificationsJob. |
||||||
|
# The new job will be added on restarting the application. |
||||||
|
Delayed::Job |
||||||
|
.where('handler LIKE ?', "%job_class: Notifications::CreateDateAlertsNotificationsJob%") |
||||||
|
.delete_all |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,6 @@ |
|||||||
|
class WorkPackageDateIndices < ActiveRecord::Migration[7.0] |
||||||
|
def change |
||||||
|
add_index :work_packages, :start_date |
||||||
|
add_index :work_packages, :due_date |
||||||
|
end |
||||||
|
end |
After Width: | Height: | Size: 103 KiB |
After Width: | Height: | Size: 126 KiB |
After Width: | Height: | Size: 100 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 147 KiB |
@ -0,0 +1,301 @@ |
|||||||
|
#-- copyright |
||||||
|
# OpenProject is an open source project management software. |
||||||
|
# Copyright (C) 2012-2023 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 docs/COPYRIGHT.rdoc for more details. |
||||||
|
#++ |
||||||
|
|
||||||
|
require 'spec_helper' |
||||||
|
|
||||||
|
describe Notifications::ScheduleDateAlertsNotificationsJob, type: :job, with_ee: %i[date_alerts] do |
||||||
|
include ActiveSupport::Testing::TimeHelpers |
||||||
|
|
||||||
|
shared_let(:project) { create(:project, name: 'main') } |
||||||
|
|
||||||
|
# Paris and Berlin are both UTC+01:00 (CET) or UTC+02:00 (CEST) |
||||||
|
shared_let(:timezone_paris) { ActiveSupport::TimeZone['Europe/Paris'] } |
||||||
|
# Kathmandu is UTC+05:45 (no DST) |
||||||
|
shared_let(:timezone_kathmandu) { ActiveSupport::TimeZone['Asia/Kathmandu'] } |
||||||
|
|
||||||
|
shared_let(:user_paris) do |
||||||
|
create( |
||||||
|
:user, |
||||||
|
firstname: 'Paris', |
||||||
|
preferences: { time_zone: timezone_paris.name } |
||||||
|
) |
||||||
|
end |
||||||
|
shared_let(:user_kathmandu) do |
||||||
|
create( |
||||||
|
:user, |
||||||
|
firstname: 'Kathmandu', |
||||||
|
preferences: { time_zone: timezone_kathmandu.name } |
||||||
|
) |
||||||
|
end |
||||||
|
|
||||||
|
let(:schedule_job) do |
||||||
|
described_class.ensure_scheduled! |
||||||
|
described_class.delayed_job |
||||||
|
end |
||||||
|
|
||||||
|
before do |
||||||
|
# We need to access the job as stored in the database to get at the run_at time persisted there |
||||||
|
allow(ActiveJob::Base) |
||||||
|
.to receive(:queue_adapter) |
||||||
|
.and_return(ActiveJob::QueueAdapters::DelayedJobAdapter.new) |
||||||
|
schedule_job |
||||||
|
end |
||||||
|
|
||||||
|
def set_scheduled_time(run_at) |
||||||
|
schedule_job.update_column(:run_at, run_at) |
||||||
|
end |
||||||
|
|
||||||
|
# Converts "hh:mm" into { hour: h, min: m } |
||||||
|
def time_hash(time) |
||||||
|
%i[hour min].zip(time.split(':', 2).map(&:to_i)).to_h |
||||||
|
end |
||||||
|
|
||||||
|
def timezone_time(time, timezone) |
||||||
|
timezone.now.change(time_hash(time)) |
||||||
|
end |
||||||
|
|
||||||
|
def run_job(scheduled_at: '1:00', local_time: '1:04', timezone: timezone_paris) |
||||||
|
set_scheduled_time(timezone_time(scheduled_at, timezone)) |
||||||
|
travel_to(timezone_time(local_time, timezone)) do |
||||||
|
schedule_job.reload.invoke_job |
||||||
|
|
||||||
|
yield if block_given? |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def deserialized_of_job(job) |
||||||
|
deserializer_class = Class.new do |
||||||
|
include(ActiveJob::Arguments) |
||||||
|
end |
||||||
|
|
||||||
|
deserializer = deserializer_class.new |
||||||
|
|
||||||
|
deserializer.deserialize(job.payload_object.job_data).to_h |
||||||
|
end |
||||||
|
|
||||||
|
def expect_job(job, klass, *arguments) |
||||||
|
job_data = deserialized_of_job(job) |
||||||
|
expect(job_data['job_class']) |
||||||
|
.to eql klass |
||||||
|
expect(job_data['arguments']) |
||||||
|
.to match_array arguments |
||||||
|
end |
||||||
|
|
||||||
|
shared_examples_for 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:04' } |
||||||
|
let(:user) { user_paris } |
||||||
|
|
||||||
|
it 'creates the job for the user' do |
||||||
|
expect do |
||||||
|
run_job(timezone:, scheduled_at:, local_time:) do |
||||||
|
expect_job(Delayed::Job.last, "Notifications::CreateDateAlertsNotificationsJob", user) |
||||||
|
end |
||||||
|
end.to change(Delayed::Job, :count).by 1 |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
shared_examples_for 'job execution creates no date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:04' } |
||||||
|
|
||||||
|
it 'creates no job' do |
||||||
|
expect do |
||||||
|
run_job(timezone:, scheduled_at:, local_time:) |
||||||
|
end.not_to change(Delayed::Job, :count) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe '#perform' do |
||||||
|
context 'for users whose local time is 1:00 am (UTC+1) when the job is executed' do |
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:04' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'for users whose local time is 1:00 am (UTC+05:45) when the job is executed' do |
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_kathmandu } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:04' } |
||||||
|
let(:user) { user_kathmandu } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'without enterprise token', with_ee: false do |
||||||
|
it_behaves_like 'job execution creates no date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:04' } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'when scheduled and executed at 01:00 am local time' do |
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:00' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'when scheduled and executed at 01:14 am local time' do |
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:14' } |
||||||
|
let(:local_time) { '1:14' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'when scheduled and executed at 01:15 am local time' do |
||||||
|
it_behaves_like 'job execution creates no date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:15' } |
||||||
|
let(:local_time) { '1:15' } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'when scheduled at 01:00 am local time and executed at 01:37 am local time' do |
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:37' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with a user having only due_date active in notification settings' do |
||||||
|
before do |
||||||
|
NotificationSetting |
||||||
|
.where(user: user_paris) |
||||||
|
.update_all(due_date: 1, |
||||||
|
start_date: nil, |
||||||
|
overdue: nil) |
||||||
|
end |
||||||
|
|
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:00' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with a user having only start_date active in notification settings' do |
||||||
|
before do |
||||||
|
NotificationSetting |
||||||
|
.where(user: user_paris) |
||||||
|
.update_all(due_date: nil, |
||||||
|
start_date: 1, |
||||||
|
overdue: nil) |
||||||
|
end |
||||||
|
|
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:00' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with a user having only overdue active in notification settings' do |
||||||
|
before do |
||||||
|
NotificationSetting |
||||||
|
.where(user: user_paris) |
||||||
|
.update_all(due_date: nil, |
||||||
|
start_date: nil, |
||||||
|
overdue: 1) |
||||||
|
end |
||||||
|
|
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:00' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'without a user having notification settings' do |
||||||
|
before do |
||||||
|
NotificationSetting |
||||||
|
.where(user: user_paris) |
||||||
|
.update_all(due_date: nil, |
||||||
|
start_date: nil, |
||||||
|
overdue: nil) |
||||||
|
end |
||||||
|
|
||||||
|
it_behaves_like 'job execution creates no date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:00' } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with a user having only a project active notification settings' do |
||||||
|
before do |
||||||
|
NotificationSetting |
||||||
|
.where(user: user_paris) |
||||||
|
.update_all(due_date: nil, |
||||||
|
start_date: nil, |
||||||
|
overdue: nil) |
||||||
|
|
||||||
|
NotificationSetting |
||||||
|
.create(user: user_paris, |
||||||
|
project: create(:project), |
||||||
|
due_date: 1, |
||||||
|
start_date: nil, |
||||||
|
overdue: nil) |
||||||
|
end |
||||||
|
|
||||||
|
it_behaves_like 'job execution creates date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:00' } |
||||||
|
let(:user) { user_paris } |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with a locked user' do |
||||||
|
before do |
||||||
|
user_paris.locked! |
||||||
|
end |
||||||
|
|
||||||
|
it_behaves_like 'job execution creates no date alerts creation job' do |
||||||
|
let(:timezone) { timezone_paris } |
||||||
|
let(:scheduled_at) { '1:00' } |
||||||
|
let(:local_time) { '1:00' } |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
Loading…
Reference in new issue