Merge remote-tracking branch 'origin/release/12.4' into dev

pull/11910/head
Oliver Günther 2 years ago
commit e949340f85
No known key found for this signature in database
GPG Key ID: A3A8BDAD7C0C552C
  1. 4
      app/models/custom_value.rb
  2. 36
      app/workers/notifications/create_date_alerts_notifications_job.rb
  3. 31
      app/workers/notifications/create_date_alerts_notifications_job/alertable_work_packages.rb
  4. 41
      app/workers/notifications/create_date_alerts_notifications_job/service.rb
  5. 63
      app/workers/notifications/schedule_date_alerts_notifications_job.rb
  6. 74
      app/workers/notifications/schedule_date_alerts_notifications_job/service.rb
  7. 18
      config/initializers/cronjobs.rb
  8. 37
      db/migrate/20230105073117_remove_renamed_date_alert_job.rb
  9. 6
      db/migrate/20230105134940_work_package_date_indices.rb
  10. 2
      docs/system-admin-guide/users-permissions/README.md
  11. 18
      docs/system-admin-guide/users-permissions/settings/README.md
  12. BIN
      docs/system-admin-guide/users-permissions/settings/openproject_admin_guide_user_settings.png
  13. 22
      docs/system-admin-guide/users-permissions/users/README.md
  14. BIN
      docs/system-admin-guide/users-permissions/users/open_project_system_admin_lock_user_permanently.png
  15. BIN
      docs/system-admin-guide/users-permissions/users/openproject_system_admin_guide_users_list.png
  16. BIN
      docs/system-admin-guide/users-permissions/users/openproject_system_guide_create_user.png
  17. BIN
      docs/system-admin-guide/users-permissions/users/openproject_system_guide_send_user_invitation.png
  18. 3
      lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb
  19. 4
      spec/features/notifications/notification_center/notification_center_date_alerts_spec.rb
  20. 11
      spec/models/work_package/work_package_acts_as_customizable_spec.rb
  21. 179
      spec/workers/notifications/create_date_alerts_notifications_job_spec.rb
  22. 301
      spec/workers/notifications/schedule_date_alerts_notifications_job_spec.rb

@ -61,6 +61,10 @@ class CustomValue < ApplicationRecord
end
end
def default?
value == custom_field.default_value
end
protected
def validate_presence_of_required_value

@ -23,41 +23,17 @@
# 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.
# See COPYRIGHT and LICENSE files for more details.
#++
module Notifications
# Creates date alerts for users whose local time is 1:00 am.
class CreateDateAlertsNotificationsJob < 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
class CreateDateAlertsNotificationsJob < ApplicationJob
def perform(user)
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) }
Service
.new(user)
.call
end
end
end

@ -23,7 +23,7 @@
# 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.
# See COPYRIGHT and LICENSE files for more details.
#++
class Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages
@ -34,26 +34,27 @@ class Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages
end
def alertable_for_start
find_alertables
.filter_map { |row| row["id"] if row["start_alert"] }
.then { |ids| WorkPackage.where(id: ids) }
alertable_for("start_alert")
end
def alertable_for_due
find_alertables
.filter_map { |row| row["id"] if row["due_alert"] }
.then { |ids| WorkPackage.where(id: ids) }
alertable_for("due_alert")
end
private
def alertable_for(alert)
find_alertables
.filter_map { |row| row["id"] if row[alert] }
.then { |ids| WorkPackage.where(id: ids) }
end
def find_alertables
@find_alertables ||= ActiveRecord::Base.connection.execute(query).to_a
end
def query
today = Arel::Nodes::build_quoted(Date.current).to_sql
alertable_durations = Arel::Nodes::Grouping.new(UserPreferences::ParamsContract::DATE_ALERT_DURATIONS.compact).to_sql
alertables = alertable_work_packages
.select(:id,
@ -61,9 +62,9 @@ class Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages
"work_packages.start_date - #{today} AS start_delta",
"work_packages.due_date - #{today} AS due_delta",
"#{today} - work_packages.due_date AS overdue_delta")
.where("work_packages.start_date - #{today} IN #{alertable_durations} " \
"OR work_packages.due_date - #{today} IN #{alertable_durations} " \
"OR #{today} - work_packages.due_date > 0")
.where("work_packages.start_date IN #{alertable_dates} " \
"OR work_packages.due_date IN #{alertable_dates} " \
"OR work_packages.due_date < #{today}")
<<~SQL.squish
WITH
@ -123,4 +124,12 @@ class Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages
join_dependency = work_packages.construct_join_dependency([:status], Arel::Nodes::OuterJoin)
work_packages.joins(join_dependency)
end
def alertable_dates
dates = UserPreferences::ParamsContract::DATE_ALERT_DURATIONS
.compact
.map { |offset| Arel::Nodes::build_quoted(Date.current + offset.days) }
Arel::Nodes::Grouping.new(dates).to_sql
end
end

@ -26,36 +26,23 @@
# 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::CreateDateAlertsNotificationsJob::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
def initialize(user)
@user = user
end
def call
return unless EnterpriseToken.allows_to?(:date_alerts)
time_zones = time_zones_covering_1am_local_time
return if time_zones.empty?
# warning: there may be a subtle bug here: if many run_times are given, time
# zones will have different time shifting. This should be ok: as the period
# covered is small this should not have any impact. If the period is more
# than 23h, then the day will change.
Time.use_zone(time_zones.first) do
User.with_time_zone(time_zones).find_each do |user|
Time.use_zone(user.time_zone) do
send_date_alert_notifications(user)
end
end
end
private
attr_accessor :user
def send_date_alert_notifications(user)
alertables = Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages.new(user)
create_date_alert_notifications(user, alertables.alertable_for_start, :date_alert_start_date)
@ -70,6 +57,8 @@ class Notifications::CreateDateAlertsNotificationsJob::Service
end
def mark_previous_notifications_as_read(user, work_packages, reason)
return if work_packages.empty?
Notification
.where(recipient: user,
reason:,
@ -86,20 +75,4 @@ class Notifications::CreateDateAlertsNotificationsJob::Service
reason:
)
end
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
end

@ -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

@ -2,14 +2,14 @@
OpenProject::Application.configure do |application|
application.config.to_prepare do
::Cron::CronJob.register! ::Cron::ClearOldSessionsJob,
::Cron::ClearTmpCacheJob,
::Cron::ClearUploadedFilesJob,
::OAuth::CleanupJob,
::PaperTrailAudits::CleanupJob,
::Attachments::CleanupUncontaineredJob,
::Notifications::CreateDateAlertsNotificationsJob,
::Notifications::ScheduleReminderMailsJob,
::Ldap::SynchronizationJob
Cron::CronJob.register! Cron::ClearOldSessionsJob,
Cron::ClearTmpCacheJob,
Cron::ClearUploadedFilesJob,
OAuth::CleanupJob,
PaperTrailAudits::CleanupJob,
Attachments::CleanupUncontaineredJob,
Notifications::ScheduleDateAlertsNotificationsJob,
Notifications::ScheduleReminderMailsJob,
Ldap::SynchronizationJob
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

@ -7,7 +7,7 @@ keywords: users, permissions, roles, groups, avatars
---
# Users and permissions
Manage users, placeholder users and permissions in OpenProject.
In this section of the System Administration guide you can learn how to manage users, placeholder users and permissions in OpenProject.
## Overview

@ -10,9 +10,9 @@ keywords: user settings
The user settings sections covers general settings, such as the default language, user deletion and user consent.
User settings are accessible by administrators from the OpenProject administration.
User settings are accessible by administrators from the OpenProject **Administration**.
![user and permissions settings](image-20200211140959585.png)
![user and permissions settings](openproject_admin_guide_user_settings.png)
| Topic | Content |
| ------------------------------------------- | -------------------------- |
@ -24,7 +24,7 @@ User settings are accessible by administrators from the OpenProject administrati
![image-20191104163417641](image-20191104163417641.png)
The default preferences section covers default user settings.
The **Default preferences** section covers default user settings.
Here, you can specify the default language for new users as well as the default time zone.
@ -32,17 +32,17 @@ The default language is displayed for users when they first sign into OpenProjec
You can also choose if success notifications (e.g. on the work package page) should be hidden automatically.
These settings can be changed by users in their "My Account" page later on.
These settings can be changed by users in their **My Account** page later on.
## User deletion
![image-20191104163546817](image-20191104163546817.png)
In the user deletion section you can determine who should be able to delete user accounts.
In the **User deletion** section you can determine who should be able to delete user accounts.
By default, only admins are able to delete accounts. If this option is activated, admins can navigate to the user list, select a user account and click on the "Delete" option on the upper right side to delete an account.
By default, only admins are able to delete accounts. If this option is activated, admins can navigate to the user list, select a user account and click on the **Delete** option on the upper right side to delete an account.
Additionally, you can select the option "Users allowed to delete their accounts". If this option is activated, users can delete their own user accounts from the "My account" page.
Additionally, you can select the option **Users allowed to delete their accounts**. If this option is activated, users can delete their own user accounts from the **My account** page.
If you want to prevent users from deleting their own accounts, it is recommended to deactivate this option.
@ -50,8 +50,8 @@ If you want to prevent users from deleting their own accounts, it is recommended
![image-20191104163858457](image-20191104163858457.png)
Data privacy and security is a priority in OpenProject. In order to comply with GDPR regulation, a consent form can be configured in OpenProject. When the option "Consent required" is checked, a user consent form is shown for users when they sign into OpenProject for the first time.
Data privacy and security is a priority in OpenProject. In order to comply with GDPR regulation, a consent form can be configured in OpenProject. When the option **Consent required** is checked, a user consent form is shown for users when they sign into OpenProject for the first time.
By default, OpenProject's privacy and security policy is referenced in the consent form. If you have any additional information you would like your users to consent to, you can link it in the consent information text.
By default, OpenProject's privacy and security policy is referenced in the consent form. If you have any additional information you would like your users to consent to, you can link it in the **Consent information text** section.
Furthermore, you can provide the email address of a consent contact. This user can then be notified when a data change or data removal is required.

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

@ -31,7 +31,7 @@ In the Community edition there is no limit to the number of users. In Enterprise
The User list is where users are managed. They can be added, edited or deleted from this list, which can be filtered if required.
![user list](image-20200211141841492.png)
![openproject_system_admin_guide_users_list](openproject_system_admin_guide_users_list.png)
Column headers can be clicked to toggle sort direction. Arrows indicate sort order, up for ascending (a-z/0-9) and down for descending (z-a/9-0). Paging controls are shown at the bottom of the list. You will also see whether a user is a system administrator in OpenProject.
@ -42,7 +42,7 @@ At the top of the user list is a filter box. Filter by status or name, then clic
* **Status** - select from Active, All or Locked Temporarily. Each selection shows the number of users.
* **Name** - enter any text; this can contain a "%" wild card for 0 or more characters. The filter applies to user name, first name, last name and email address.
![filter users](image-20200115155456033.png)
![Filter users in OpenProject](image-20200115155456033.png)
## Lock and unlock users
@ -52,9 +52,9 @@ If you are using [Enterprise cloud](../../../enterprise-guide/enterprise-cloud-g
> **Note**: The previous activities from a locked user will still be displayed in the system.
![System-admin-guide_lock-users](System-admin-guide_lock-users.png)
![Lock users in OpenProject](open_project_system_admin_lock_user_permanently.png)
If a user has repeated failed logins the user will be locked temporarily and a "Reset failed logins" link is shown in the user list. Click the link to unlock it now, or wait and it will be unlocked automatically. Have a look at the section [Other authentication settings](../../authentication/authentication-settings/#other-authentication-settings) for failed attempts and time blocked.
If a user has repeated failed logins the user will be locked temporarily and a **Reset failed logins" link will be shown in the user list. Click the link to unlock it right away, or wait and it will be unlocked automatically. Have a look at the section [Other authentication settings](../../authentication/authentication-settings/#other-authentication-settings) for failed attempts and time blocked.
## Create users
@ -62,9 +62,9 @@ New users can be created and configured by an administrator or by the users them
### Invite user (as administrator)
In the user list, click the **+User** button to open the "New user" form.
In the user list, click the **+User** button to open the **New user** form.
![new user](image-20200115155855409.png)
![Create a new user in OpenProject](openproject_system_guide_create_user.png)
Enter the email address, first name, and last name of the new user. Tick the box to make them a system administrator user.
@ -75,28 +75,28 @@ When adding the last of multiple users you can click on **Create** or click the
### Create user (via self-registration)
To allow users to create their own user accounts allow self-registration in the [authentication settings](../../authentication/authentication-settings). A person can then create their own user from the home page by clicking on the "Sign in" button (top right), then on the "Create a new account" link in the sign in box.
To allow users to create their own user accounts allow self-registration in the [authentication settings](../../authentication/authentication-settings). A person can then create their own user from the home page by clicking on the **Sign in** button (top right), then on the **Create a new account** link in the sign in box.
Enter values in all fields (they cannot be left blank). The email field must be a valid email address that is not used in this system. Click the **Create** button. Depending on the [settings](../../authentication/authentication-settings) the account is created but it could be that it still needs to be activated by an administrator.
#### Activate users
Open the user list. If a user has created their own account (and it has not been activated automatically) it is shown in the user list with an "Activate" link on the right. Click this link and continue to add details to this user as below. There is also an "Activate" button at the top of the user's details page.
Open the user list. If a user has created their own account (and it has not been activated automatically) it is shown in the user list with an **Activate** link on the right. Click this link and continue to add details to this user as below. There is also an **Activate** button at the top of the user's details page.
### Set initial details
You can edit the details of a newly created user. Useful fields might be Username, Language and Time zone. You might also fill Projects, Groups and Rates, or leave these to the "Project creator".
You can edit the details of a newly created user. Useful fields might be **Username**, **Language** and **Time zone**. You might also fill Projects, Groups and Rates, or leave these to the **Project creator**.
Also consider the [authentication](#authentication) settings.
See [Manage user settings](#manage-user-settings) for full details.
### Resend user invitation via email
If a user did not receive the email invitation or shall change their authentication method to email, you can send the invitation to the user again if needed. In the user list, click on the user name to whom you want to resend the email with the invitation link to the system.
If a user did not receive the email invitation or shall change their authentication method to email, you can send the invitation to the user again if needed. In the user list, click on the name of the user to whom you want to resend the email with the invitation link to the system.
In the top right, click the **Send invitation** button in order to send the email once again.
![Sys-admin-resend-invitation](Sys-admin-resend-invitation.png)
![Send user invitation in OpenProject](openproject_system_guide_send_user_invitation.png)
### Delete user invitations

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@ -256,6 +256,9 @@ module Redmine
# Skip when the old value equals the new value (no change happened).
next cfv_changes if value_was == cfv.value
# Skip when the new value is the default value
next cfv_changes if value_was.nil? && cfv.default?
cfv_changes.merge("custom_field_#{cfv.custom_field_id}": [value_was, cfv.value])
end
end

@ -166,13 +166,15 @@ describe "Notification center date alerts", js: true, with_settings: { journal_a
end
def run_create_date_alerts_notifications_job
create_date_alerts_service = Notifications::CreateDateAlertsNotificationsJob::Service.new([timezone_time('1:00', time_zone)])
create_date_alerts_service = Notifications::ScheduleDateAlertsNotificationsJob::Service
.new([timezone_time('1:00', time_zone)])
travel_to(timezone_time('1:04', time_zone))
create_date_alerts_service.call
end
before do
run_create_date_alerts_notifications_job
perform_enqueued_jobs
login_as user
visit notifications_center_path
end

@ -135,5 +135,16 @@ describe WorkPackage, 'acts_as_customizable' do
before do
setup_custom_field(custom_field)
end
context 'with a default value' do
before do
custom_field.update! default_value: 'foobar'
model_instance.custom_values.destroy_all
end
it 'returns no changes' do
expect(model_instance.custom_field_changes).to be_empty
end
end
end
end

@ -23,7 +23,7 @@
# 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.
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
@ -48,43 +48,19 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
shared_let(:in_3_days) { today + 3.days }
shared_let(:in_7_days) { today + 7.days }
shared_let(:user_paris) do
shared_let(:user) do
create(
:user,
firstname: 'Paris',
preferences: { time_zone: timezone_paris.name }
)
end
shared_let(:user_berlin) do
create(
:user,
firstname: 'Berlin',
preferences: { time_zone: timezone_berlin.name }
)
end
shared_let(:user_kathmandu) do
create(
:user,
firstname: 'Kathmandu',
preferences: { time_zone: timezone_kathmandu.name }
)
end
shared_let(:alertable_work_packages) do
create_list(:work_package, 2, project:, author: user_paris)
end
let(:scheduled_job) do
described_class.ensure_scheduled!
described_class.delayed_job
create_list(:work_package, 2, project:, author: user)
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)
end
let(:job) { described_class }
define :have_a_start_date_alert_notification_for do |work_package|
match do |user|
@ -154,12 +130,8 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
end
end
def set_scheduled_time(run_at)
scheduled_job.update_column(:run_at, run_at)
end
def alertable_work_package(attributes = {})
assignee = attributes.slice(:responsible, :responsible_id).any? ? nil : user_paris
assignee = attributes.slice(:responsible, :responsible_id).any? ? nil : user
attributes = attributes.reverse_merge(
start_date: in_1_day,
assigned_to: assignee
@ -179,9 +151,8 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
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
scheduled_job.reload.invoke_job
job.perform_now(user)
yield
end
@ -193,57 +164,63 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
closed_work_package = alertable_work_package(status: status_closed)
run_job do
expect(user_paris).to have_a_start_date_alert_notification_for(open_work_package)
expect(user_paris).not_to have_a_start_date_alert_notification_for(closed_work_package)
expect(user).to have_a_start_date_alert_notification_for(open_work_package)
expect(user).not_to have_a_start_date_alert_notification_for(closed_work_package)
end
end
it 'creates date alert notifications only for users whose local time is 1:00 am when the job is executed' do
work_package_for_paris_user = alertable_work_package(assigned_to: user_paris,
start_date: timezone_paris.today + 1.day)
work_package_for_kathmandu_user = alertable_work_package(assigned_to: user_kathmandu,
start_date: timezone_kathmandu.today + 1.day)
it 'creates date alert notifications if user is assigned to the work package' do
work_package_assigned = alertable_work_package(assigned_to: user)
run_job(timezone: timezone_paris) do
expect(user_paris).to have_a_start_date_alert_notification_for(work_package_for_paris_user)
expect(user_kathmandu).not_to have_a_start_date_alert_notification_for(work_package_for_kathmandu_user)
run_job do
expect(user).to have_a_start_date_alert_notification_for(work_package_assigned)
end
end
# change timezone to cover Kathmandu
run_job(timezone: timezone_kathmandu) do
expect(user_kathmandu).to have_a_start_date_alert_notification_for(work_package_for_kathmandu_user)
it 'creates date alert notifications if user is accountable of the work package' do
work_package_accountable = alertable_work_package(responsible: user)
run_job do
expect(user).to have_a_start_date_alert_notification_for(work_package_accountable)
end
end
it 'creates date alert notifications if user is assigned to or accountable of the work package' do
work_package_assigned = alertable_work_package(assigned_to: user_paris)
work_package_accountable = alertable_work_package(responsible: user_paris)
it 'creates date alert notifications if user is watcher of the work package' do
work_package_watched = alertable_work_package(responsible: nil)
build(:watcher, watchable: work_package_watched, user:).save(validate: false)
run_job do
expect(user_paris).to have_a_start_date_alert_notification_for(work_package_assigned)
expect(user_paris).to have_a_start_date_alert_notification_for(work_package_accountable)
expect(user).to have_a_start_date_alert_notification_for(work_package_watched)
end
end
it 'creates start and finish date alert notifications based on user notification settings' do
user_paris.notification_settings.first.update(
it 'creates start date alert notifications based on user notification settings' do
user.notification_settings.first.update(
start_date: 1,
due_date: nil
)
user_berlin.notification_settings.first.update(
work_package = alertable_work_package(assigned_to: user,
start_date: in_1_day,
due_date: in_3_days)
run_job do
expect(user).to have_a_start_date_alert_notification_for(work_package)
expect(user).not_to have_a_due_date_alert_notification_for(work_package)
end
end
it 'creates due date alert notifications based on user notification settings' do
user.notification_settings.first.update(
start_date: nil,
due_date: 3
)
work_package = alertable_work_package(assigned_to: user_paris,
responsible: user_berlin,
work_package = alertable_work_package(assigned_to: user,
start_date: in_1_day,
due_date: in_3_days)
run_job do
expect(user_paris).to have_a_start_date_alert_notification_for(work_package)
expect(user_paris).not_to have_a_due_date_alert_notification_for(work_package)
expect(user_berlin).not_to have_a_start_date_alert_notification_for(work_package)
expect(user_berlin).to have_a_due_date_alert_notification_for(work_package)
expect(user).not_to have_a_start_date_alert_notification_for(work_package)
expect(user).to have_a_due_date_alert_notification_for(work_package)
end
end
@ -252,7 +229,7 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
work_package = alertable_work_package
run_job do
expect(user_paris).not_to have_a_start_date_alert_notification_for(work_package)
expect(user).not_to have_a_start_date_alert_notification_for(work_package)
end
end
end
@ -260,52 +237,52 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
context 'when project notification settings are defined for a user' do
it 'creates date alert notifications using these settings for work packages of the project' do
# global notification settings
user_paris.notification_settings.first.update(
user.notification_settings.first.update(
start_date: 1,
due_date: nil
)
# project notifications settings
user_paris.notification_settings.create(
user.notification_settings.create(
project:,
start_date: nil,
due_date: 7
)
silent_work_package = alertable_work_package(assigned_to: user_paris,
silent_work_package = alertable_work_package(assigned_to: user,
project:,
start_date: in_1_day,
due_date: in_1_day)
noisy_work_package = alertable_work_package(assigned_to: user_paris,
noisy_work_package = alertable_work_package(assigned_to: user,
project:,
start_date: in_7_days,
due_date: in_7_days)
run_job do
expect(user_paris).not_to have_a_start_date_alert_notification_for(silent_work_package)
expect(user_paris).not_to have_a_due_date_alert_notification_for(silent_work_package)
expect(user_paris).not_to have_a_start_date_alert_notification_for(noisy_work_package)
expect(user_paris).to have_a_due_date_alert_notification_for(noisy_work_package)
expect(user).not_to have_a_start_date_alert_notification_for(silent_work_package)
expect(user).not_to have_a_due_date_alert_notification_for(silent_work_package)
expect(user).not_to have_a_start_date_alert_notification_for(noisy_work_package)
expect(user).to have_a_due_date_alert_notification_for(noisy_work_package)
end
end
end
context 'with existing date alerts' do
it 'marks them as read when new ones are created' do
work_package = alertable_work_package(assigned_to: user_paris,
work_package = alertable_work_package(assigned_to: user,
start_date: in_1_day,
due_date: in_1_day)
existing_start_notification = create(:notification,
resource: work_package,
recipient: user_paris,
recipient: user,
reason: :date_alert_start_date)
existing_due_notification = create(:notification,
resource: work_package,
recipient: user_paris,
recipient: user,
reason: :date_alert_due_date)
run_job do
expect(existing_start_notification.reload).to have_attributes(read_ian: true)
expect(existing_due_notification.reload).to have_attributes(read_ian: true)
unread_date_alert_notifications = Notification.where(recipient: user_paris,
unread_date_alert_notifications = Notification.where(recipient: user,
read_ian: false,
resource: work_package)
expect(unread_date_alert_notifications.pluck(:reason))
@ -315,27 +292,27 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
# rubocop:disable RSpec/ExampleLength
it 'does not mark them as read when if no new notifications are created' do
work_package_start = alertable_work_package(assigned_to: user_paris,
work_package_start = alertable_work_package(assigned_to: user,
start_date: in_1_day,
due_date: nil)
work_package_due = alertable_work_package(assigned_to: user_paris,
work_package_due = alertable_work_package(assigned_to: user,
start_date: nil,
due_date: in_1_day)
existing_wp_start_start_notif = create(:notification,
reason: :date_alert_start_date,
recipient: user_paris,
recipient: user,
resource: work_package_start)
existing_wp_start_due_notif = create(:notification,
reason: :date_alert_due_date,
recipient: user_paris,
recipient: user,
resource: work_package_start)
existing_wp_due_start_notif = create(:notification,
reason: :date_alert_start_date,
recipient: user_paris,
recipient: user,
resource: work_package_due)
existing_wp_due_due_notif = create(:notification,
reason: :date_alert_due_date,
recipient: user_paris,
recipient: user,
resource: work_package_due)
run_job do
@ -347,45 +324,5 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: %
end
# rubocop:enable RSpec/ExampleLength
end
context 'when scheduled and executed at 01:00 am local time' do
it 'creates a start date alert notification for a user in the same time zone' do
work_package = alertable_work_package
run_job(scheduled_at: '1:00', local_time: '1:00') do
expect(user_paris).to have_a_start_date_alert_notification_for(work_package)
end
end
end
context 'when scheduled and executed at 01:14 am local time' do
it 'creates a start date alert notification for a user in the same time zone' do
work_package = alertable_work_package
run_job(scheduled_at: '1:14', local_time: '1:14') do
expect(user_paris).to have_a_start_date_alert_notification_for(work_package)
end
end
end
context 'when scheduled and executed at 01:15 am local time' do
it 'does not create a start date alert notification for a user in the same time zone' do
work_package = alertable_work_package
run_job(scheduled_at: '1:15', local_time: '1:15') do
expect(user_paris).not_to have_a_start_date_alert_notification_for(work_package)
end
end
end
context 'when scheduled at 01:00 am local time and executed at 01:37 am local time' do
it 'creates a start date alert notification for a user in the same time zone' do
work_package = alertable_work_package
run_job(scheduled_at: '1:00', local_time: '1:37') do
expect(user_paris).to have_a_start_date_alert_notification_for(work_package)
end
end
end
end
end

@ -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…
Cancel
Save