Merge branch 'feature/38339-add-mark-notification-button-to-split-screen' of github.com:opf/openproject into feature/38339-add-mark-notification-button-to-split-screen

pull/9599/head
Benjamin Bädorf 3 years ago
commit ffe9c46e52
No known key found for this signature in database
GPG Key ID: 069CA2D117AB5CCF
  1. 4
      .rubocop.yml
  2. 118
      Gemfile.lock
  3. 45
      app/helpers/work_packages_helper.rb
  4. 2
      app/mailers/application_mailer.rb
  5. 13
      app/models/comment.rb
  6. 2
      app/models/journal.rb
  7. 36
      app/models/journal/associated_journal.rb
  8. 2
      app/models/journal/attachable_journal.rb
  9. 6
      app/models/journal/base_journal.rb
  10. 4
      app/models/journal/customizable_journal.rb
  11. 3
      app/models/journal/message_journal.rb
  12. 2
      app/models/journal/news_journal.rb
  13. 3
      app/models/journal/wiki_content_journal.rb
  14. 15
      app/models/message.rb
  15. 15
      app/models/news.rb
  16. 7
      app/models/notification.rb
  17. 42
      app/models/notifications/scopes/recipient.rb
  18. 42
      app/models/notifications/scopes/unread_mail.rb
  19. 42
      app/models/notifications/scopes/unread_mail_digest.rb
  20. 3
      app/models/user.rb
  21. 10
      app/models/user_preference.rb
  22. 49
      app/models/users/scopes/watcher_recipients.rb
  23. 4
      app/models/wiki_content.rb
  24. 311
      app/services/notifications/create_from_model_service.rb
  25. 43
      app/services/notifications/create_from_model_service/comment_strategy.rb
  26. 70
      app/services/notifications/create_from_model_service/message_strategy.rb
  27. 40
      app/services/notifications/create_from_model_service/news_strategy.rb
  28. 74
      app/services/notifications/create_from_model_service/wiki_content_strategy.rb
  29. 67
      app/services/notifications/create_from_model_service/work_package_strategy.rb
  30. 41
      app/services/notifications/create_service.rb
  31. 120
      app/services/notifications/journal_wiki_mail_service.rb
  32. 262
      app/services/notifications/journal_wp_notification_service.rb
  33. 77
      app/services/notifications/mail_service.rb
  34. 30
      app/services/notifications/mail_service/comment_strategy.rb
  35. 49
      app/services/notifications/mail_service/message_strategy.rb
  36. 49
      app/services/notifications/mail_service/news_strategy.rb
  37. 58
      app/services/notifications/mail_service/wiki_content_strategy.rb
  38. 48
      app/services/notifications/mail_service/work_package_strategy.rb
  39. 5
      app/services/work_packages/copy_service.rb
  40. 2
      app/views/digest_mailer/work_packages.html.erb
  41. 81
      app/workers/concerns/state_machine_job.rb
  42. 49
      app/workers/journals/completed_job.rb
  43. 21
      app/workers/mails/digest_job.rb
  44. 6
      app/workers/mails/watcher_job.rb
  45. 85
      app/workers/notifications/workflow_job.rb
  46. 28
      config/initializers/subscribe_listeners.rb
  47. 2
      config/locales/crowdin/ar.yml
  48. 2
      config/locales/crowdin/bg.yml
  49. 2
      config/locales/crowdin/ca.yml
  50. 2
      config/locales/crowdin/cs.yml
  51. 2
      config/locales/crowdin/da.yml
  52. 2
      config/locales/crowdin/de.yml
  53. 2
      config/locales/crowdin/el.yml
  54. 2
      config/locales/crowdin/es.yml
  55. 2
      config/locales/crowdin/fi.yml
  56. 2
      config/locales/crowdin/fil.yml
  57. 2
      config/locales/crowdin/fr.yml
  58. 2
      config/locales/crowdin/hr.yml
  59. 24
      config/locales/crowdin/hu.yml
  60. 2
      config/locales/crowdin/id.yml
  61. 2
      config/locales/crowdin/it.yml
  62. 2
      config/locales/crowdin/ja.yml
  63. 2
      config/locales/crowdin/js-ar.yml
  64. 2
      config/locales/crowdin/js-bg.yml
  65. 2
      config/locales/crowdin/js-ca.yml
  66. 2
      config/locales/crowdin/js-cs.yml
  67. 2
      config/locales/crowdin/js-da.yml
  68. 2
      config/locales/crowdin/js-de.yml
  69. 2
      config/locales/crowdin/js-el.yml
  70. 2
      config/locales/crowdin/js-es.yml
  71. 2
      config/locales/crowdin/js-fi.yml
  72. 2
      config/locales/crowdin/js-fil.yml
  73. 2
      config/locales/crowdin/js-fr.yml
  74. 2
      config/locales/crowdin/js-hr.yml
  75. 16
      config/locales/crowdin/js-hu.yml
  76. 2
      config/locales/crowdin/js-id.yml
  77. 2
      config/locales/crowdin/js-it.yml
  78. 2
      config/locales/crowdin/js-ja.yml
  79. 2
      config/locales/crowdin/js-ko.yml
  80. 2
      config/locales/crowdin/js-lt.yml
  81. 2
      config/locales/crowdin/js-nl.yml
  82. 2
      config/locales/crowdin/js-no.yml
  83. 2
      config/locales/crowdin/js-pl.yml
  84. 2
      config/locales/crowdin/js-pt.yml
  85. 2
      config/locales/crowdin/js-ro.yml
  86. 2
      config/locales/crowdin/js-ru.yml
  87. 2
      config/locales/crowdin/js-sk.yml
  88. 2
      config/locales/crowdin/js-sl.yml
  89. 2
      config/locales/crowdin/js-sv.yml
  90. 2
      config/locales/crowdin/js-tr.yml
  91. 2
      config/locales/crowdin/js-uk.yml
  92. 2
      config/locales/crowdin/js-vi.yml
  93. 6
      config/locales/crowdin/js-zh-CN.yml
  94. 2
      config/locales/crowdin/js-zh-TW.yml
  95. 2
      config/locales/crowdin/ko.yml
  96. 6
      config/locales/crowdin/lt.yml
  97. 2
      config/locales/crowdin/nl.yml
  98. 2
      config/locales/crowdin/no.yml
  99. 2
      config/locales/crowdin/pl.yml
  100. 2
      config/locales/crowdin/pt.yml
  101. Some files were not shown because too many files have changed in this diff Show More

@ -179,6 +179,10 @@ RSpec/LeadingSubject:
RSpec/NamedSubject:
Enabled: false
RSpec/FilePath:
Enabled: false
Style/Alias:
Enabled: false

@ -183,28 +183,28 @@ GEM
remote: https://rubygems.org/
specs:
Ascii85 (1.1.0)
actioncable (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
actioncable (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
actionmailbox (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
mail (>= 2.7.1)
actionmailer (6.1.4)
actionpack (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activesupport (= 6.1.4)
actionmailer (6.1.4.1)
actionpack (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activesupport (= 6.1.4.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4)
actionview (= 6.1.4)
activesupport (= 6.1.4)
actionpack (6.1.4.1)
actionview (= 6.1.4.1)
activesupport (= 6.1.4.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
@ -212,30 +212,30 @@ GEM
actionpack-xml_parser (2.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
actiontext (6.1.4)
actionpack (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
actiontext (6.1.4.1)
actionpack (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
nokogiri (>= 1.8.5)
actionview (6.1.4)
activesupport (= 6.1.4)
actionview (6.1.4.1)
activesupport (= 6.1.4.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.4)
activesupport (= 6.1.4)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
globalid (>= 0.3.6)
activemodel (6.1.4)
activesupport (= 6.1.4)
activemodel (6.1.4.1)
activesupport (= 6.1.4.1)
activemodel-serializers-xml (1.0.2)
activemodel (> 5.x)
activesupport (> 5.x)
builder (~> 3.1)
activerecord (6.1.4)
activemodel (= 6.1.4)
activesupport (= 6.1.4)
activerecord (6.1.4.1)
activemodel (= 6.1.4.1)
activesupport (= 6.1.4.1)
activerecord-import (1.1.0)
activerecord (>= 3.2)
activerecord-nulldb-adapter (0.7.0)
@ -246,14 +246,14 @@ GEM
multi_json (~> 1.11, >= 1.11.2)
rack (>= 2.0.8, < 3)
railties (>= 5.2.4.1)
activestorage (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activesupport (= 6.1.4)
activestorage (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activesupport (= 6.1.4.1)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4)
activesupport (6.1.4.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -504,8 +504,8 @@ GEM
ffi (~> 1.0)
git (1.9.1)
rchardet (~> 1.8)
globalid (0.4.2)
activesupport (>= 4.2.0)
globalid (0.5.2)
activesupport (>= 5.0)
gon (6.4.0)
actionpack (>= 3.0.20)
i18n (>= 0.7)
@ -579,7 +579,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.10.0)
loofah (2.12.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -593,7 +593,7 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0704)
mini_magick (4.11.0)
mini_mime (1.1.0)
mini_mime (1.1.1)
mini_portile2 (2.5.3)
minisyntax (0.2.5)
minitest (5.14.4)
@ -608,7 +608,7 @@ GEM
nap (1.1.0)
net-ldap (0.17.0)
netrc (0.11.0)
nio4r (2.5.7)
nio4r (2.5.8)
no_proxy_fix (0.1.2)
nokogiri (1.11.7)
mini_portile2 (~> 2.5.0)
@ -713,20 +713,20 @@ GEM
rack_session_access (0.2.0)
builder (>= 2.0.0)
rack (>= 1.0.0)
rails (6.1.4)
actioncable (= 6.1.4)
actionmailbox (= 6.1.4)
actionmailer (= 6.1.4)
actionpack (= 6.1.4)
actiontext (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activemodel (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
actionmailer (= 6.1.4.1)
actionpack (= 6.1.4.1)
actiontext (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activemodel (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
bundler (>= 1.15.0)
railties (= 6.1.4)
railties (= 6.1.4.1)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
@ -735,14 +735,14 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0)
rails-html-sanitizer (1.4.1)
loofah (~> 2.3)
rails-i18n (6.0.0)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7)
railties (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
method_source
rake (>= 0.13)
thor (~> 1.0)

@ -35,17 +35,16 @@ module WorkPackagesHelper
# Displays a link to +work_package+ with its subject.
# Examples:
#
# link_to_work_package(package) # => Defect #6: This is the subject
# link_to_work_package(package, all_link: true) # => Defect #6: This is the subject (everything within the link)
# link_to_work_package(package, truncate: 9) # => Defect #6: This i...
# link_to_work_package(package, subject: false) # => Defect #6
# link_to_work_package(package, type: false) # => #6: This is the subject
# link_to_work_package(package, project: true) # => Foo - Defect #6
# link_to_work_package(package, id_only: true) # => #6
# link_to_work_package(package, subject_only: true) # => This is the subject (as link)
# link_to_work_package(package, status: true) # => #6 New (if #id => true)
# link_to_work_package(package) # => Defect #6: This is the subject
# link_to_work_package(package, all_link: true) # => Defect #6: This is the subject (everything within the link)
# link_to_work_package(package, truncate: 9) # => Defect #6: This i...
# link_to_work_package(package, subject: false) # => Defect #6
# link_to_work_package(package, type: false) # => #6: This is the subject
# link_to_work_package(package, project: true) # => Foo - Defect #6
# link_to_work_package(package, id_only: true) # => #6
# link_to_work_package(package, subject_only: true) # => This is the subject (as link)
# link_to_work_package(package, status: true) # => #6 New (if #id => true)
def link_to_work_package(package, options = {})
only_path = options.fetch(:only_path) { true }
if options[:subject_only]
options.merge!(type: false,
subject: true,
@ -67,7 +66,7 @@ module WorkPackagesHelper
link: [],
suffix: [],
title: [],
css_class: ['issue'] }
css_class: link_to_work_package_css_classes(package, options) }
# Prefix part
@ -85,12 +84,10 @@ module WorkPackagesHelper
# Hidden link part
if package.closed?
if package.closed? && !options[:no_hidden]
parts[:hidden_link] << content_tag(:span,
I18n.t(:label_closed_work_packages),
class: 'hidden-for-sighted')
parts[:css_class] << 'closed'
end
# Suffix part
@ -117,14 +114,13 @@ module WorkPackagesHelper
prefix = parts[:prefix].join(' ')
suffix = parts[:suffix].join(' ')
link = parts[:link].join(' ').strip
hidden_link = parts[:hidden_link].join('')
hidden_link = parts[:hidden_link].join
title = parts[:title].join(' ')
css_class = parts[:css_class].join(' ')
css_class << options[:class].to_s
# Determine path or url
work_package_link =
if only_path
if options.fetch(:only_path, true)
work_package_path(package)
else
work_package_url(package)
@ -133,14 +129,14 @@ module WorkPackagesHelper
if options[:all_link]
link_text = [prefix, link].reject(&:empty?).join(' - ')
link_text = [link_text, suffix].reject(&:empty?).join(': ')
link_text = [hidden_link, link_text].reject(&:empty?).join('')
link_text = [hidden_link, link_text].reject(&:empty?).join
link_to(link_text.html_safe,
work_package_link,
title: title,
class: css_class)
else
link_text = [hidden_link, link].reject(&:empty?).join('')
link_text = [hidden_link, link].reject(&:empty?).join
html_link = link_to(link_text.html_safe,
work_package_link,
@ -179,8 +175,7 @@ module WorkPackagesHelper
# Returns a string of css classes that apply to the issue
def work_package_css_classes(work_package)
# TODO: remove issue once css is cleaned of it
s = 'issue work_package preview-trigger'.html_safe
s = 'work_package preview-trigger'.html_safe
s << " status-#{work_package.status.position}" if work_package.status
s << " priority-#{work_package.priority.position}" if work_package.priority
s << ' closed' if work_package.closed?
@ -258,4 +253,12 @@ module WorkPackagesHelper
[responsible, assignee].compact.join('<br>').html_safe
end
def link_to_work_package_css_classes(package, options)
classes = ['work_package']
classes << 'closed' if package.closed?
classes << options[:class].to_s
classes
end
end

@ -74,7 +74,7 @@ class ApplicationMailer < ActionMailer::Base
end
def remove_self_notifications(message, author)
if author.pref && author.pref[:no_self_notified] && message.to.present?
if author.pref && message.to.present?
message.to = message.to.reject { |address| address == author.mail }
end
end

@ -30,7 +30,7 @@
class Comment < ApplicationRecord
belongs_to :commented, polymorphic: true, counter_cache: true
belongs_to :author, class_name: 'User', foreign_key: 'author_id'
belongs_to :author, class_name: 'User'
validates :commented, :author, :comments, presence: true
@ -47,13 +47,8 @@ class Comment < ApplicationRecord
private
def send_news_comment_added_mail
return unless Setting.notified_events.include?('news_comment_added')
return unless commented.is_a?(News)
recipients = commented.recipients + commented.watcher_recipients
recipients.uniq.each do |user|
UserMailer.news_comment_added(user, self, User.current).deliver_later
end
OpenProject::Notifications.send(OpenProject::Events::NEWS_COMMENT_CREATED,
comment: self,
send_notification: true)
end
end

@ -50,6 +50,8 @@ class Journal < ApplicationRecord
has_many :attachable_journals, class_name: 'Journal::AttachableJournal', dependent: :destroy
has_many :customizable_journals, class_name: 'Journal::CustomizableJournal', dependent: :destroy
has_many :notifications, dependent: :destroy
# Scopes to all journals excluding the initial journal - useful for change
# logs like the history on issue#show
scope :changing, -> { where(['version > 1']) }

@ -0,0 +1,36 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
# AssociatedJournals that belong to another journal reflecting
# an has_many relation (e.g. custom_values) on the journaled object.
class Journal::AssociatedJournal < ApplicationRecord
self.abstract_class = true
belongs_to :author, class_name: 'User'
belongs_to :journal
end

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Journal::AttachableJournal < Journal::BaseJournal
class Journal::AttachableJournal < Journal::AssociatedJournal
self.table_name = 'attachable_journals'
belongs_to :attachment

@ -29,8 +29,8 @@
class Journal::BaseJournal < ApplicationRecord
self.abstract_class = true
belongs_to :journal
belongs_to :author, class_name: 'User', foreign_key: :author_id
has_one :journal, as: :data, inverse_of: :data, dependent: :destroy
belongs_to :author, class_name: 'User'
def journaled_attributes
attributes.symbolize_keys.select { |k, _| self.class.journaled_attributes.include? k }
@ -41,7 +41,7 @@ class Journal::BaseJournal < ApplicationRecord
end
def self.excluded_attributes
[primary_key.to_sym, inheritance_column.to_sym, :journal_id]
[primary_key.to_sym, inheritance_column.to_sym]
end
private_class_method :excluded_attributes
end

@ -28,8 +28,8 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Journal::CustomizableJournal < Journal::BaseJournal
class Journal::CustomizableJournal < Journal::AssociatedJournal
self.table_name = 'customizable_journals'
belongs_to :custom_field, foreign_key: :custom_field_id
belongs_to :custom_field
end

@ -30,4 +30,7 @@
class Journal::MessageJournal < Journal::BaseJournal
self.table_name = 'message_journals'
belongs_to :forum
has_one :project, through: :forum
end

@ -30,4 +30,6 @@
class Journal::NewsJournal < Journal::BaseJournal
self.table_name = 'news_journals'
belongs_to :project
end

@ -30,4 +30,7 @@
class Journal::WikiContentJournal < Journal::BaseJournal
self.table_name = 'wiki_content_journals'
# The project does not change over the course of a wiki content lifetime
delegate :project, to: :journal
end

@ -67,8 +67,7 @@ class Message < ApplicationRecord
validates_length_of :subject, maximum: 255
after_create :add_author_as_watcher,
:update_last_reply_in_parent,
:send_message_posted_mail
:update_last_reply_in_parent
after_update :update_ancestors, if: :saved_change_to_forum_id?
after_destroy :reset_counters
@ -149,16 +148,4 @@ class Message < ApplicationRecord
watchers.reload
watcher_users.reload
end
def send_message_posted_mail
return unless Setting.notified_events.include?('message_posted')
to_mail = recipients +
root.watcher_recipients +
forum.watcher_recipients
to_mail.uniq.each do |user|
UserMailer.message_posted(user, self, User.current).deliver_later
end
end
end

@ -30,7 +30,7 @@
class News < ApplicationRecord
belongs_to :project
belongs_to :author, class_name: 'User', foreign_key: 'author_id'
belongs_to :author, class_name: 'User'
has_many :comments, -> {
order(:created_at)
}, as: :commented, dependent: :delete_all
@ -43,15 +43,14 @@ class News < ApplicationRecord
acts_as_event url: Proc.new { |o| { controller: '/news', action: 'show', id: o.id } }
acts_as_searchable columns: ["#{table_name}.title", "#{table_name}.summary", "#{table_name}.description"],
acts_as_searchable columns: %W[#{table_name}.title #{table_name}.summary #{table_name}.description],
include: :project,
references: :projects,
date_column: "#{table_name}.created_at"
acts_as_watchable
after_create :add_author_as_watcher,
:send_news_added_mail
after_create :add_author_as_watcher
scope :visible, ->(*args) do
includes(:project)
@ -106,12 +105,4 @@ class News < ApplicationRecord
def add_author_as_watcher
Watcher.create(watchable: self, user: author)
end
def send_news_added_mail
if Setting.notified_events.include?('news_added')
recipients.uniq.each do |user|
UserMailer.news_added(user, self, User.current).deliver_later
end
end
end
end

@ -21,8 +21,9 @@ class Notification < ApplicationRecord
belongs_to :journal
belongs_to :resource, polymorphic: true
scope :recipient, ->(user) { where(recipient_id: user.is_a?(User) ? user.id : user) }
include Scopes::Scoped
scopes :mail_digest_before
scopes :mail_digest_before,
:unread_mail,
:unread_mail_digest,
:recipient
end

@ -0,0 +1,42 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
# Return mail notifications destined at the provided recipient
module Notifications::Scopes
module Recipient
extend ActiveSupport::Concern
class_methods do
def recipient(user)
where(recipient_id: user.is_a?(User) ? user.id : user)
end
end
end
end

@ -0,0 +1,42 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
# Return mail notifications that are unread (have read_mail: false)
module Notifications::Scopes
module UnreadMail
extend ActiveSupport::Concern
class_methods do
def unread_mail
where(read_mail: false)
end
end
end
end

@ -0,0 +1,42 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
# Return digest mail notifications that are unread (have read_digest_mail: false)
module Notifications::Scopes
module UnreadMailDigest
extend ActiveSupport::Concern
class_methods do
def unread_mail_digest
where(read_mail_digest: false)
end
end
end
end

@ -75,7 +75,8 @@ class User < Principal
scopes :find_by_login,
:newest,
:notified_on_all
:notified_on_all,
:watcher_recipients
def self.create_blocked_scope(scope, blocked)
scope.where(blocked_condition(blocked))

@ -59,14 +59,6 @@ class UserPreference < ApplicationRecord
comments_sorting == 'desc'
end
def self_notified?
!others[:no_self_notified]
end
def self_notified=(value)
others[:no_self_notified] = !value
end
def auto_hide_popups=(value)
others[:auto_hide_popups] = to_boolean(value)
end
@ -116,7 +108,7 @@ class UserPreference < ApplicationRecord
end
def init_other_preferences
self.others ||= { no_self_notified: true }
self.others ||= {}
end
def time_zone_correctness

@ -0,0 +1,49 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
# Returns a scope of users watching the instance that should be notified via whatever channel upon updates to the instance.
# The users need to have the necessary permissions to see the instance as defined by the watchable_permission.
# Additionally, the users need to have their mail notification setting set to watched: true.
module Users::Scopes
module WatcherRecipients
extend ActiveSupport::Concern
class_methods do
def watcher_recipients(model)
model
.possible_watcher_users
.where(id: NotificationSetting
.applicable(model.project)
.where(watched: true, user_id: model.watcher_users)
.select(:user_id))
end
end
end
end

@ -31,8 +31,8 @@
class WikiContent < ApplicationRecord
extend DeprecatedAlias
belongs_to :page, class_name: 'WikiPage', foreign_key: 'page_id'
belongs_to :author, class_name: 'User', foreign_key: 'author_id'
belongs_to :page, class_name: 'WikiPage'
belongs_to :author, class_name: 'User'
validates_length_of :comments, maximum: 255, allow_nil: true
attr_accessor :comments

@ -0,0 +1,311 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
class Notifications::CreateFromModelService
MENTION_USER_ID_PATTERN =
'<mention[^>]*(?:data-type="user"[^>]*data-id="(\d+)")|(?:data-id="(\d+)"[^>]*data-type="user")[^>]*>)|(?:\buser#(\d+)\b'
.freeze
MENTION_USER_LOGIN_PATTERN =
'\buser:"(.+?)"'.freeze
MENTION_GROUP_ID_PATTERN =
'<mention[^>]*(?:data-type="group"[^>]*data-id="(\d+)")|(?:data-id="(\d+)"[^>]*data-type="group")[^>]*>)|(?:\bgroup#(\d+)\b'
.freeze
MENTION_PATTERN = Regexp.new("(?:#{MENTION_USER_ID_PATTERN})|(?:#{MENTION_USER_LOGIN_PATTERN})|(?:#{MENTION_GROUP_ID_PATTERN})")
def initialize(model)
self.model = model
end
def call(send_notifications)
result = ServiceResult.new success: !abort_sending?(send_notifications)
return result if result.failure?
notification_receivers.each do |recipient_id, channel_reasons|
call = create_notification(recipient_id, channel_reasons)
result.add_dependent!(call)
end
result
end
private
attr_accessor :model
def create_notification(recipient_id, channel_reasons)
notification_attributes = {
recipient_id: recipient_id,
project: project,
resource: resource,
journal: journal,
actor: user_with_fallback
}.merge(channel_attributes(channel_reasons))
Notifications::CreateService
.new(user: user_with_fallback)
.call(notification_attributes)
end
def channel_attributes(channel_reasons)
channel_attributes_mail(channel_reasons)
.merge(channel_attributes_mail_digest(channel_reasons))
.merge(channel_attributes_ian(channel_reasons))
end
def channel_attributes_mail(channel_reasons)
{
read_mail: strategy.supports_mail? && channel_reasons.keys.include?('mail') ? false : nil,
reason_mail: strategy.supports_mail? && channel_reasons['mail']&.first
}
end
def channel_attributes_mail_digest(channel_reasons)
{
read_mail_digest: strategy.supports_mail_digest? && channel_reasons.keys.include?('mail_digest') ? false : nil,
reason_mail_digest: strategy.supports_mail_digest? && channel_reasons['mail_digest']&.first
}
end
def channel_attributes_ian(channel_reasons)
{
read_ian: strategy.supports_ian? && channel_reasons.keys.include?('in_app') ? false : nil,
reason_ian: strategy.supports_ian? && channel_reasons['in_app']&.first
}
end
def notification_receivers
receivers = receivers_hash
strategy.reasons.each do |reason|
add_receivers_by_reason(receivers, reason)
end
remove_self_recipient(receivers)
receivers
end
def add_receivers_by_reason(receivers, reason)
add_receiver(receivers, send(:"settings_of_#{reason}"), reason)
end
def settings_of_mentioned
applicable_settings(mentioned_ids,
project,
:mentioned)
end
def settings_of_involved
scope = User
.where(id: group_or_user_ids(journal.data.assigned_to))
.or(User.where(id: group_or_user_ids(journal.data.responsible)))
applicable_settings(scope,
project,
:involved)
end
def settings_of_subscribed
applicable_settings(strategy.subscribed_users(model),
project,
:all)
end
def settings_of_watched
applicable_settings(strategy.watcher_users(model),
project,
:watched)
end
def settings_of_commented
return NotificationSetting.none unless journal.notes?
applicable_settings(User.all,
project,
:work_package_commented)
end
def settings_of_created
return NotificationSetting.none unless journal.initial?
applicable_settings(User.all,
project,
:work_package_created)
end
def settings_of_processed
return NotificationSetting.none unless !journal.initial? && journal.details.has_key?(:status_id)
applicable_settings(User.all,
project,
:work_package_processed)
end
def settings_of_prioritized
return NotificationSetting.none unless !journal.initial? && journal.details.has_key?(:priority_id)
applicable_settings(User.all,
project,
:work_package_prioritized)
end
def settings_of_scheduled
if journal.initial? || !(journal.details.has_key?(:start_date) || journal.details.has_key?(:due_date))
return NotificationSetting.none
end
applicable_settings(User.all,
project,
:work_package_scheduled)
end
def applicable_settings(user_scope, project, reason)
NotificationSetting
.applicable(project)
.where(reason => true)
.where(user: user_scope.where(id: User.allowed(strategy.permission, project)))
end
def text_for_mentions
potential_text = ""
potential_text << journal.notes if journal.try(:notes)
%i[description subject].each do |field|
details = journal.details[field]
if details.present?
potential_text << "\n#{Redmine::Helpers::Diff.new(*details.reverse).additions.join(' ')}"
end
end
potential_text
end
def mentioned_ids
matches = mention_matches
base_scope = User
.includes(:groups)
.references(:groups_users)
by_id = base_scope.where(id: matches[:user_ids])
by_login = base_scope.where(login: matches[:user_login_names])
by_group = base_scope.where(groups_users: { id: matches[:group_ids] })
by_id
.or(by_login)
.or(by_group)
end
def send_notification?(send_notifications)
send_notifications && ::UserMailer.perform_deliveries
end
def mention_matches
text = text_for_mentions
user_ids_tag_after,
user_ids_tag_before,
user_ids_hash,
user_login_names,
group_ids_tag_after,
group_ids_tag_before,
group_ids_hash = text
.scan(MENTION_PATTERN)
.transpose
.each(&:compact!)
{
user_ids: [user_ids_tag_after, user_ids_tag_before, user_ids_hash].flatten.compact,
user_login_names: [user_login_names].flatten.compact,
group_ids: [group_ids_tag_after, group_ids_tag_before, group_ids_hash].flatten.compact
}
end
def abort_sending?(send_notifications)
!send_notification?(send_notifications) ||
model.nil? ||
!model.class.exists?(id: model.id) ||
journal&.noop? ||
!supported?
end
def group_or_user_ids(association)
association.is_a?(Group) ? association.user_ids : association&.id
end
def user_with_fallback
user || DeletedUser.first
end
def add_receiver(receivers, collection, reason)
collection.each do |notification|
receivers[notification.user_id][notification.channel] << reason
end
end
def remove_self_recipient(receivers)
receivers.delete(user_with_fallback.id)
end
def receivers_hash
Hash.new do |hash, user|
hash[user] = Hash.new do |channel_hash, channel|
channel_hash[channel] = []
end
end
end
def strategy
@strategy ||= if self.class.const_defined?("#{resource.class}Strategy")
"#{self.class}::#{resource.class}Strategy".constantize
end
end
def supported?
strategy.present?
end
def user
strategy.user(model)
end
def project
strategy.project(model)
end
def resource
model.is_a?(Journal) ? model.journable : model
end
def journal
model.is_a?(Journal) ? model : nil
end
end

@ -1,3 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
@ -26,27 +28,40 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Mails::NotificationJob < ApplicationJob
queue_with_priority :notification
module Notifications::CreateFromModelService::CommentStrategy
def self.reasons
%i(watched subscribed)
end
def perform(notification)
ensure_supported(notification)
def self.permission
:view_news
end
return if ian_read?(notification)
def self.supports_ian?
false
end
Mails::WorkPackageJob
.perform_now(notification.journal, notification.recipient_id, notification.actor_id)
def self.supports_mail_digest?
false
end
private
def self.supports_mail?
true
end
def self.subscribed_users(comment)
User.notified_on_all(project(comment))
end
def self.watcher_users(comment)
User.watcher_recipients(comment.commented)
end
def ensure_supported(notification)
unless notification.journal && notification.journal.journable.is_a?(WorkPackage)
raise ArgumentError, "Only notification for work package journals are currently supported"
end
def self.project(comment)
comment.commented.project
end
def ian_read?(notification)
notification.read_ian
def self.user(comment)
comment.author
end
end

@ -0,0 +1,70 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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::CreateFromModelService::MessageStrategy
def self.reasons
%i(watched subscribed)
end
def self.permission
:view_messages
end
def self.supports_ian?
false
end
def self.supports_mail_digest?
false
end
def self.supports_mail?
true
end
def self.subscribed_users(journal)
User.notified_on_all(journal.data.project)
end
def self.watcher_users(journal)
message = journal.journable
User.watcher_recipients(message.root)
.or(User.watcher_recipients(message.forum))
end
def self.project(journal)
journal.data.project
end
def self.user(journal)
journal.user
end
end

@ -28,31 +28,41 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Mails::WorkPackageJob < Mails::DeliverJob
include Mails::WithSender
module Notifications::CreateFromModelService::NewsStrategy
def self.reasons
%i(subscribed)
end
def self.permission
:view_news
end
def perform(journal_id, recipient_id, author_id)
@journal_id = journal_id
super(recipient_id, author_id)
def self.supports_ian?
false
end
def render_mail
return nil unless journal # abort, assuming that the underlying WP was deleted
def self.supports_mail_digest?
false
end
def self.supports_mail?
true
end
def self.subscribed_users(journal)
if journal.initial?
UserMailer.work_package_added(recipient, journal, sender)
User.notified_on_all(journal.data.project)
else
UserMailer.work_package_updated(recipient, journal, sender)
# No notification on updating a news
User.none
end
end
private
def journal
@journal ||= Journal.find_by(id: @journal_id)
def self.project(journal)
journal.data.project
end
def work_package
@work_package ||= journal.journable
def self.user(journal)
journal.user
end
end

@ -0,0 +1,74 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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::CreateFromModelService::WikiContentStrategy
def self.reasons
%i(watched subscribed)
end
def self.permission
:view_wiki_pages
end
def self.supports_ian?
false
end
def self.supports_mail_digest?
false
end
def self.supports_mail?
true
end
def self.subscribed_users(journal)
User.notified_on_all(journal.data.project)
end
def self.watcher_users(journal)
page = journal.journable.page
if journal.initial?
User.watcher_recipients(page.wiki)
else
User.watcher_recipients(page.wiki)
.or(User.watcher_recipients(page))
end
end
def self.project(journal)
journal.data.project
end
def self.user(journal)
journal.user
end
end

@ -0,0 +1,67 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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::CreateFromModelService::WorkPackageStrategy
def self.reasons
%i(mentioned involved watched subscribed commented created processed prioritized scheduled)
end
def self.permission
:view_work_packages
end
def self.supports_ian?
true
end
def self.supports_mail_digest?
true
end
def self.supports_mail?
true
end
def self.subscribed_users(journal)
User.notified_on_all(journal.data.project)
end
def self.watcher_users(journal)
User.watcher_recipients(journal.journable)
end
def self.project(journal)
journal.data.project
end
def self.user(journal)
journal.user
end
end

@ -29,45 +29,4 @@
#++
class Notifications::CreateService < ::BaseServices::Create
protected
def after_perform(call)
super.tap do |super_call|
schedule_mail_jobs(super_call.result) if super_call.success?
end
end
def schedule_mail_jobs(notification)
# Explicitly checking for false here as the nil value means that no
# notification should be sent at all.
schedule_direct_mail_job(notification) if notification.read_mail == false
schedule_digest_mail_job(notification) if notification.read_mail_digest == false
end
def schedule_direct_mail_job(notification)
Mails::NotificationJob
.set(wait: Setting.notification_email_delay_minutes.minutes)
.perform_later(notification)
end
def schedule_digest_mail_job(notification)
# This alone is vulnerable to the edge case of the Mails::DigestJob
# having started but not completed when a new digest notification is generated.
# To cope with it, the Mails::DigestJob as its first action sets all digest notifications
# to being handled even though they are still processed.
# See the DigestJob for more details.
return if digest_job_already_scheduled?(notification)
Mails::DigestJob
.set(wait_until: Mails::DigestJob.execution_time(notification.recipient))
.perform_later(notification.recipient)
end
def digest_job_already_scheduled?(notification)
Notification
.mail_digest_before(recipient: notification.recipient,
time: notification.created_at)
.where.not(id: notification.id)
.exists?
end
end

@ -1,120 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
class Notifications::JournalWikiMailService
class << self
def call(journal, send_mails)
new(journal)
.call(send_mails)
end
end
attr_reader :journal
def initialize(journal)
@journal = journal
end
def call(send_mails)
return unless send_mail?(send_mails)
if journal.initial?
send_content_added_mail
else
send_content_updated_mail
end
end
private
def send_mail?(send_mails)
send_mails && ::UserMailer.perform_deliveries && !journal.noop?
end
def send_content_added_mail
send_content(create_recipients, :wiki_content_added)
end
def send_content_updated_mail
send_content(update_recipients, :wiki_content_updated)
end
def notification_disabled?(name)
!Setting.notified_events.include?(name)
end
# Returns the mail addresses of users that should be notified
def recipients
project
.notified_users
.select { |user| wiki_content.visible?(user) }
end
def send_content(recipients, method)
return if notification_disabled?(method.to_s)
recipients.uniq.each do |user|
UserMailer
.send(method, user, wiki_content, journal_user)
.deliver_later
end
end
def create_recipients
recipients +
wiki.watcher_recipients
end
def update_recipients
recipients +
wiki.watcher_recipients +
page.watcher_recipients
end
def wiki_content
journal.journable
end
def page
wiki_content.page
end
def wiki
page.wiki
end
def project
wiki.project
end
def journal_user
journal.user || DeletedUser.first
end
end

@ -1,262 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
class Notifications::JournalWpNotificationService
MENTION_USER_ID_PATTERN =
'<mention[^>]*(?:data-type="user"[^>]*data-id="(\d+)")|(?:data-id="(\d+)"[^>]*data-type="user")[^>]*>)|(?:\buser#(\d+)\b'
.freeze
MENTION_USER_LOGIN_PATTERN =
'\buser:"(.+?)"'.freeze
MENTION_GROUP_ID_PATTERN =
'<mention[^>]*(?:data-type="group"[^>]*data-id="(\d+)")|(?:data-id="(\d+)"[^>]*data-type="group")[^>]*>)|(?:\bgroup#(\d+)\b'
.freeze
MENTION_PATTERN = Regexp.new("(?:#{MENTION_USER_ID_PATTERN})|(?:#{MENTION_USER_LOGIN_PATTERN})|(?:#{MENTION_GROUP_ID_PATTERN})")
class << self
def call(journal, send_notifications)
return nil if abort_sending?(journal, send_notifications)
notification_receivers(journal).each do |recipient_id, channel_reasons|
create_notification(journal,
recipient_id,
channel_reasons)
end
end
private
def create_notification(journal, recipient_id, channel_reasons)
notification_attributes = {
recipient_id: recipient_id,
project: journal.project,
resource: journal.journable,
journal: journal,
actor: user_with_fallback(journal)
}.merge(channel_attributes(channel_reasons))
Notifications::CreateService
.new(user: user_with_fallback(journal))
.call(notification_attributes)
end
def channel_attributes(channel_reasons)
{
read_mail: channel_reasons.keys.include?('mail') ? false : nil,
read_mail_digest: channel_reasons.keys.include?('mail_digest') ? false : nil,
read_ian: channel_reasons.keys.include?('in_app') ? false : nil,
reason_ian: channel_reasons['in_app']&.first,
reason_mail: channel_reasons['mail']&.first,
reason_mail_digest: channel_reasons['mail_digest']&.first
}
end
def notification_receivers(journal)
receivers = receivers_hash
%i(mentioned involved watched subscribed commented created processed prioritized scheduled).each do |reason|
add_receivers_by_reason(receivers, journal, reason)
end
remove_self_recipient(receivers, journal)
receivers
end
def add_receivers_by_reason(receivers, journal, reason)
add_receiver(receivers, send(:"settings_of_#{reason}", journal), reason)
end
def settings_of_mentioned(journal)
applicable_settings(mentioned_ids(journal),
journal.data.project,
:mentioned)
end
def settings_of_involved(journal)
scope = User
.where(id: group_or_user_ids(journal.data.assigned_to))
.or(User.where(id: group_or_user_ids(journal.data.responsible)))
applicable_settings(scope,
journal.data.project,
:involved)
end
def settings_of_subscribed(journal)
project = journal.data.project
applicable_settings(User.notified_on_all(project),
project,
:all)
end
def settings_of_watched(journal)
work_package = journal.journable
applicable_settings(work_package.watcher_users,
work_package.project,
:watched)
end
def settings_of_commented(journal)
return NotificationSetting.none unless journal.notes?
applicable_settings(User.all,
journal.data.project,
:work_package_commented)
end
def settings_of_created(journal)
return NotificationSetting.none unless journal.initial?
applicable_settings(User.all,
journal.data.project,
:work_package_created)
end
def settings_of_processed(journal)
return NotificationSetting.none unless !journal.initial? && journal.details.has_key?(:status_id)
applicable_settings(User.all,
journal.data.project,
:work_package_processed)
end
def settings_of_prioritized(journal)
return NotificationSetting.none unless !journal.initial? && journal.details.has_key?(:priority_id)
applicable_settings(User.all,
journal.data.project,
:work_package_prioritized)
end
def settings_of_scheduled(journal)
if journal.initial? || !(journal.details.has_key?(:start_date) || journal.details.has_key?(:due_date))
return NotificationSetting.none
end
applicable_settings(User.all,
journal.data.project,
:work_package_scheduled)
end
def applicable_settings(user_scope, project, reason)
NotificationSetting
.applicable(project)
.where(reason => true)
.where(user: user_scope.where(id: User.allowed(:view_work_packages, project)))
end
def text_for_mentions(journal)
potential_text = ""
potential_text << journal.notes if journal.try(:notes)
%i[description subject].each do |field|
details = journal.details[field]
if details.present?
potential_text << "\n#{Redmine::Helpers::Diff.new(*details.reverse).additions.join(' ')}"
end
end
potential_text
end
def mentioned_ids(journal)
matches = mention_matches(journal)
base_scope = User
.includes(:groups)
.references(:groups_users)
by_id = base_scope.where(id: matches[:user_ids])
by_login = base_scope.where(login: matches[:user_login_names])
by_group = base_scope.where(groups_users: { id: matches[:group_ids] })
by_id
.or(by_login)
.or(by_group)
end
def send_notification?(journal, send_notifications)
send_notifications && ::UserMailer.perform_deliveries
end
def mention_matches(journal)
text = text_for_mentions(journal)
user_ids_tag_after,
user_ids_tag_before,
user_ids_hash,
user_login_names,
group_ids_tag_after,
group_ids_tag_before,
group_ids_hash = text
.scan(MENTION_PATTERN)
.transpose
.each(&:compact!)
{
user_ids: [user_ids_tag_after, user_ids_tag_before, user_ids_hash].flatten.compact,
user_login_names: [user_login_names].flatten.compact,
group_ids: [group_ids_tag_after, group_ids_tag_before, group_ids_hash].flatten.compact
}
end
def abort_sending?(journal, send_notifications)
!send_notification?(journal, send_notifications) || journal.noop?
end
def group_or_user_ids(association)
association.is_a?(Group) ? association.user_ids : association&.id
end
def user_with_fallback(journal)
journal.user || DeletedUser.first
end
def add_receiver(receivers, collection, reason)
collection.each do |notification|
receivers[notification.user_id][notification.channel] << reason
end
end
def remove_self_recipient(receivers, journal)
receivers.delete(journal.user_id) if receivers[journal.user_id] && !user_with_fallback(journal).pref.self_notified?
end
def receivers_hash
Hash.new do |hash, user|
hash[user] = Hash.new do |channel_hash, channel|
channel_hash[channel] = []
end
end
end
end
end

@ -0,0 +1,77 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
class Notifications::MailService
def initialize(notification)
self.notification = notification
end
def call
ensure_supported
return if ian_read?
strategy.send_mail(notification)
end
private
attr_accessor :notification
def ensure_supported
unless supported?
raise ArgumentError, "Sending mails for notifications is not supported for #{strategy_model}"
end
end
def ian_read?
notification.read_ian
end
def strategy
@strategy ||= if self.class.const_defined?("#{strategy_model}Strategy")
"#{self.class}::#{strategy_model}Strategy".constantize
end
end
def strategy_model
journal&.journable_type || resource&.class
end
def journal
notification.journal
end
def resource
notification.resource
end
def supported?
strategy.present?
end
end

@ -1,5 +1,3 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
@ -28,26 +26,24 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Notifications::JournalNotificationService
module Notifications::MailService::CommentStrategy
class << self
def call(journal, send_mails)
enqueue_notification(journal, send_mails) if supported?(journal)
end
private
def send_mail(notification)
return if notification_disabled?
def enqueue_notification(journal, send_mails)
Notifications::JournalCompletedJob
.set(wait_until: delivery_time)
.perform_later(journal.id, send_mails)
UserMailer
.news_comment_added(
notification.recipient,
notification.resource,
notification.resource.author || DeletedUser.first
)
.deliver_later
end
def delivery_time
Setting.journal_aggregation_time_minutes.to_i.minutes.from_now
end
private
def supported?(journal)
%w(WorkPackage WikiContent).include?(journal.journable_type)
def notification_disabled?
Setting.notified_events.exclude?('news_comment_added')
end
end
end

@ -0,0 +1,49 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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::MailService::MessageStrategy
class << self
def send_mail(notification)
return if notification_disabled?
UserMailer
.message_posted(
notification.recipient,
notification.resource,
notification.actor || DeletedUser.first
)
.deliver_later
end
private
def notification_disabled?
Setting.notified_events.exclude?('message_posted')
end
end
end

@ -0,0 +1,49 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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::MailService::NewsStrategy
class << self
def send_mail(notification)
return if notification_disabled? || !notification.journal.initial?
UserMailer
.news_added(
notification.recipient,
notification.journal.journable,
notification.journal.user || DeletedUser.first
)
.deliver_later
end
private
def notification_disabled?
Setting.notified_events.exclude?('news_added')
end
end
end

@ -0,0 +1,58 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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::MailService::WikiContentStrategy
class << self
def send_mail(notification)
method = mailer_method(notification)
return if notification_disabled?(method.to_s)
UserMailer
.send(method,
notification.recipient,
notification.journal.journable,
notification.journal.user || DeletedUser.first)
.deliver_later
end
private
def mailer_method(notification)
if notification.journal.initial?
:wiki_content_added
else
:wiki_content_updated
end
end
def notification_disabled?(name)
Setting.notified_events.exclude?(name)
end
end
end

@ -0,0 +1,48 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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::MailService::WorkPackageStrategy
class << self
def send_mail(notification)
journal = notification.journal
UserMailer
.send(mailer_method(notification),
notification.recipient,
journal,
notification.journal.user || DeletedUser.first)
.deliver_later
end
private
def mailer_method(notification)
notification.journal.initial? ? :work_package_added : :work_package_updated
end
end
end

@ -56,6 +56,7 @@ class WorkPackages::CopyService
copied = create(attributes, send_notifications)
if copied.success?
remove_author_watcher(copied.result)
copy_watchers(copied.result)
end
@ -84,6 +85,10 @@ class WorkPackages::CopyService
instantiate_contract(wp, user).writable_attributes
end
def remove_author_watcher(copied)
copied.remove_watcher(copied.author)
end
def copy_watchers(copied)
work_package.watcher_users.each do |user|
copied.add_watcher(user) if user.active?

@ -8,7 +8,7 @@
<% notifications_by_work_package.each do |work_package, notifications| %>
<section style="margin-bottom: 3em;">
<h2 style="margin-bottom: 1em; font-size: 1.5em;"><%= link_to_work_package work_package, status: true, only_path: false %></h2>
<h2 style="margin-bottom: 1em; font-size: 1.5em;"><%= link_to_work_package work_package, status: true, only_path: false, no_hidden: true %></h2>
<% notifications.sort_by(&:created_at).each do |notification| %>

@ -0,0 +1,81 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
# A StateMachineJob is a job that consists of multiple steps to complete where a step needs
# to be finished before the next step is to be taken. Between each step, an amount of time may have to pass.
# A job including this concern can define step-blocks that will be executed one after another.
# A step receives the return value of the previous step-block as input. In case a waiting time is specified
# the job will reschedule itself.
module StateMachineJob
extend ActiveSupport::Concern
included do
def perform(state, *args)
results = instance_exec(*args, &states[state][:block])
to = states[state][:to]
switch_state(to, *results) if to
end
class_attribute :states,
instance_writer: false,
default: {}
# Defines a new step, that can then be executed either by starting at this step
# (by providing the step name as the first parameter of a call to #perform), or as a step
# in a chain of steps.
# @param name<Symbol> The name of the step that serves as an identifier to subsequent calls.
# @param to<Symbol, NilClass> The name of the step triggered after this step. If the value is *nil*, no subsequent
# step will be triggered.
# @param wait<Lambda> The result of the lambda dictates the amount of time the job waits before the next step is
# executed
# @param block<Proc> The code to execute as part of the step. The block will be executed in the context of the
# job instance, not the class.
def self.state(name, to: nil, wait: nil, &block)
states[name] = { to: to, wait: wait, block: block }
end
private
def switch_state(to, *results)
wait = states[to][:wait]
if wait
self
.class
.set(wait: wait.call)
.perform_later(to, *results)
else
perform(to, *results)
end
end
end
end

@ -28,15 +28,47 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Notifications::JournalCompletedJob < ApplicationJob
class Journals::CompletedJob < ApplicationJob
queue_with_priority :notification
class << self
def schedule(journal, send_mails)
return unless supported?(journal)
set(wait_until: delivery_time)
.perform_later(journal.id, send_mails)
end
def aggregated_event(journal)
case journal.journable_type
when WikiContent.name
OpenProject::Events::AGGREGATED_WIKI_JOURNAL_READY
when WorkPackage.name
OpenProject::Events::AGGREGATED_WORK_PACKAGE_JOURNAL_READY
when News.name
OpenProject::Events::AGGREGATED_NEWS_JOURNAL_READY
when Message.name
OpenProject::Events::AGGREGATED_MESSAGE_JOURNAL_READY
end
end
private
def delivery_time
Setting.journal_aggregation_time_minutes.to_i.minutes.from_now
end
def supported?(journal)
aggregated_event(journal).present?
end
end
def perform(journal_id, send_mails)
journal = Journal.find_by(id: journal_id)
# If the WP has been deleted the journal will have been deleted, too.
# Or the journal might have been replaced
return unless journal
return if journal.nil?
notify_journal_complete(journal, send_mails)
end
@ -44,19 +76,8 @@ class Notifications::JournalCompletedJob < ApplicationJob
private
def notify_journal_complete(journal, send_mails)
OpenProject::Notifications.send(notification_event_type(journal),
OpenProject::Notifications.send(self.class.aggregated_event(journal),
journal: journal,
send_mail: send_mails)
end
def notification_event_type(journal)
case journal.journable_type
when WikiContent.name
OpenProject::Events::AGGREGATED_WIKI_JOURNAL_READY
when WorkPackage.name
OpenProject::Events::AGGREGATED_WORK_PACKAGE_JOURNAL_READY
else
raise 'Unsupported journal created event type'
end
end
end

@ -30,11 +30,32 @@
class Mails::DigestJob < Mails::DeliverJob
class << self
def schedule(notification)
# This alone is vulnerable to the edge case of the Mails::DigestJob
# having started but not completed when a new digest notification is generated.
# To cope with it, the Mails::DigestJob as its first action sets all digest notifications
# to being handled even though they are still processed.
return if digest_job_already_scheduled?(notification)
set(wait_until: execution_time(notification.recipient))
.perform_later(notification.recipient)
end
private
def execution_time(user)
zone = (user.time_zone || ActiveSupport::TimeZone.new('UTC'))
zone.parse(Setting.notification_email_digest_time) + 1.day
end
def digest_job_already_scheduled?(notification)
Notification
.mail_digest_before(recipient: notification.recipient,
time: notification.created_at)
.where.not(id: notification.id)
.exists?
end
end
private

@ -54,7 +54,7 @@ class Mails::WatcherJob < Mails::DeliverJob
end
def notify_about_watcher_changed?
return false if notify_about_self_watching?
return false if self_watching?
return false unless UserMailer.perform_deliveries
settings = watcher
@ -67,8 +67,8 @@ class Mails::WatcherJob < Mails::DeliverJob
settings.watched || settings.all
end
def notify_about_self_watching?
watcher.user == sender && !sender.pref.self_notified?
def self_watching?
watcher.user == sender
end
def action

@ -0,0 +1,85 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
# Governs the workflow of how journals are passed through:
# 1) The notifications for any event (e.g. journal creation) is to be created as fast as possible
# so that it becomes visible as an in app notification. If the resource passed in is indeed a journal,
# it might get replaced later on (by a subsequent journal). This will lead to notifications being removed.
# 2) After the journal aggregation time has passed as well as the desired delay, the direct email is sent out.
# 3) At the same time (TODO: but it could already have been triggered after the aggregation time has passed)
# the digest is scheduled.
# This order has to be kept to ensure that the notifications are created before email sending is attempted. If it weren't
# guaranteed, with the notifications created in one job and the mails send in another, the mail sending job might get executed
# without any notifications being created which would result in no emails being sent at all. An alternative would be to
# decouple notification creation and mail sending from another. But then, in app notifications being read could not prevent
# mails being sent out.
class Notifications::WorkflowJob < ApplicationJob
include ::StateMachineJob
queue_with_priority :notification
# In case a resource (e.g. journal) cannot be deserialized (which means fetching it from the db)
# the resource has been removed which might happen. In that case, no notifications
# need to be sent out any more.
discard_on ActiveJob::DeserializationError
state :create_notifications,
to: :send_mails do |resource, send_notification|
Notifications::CreateFromModelService
.new(resource)
.call(send_notification)
.all_results
.map(&:id)
end
state :send_mails,
wait: -> {
Setting.notification_email_delay_minutes.minutes + Setting.journal_aggregation_time_minutes.to_i.minutes
} do |*notification_ids|
next unless notification_ids
Notification
.where(id: notification_ids)
.unread_mail
.each do |notification|
Notifications::MailService
.new(notification)
.call
end
Notification
.where(id: notification_ids)
.unread_mail_digest
.each do |notification|
Mails::DigestJob
.schedule(notification)
end
end
end

@ -29,18 +29,17 @@
#++
OpenProject::Notifications.subscribe(OpenProject::Events::JOURNAL_CREATED) do |payload|
Notifications::JournalNotificationService.call(payload[:journal], payload[:send_notification])
end
# The aggregated journal ready listeners in effect are run inside a delayed job
# since they are called by (in effect) by a background job triggered within the
# Notifications::JournalNotificationService
OpenProject::Notifications.subscribe(OpenProject::Events::AGGREGATED_WORK_PACKAGE_JOURNAL_READY) do |payload|
Notifications::JournalWpNotificationService.call(payload[:journal], payload[:send_mail])
end
# A job is scheduled that creates notifications (in app if supported) right away and schedules
# jobs to be run for mail and digest mails.
Notifications::WorkflowJob
.perform_later(:create_notifications,
payload[:journal],
payload[:send_notification])
OpenProject::Notifications.subscribe(OpenProject::Events::AGGREGATED_WIKI_JOURNAL_READY) do |payload|
Notifications::JournalWikiMailService.call(payload[:journal], payload[:send_mail])
# A job is scheduled for the end of the journal aggregation time. If the journal does still exist
# at the end (it might be replaced because another journal was created within that timeframe)
# that job generates a OpenProject::Events::AGGREGATED_..._JOURNAL_READY event.
Journals::CompletedJob.schedule(payload[:journal], payload[:send_notification])
end
OpenProject::Notifications.subscribe(OpenProject::Events::WATCHER_ADDED) do |payload|
@ -70,3 +69,10 @@ OpenProject::Notifications.subscribe(OpenProject::Events::MEMBER_UPDATED) do |pa
member: payload[:member],
message: payload[:message])
end
OpenProject::Notifications.subscribe(OpenProject::Events::NEWS_COMMENT_CREATED) do |payload|
Notifications::WorkflowJob
.perform_later(:create_notifications,
payload[:comment],
payload[:send_notification])
end

@ -170,6 +170,8 @@ ar:
enabled_in_project: 'Enabled in project'
contained_in_type: 'Contained in type'
confirm_destroy_option: "Deleting an option will delete all of its occurrences (e.g. in work packages). Are you sure you want to delete it?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: لا يوجد حالياً ملفات للزبائن.
no_results_content_text: إنشاء ملف زبون جديد

@ -170,6 +170,8 @@ bg:
enabled_in_project: 'Enabled in project'
contained_in_type: 'Contained in type'
confirm_destroy_option: "Deleting an option will delete all of its occurrences (e.g. in work packages). Are you sure you want to delete it?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: В момента няма потребителски полета.
no_results_content_text: Създаване на ново персонализирано поле

@ -170,6 +170,8 @@ ca:
enabled_in_project: 'Enabled in project'
contained_in_type: 'Contained in type'
confirm_destroy_option: "Deleting an option will delete all of its occurrences (e.g. in work packages). Are you sure you want to delete it?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Actualment no hi ha camps personalitzats.
no_results_content_text: Crear un camp personalitzat nou

@ -170,6 +170,8 @@ cs:
enabled_in_project: 'Povoleno v projektu'
contained_in_type: 'Obsahuje typ'
confirm_destroy_option: "Smazáním možnosti smažete všechny výskyty (např. v pracovních balíčcích). Opravdu ji chcete odstranit?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: V současné době nejsou žádná vlastní pole.
no_results_content_text: Vytvořit nové vlastní pole

@ -168,6 +168,8 @@ da:
enabled_in_project: 'Aktiveret i projektet'
contained_in_type: 'Indeholdt i type'
confirm_destroy_option: "Sletning af en indstilling vil slette alle dens forekomster (f.eks. i arbejdspakker). Er du sikker på, at du vil slette den?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Der er i øjeblikket ingen brugerdefinerede felter.
no_results_content_text: Oprette en ny brugerdefineret felt

@ -167,6 +167,8 @@ de:
enabled_in_project: 'Im Projekt aktiviert'
contained_in_type: 'In Typ enthalten'
confirm_destroy_option: "Löschen eines Werts entfernt alle bereits gesetzten Werte (z.B. bei Arbeitspaketen). Sind Sie sicher, dass Sie den Wert löschen möchten?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Zur Zeit existieren keine benutzerdefinierten Felder.
no_results_content_text: Neues benutzerdefiniertes Feld anlegen

@ -166,6 +166,8 @@ el:
enabled_in_project: 'Ενεργοποιημένο στο έργο'
contained_in_type: 'Περιέχονται στον τύπο'
confirm_destroy_option: "Η διαγραφή μιας επιλογής θα διαγράψει όλα τα περιστατικά της (π.χ. σε πακέτα εργασίας). Είστε βέβαιοι ότι θέλετε να το διαγράψετε;"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Προς το παρόν δεν υπάρχουν προσαρμοσμένα πεδία.
no_results_content_text: Δημιουργία νέου προσαρμοσμένου πεδίου

@ -167,6 +167,8 @@ es:
enabled_in_project: 'Activado en el proyecto'
contained_in_type: 'Incluido en el tipo'
confirm_destroy_option: "Al eliminar una opción, se eliminarán todas las repeticiones (por ejemplo, en paquetes de trabajo). ¿Está seguro de que desea eliminarla?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Actualmente no hay campos personalizados.
no_results_content_text: Crear un nuevo campo personalizado

@ -170,6 +170,8 @@ fi:
enabled_in_project: 'Käytössä projektissa'
contained_in_type: 'Sisältyy tyyppiin'
confirm_destroy_option: "Deleting an option will delete all of its occurrences (e.g. in work packages). Are you sure you want to delete it?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Tällä hetkellä ei ole mukautettuja kenttiä.
no_results_content_text: Luo uusi mukautettu kenttä

@ -170,6 +170,8 @@ fil:
enabled_in_project: 'Pinagana sa proyekto'
contained_in_type: 'Naglaman ng uri'
confirm_destroy_option: "Kapag nagbubura ng opsyon ay makakabura sa lahat ng mga pangyayari nito (e.g. sa mga package na trabaho). Sigurado ka bang gusto mong burahin ito?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Sa kasalukuyan ay walang mga custom field.
no_results_content_text: Gumawa ng bagong patlang na custom

@ -170,6 +170,8 @@ fr:
enabled_in_project: 'Activé dans le projet'
contained_in_type: 'Figurant dans le type'
confirm_destroy_option: "Supprimer une option supprimera toutes ses occurrences (ex. dans les plans de travail). Êtes-vous sûr de vouloir le supprimer ?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Il n'y a actuellement aucun champ personnalisé.
no_results_content_text: Créer un nouveau champ personnalisé

@ -170,6 +170,8 @@ hr:
enabled_in_project: 'Enabled in project'
contained_in_type: 'Contained in type'
confirm_destroy_option: "Deleting an option will delete all of its occurrences (e.g. in work packages). Are you sure you want to delete it?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Trenutno nije dostupno niti jedno prilagođeno polje.
no_results_content_text: Novo prilagođeno polje

@ -169,6 +169,8 @@ hu:
enabled_in_project: 'Engedélyezve a projektben'
contained_in_type: 'A típus tartalmazza'
confirm_destroy_option: "Egy opció törlése az összes eddigi használatánál (úgy, mint munkacsomagok) is törlődnek. Biztosan törölni szeretné?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Jelenleg nincsenek egyéni mezők.
no_results_content_text: Új egyéni mező létrehozása
@ -250,7 +252,7 @@ hu:
You are not allowed to delete the placeholder user. You do not have the right to manage members for all projects that the placeholder user is a member of.
delete_tooltip: "Delete placeholder user"
deletion_info:
heading: "Delete placeholder user %{name}"
heading: "Teszt felhasználó%{name} törlése"
data_consequences: >
All occurrences of the placeholder user (e.g., as assignee, responsible or other user values) will be reassigned to an account called "Deleted user".
As the data of every deleted account is reassigned to this account it will not be possible to distinguish the data the user created from the data of another deleted account.
@ -516,7 +518,7 @@ hu:
watcher: "Megfigyelő"
'doorkeeper/application':
uid: "Ügyfél azonosító"
secret: "Client secret"
secret: "Kliens titok"
owner: "Tulajdonos"
redirect_uri: "Átirányítás URI"
client_credentials_user_id: "Client Credentials User ID"
@ -774,7 +776,7 @@ hu:
notification:
one: "Értesítés"
other: "Értesítések"
placeholder_user: "Placeholder user"
placeholder_user: "Teszt felhasználó"
project: "Projekt"
query: "Egyéni lekérdezés"
role:
@ -858,7 +860,7 @@ hu:
label_create_token: "Visszaállítási kulcs létrehozása"
label_delete_token: "Visszaállítási kulcs törlése"
label_reset_token: "Visszaállítási kulcs törlése"
label_token_users: "The following users have active backup tokens"
label_token_users: "A következő felhasználóknak van aktív visszaállítási kulcsuk"
reset_token:
action_create: Létrehoz
action_reset: Visszaállít, reset
@ -879,7 +881,7 @@ hu:
invalid_token: Érvénytelen vagy hiányzó visszaállítási kulcs
token_cooldown: A visszaállítási kulcs még %{hours} óráig érvényes.
backup_pending: Egy visszaállítás még függőben van.
limit_reached: You can only do %{limit} backups per day.
limit_reached: Csak a megadott %{limit} visszaállítást tudsz készíteni naponta
button_add: "Hozzáadás"
button_add_comment: "Megjegyzés hozzáadása"
button_add_member: Tag hozzáadása
@ -1299,7 +1301,7 @@ hu:
mail_notification: "Email értesítés"
incoming_outgoing: "Bejövő/Kimenő"
quick_add:
label: "Open quick add menu"
label: "Gyorsmenü megnyitása"
my_account:
access_tokens:
no_results:
@ -1646,9 +1648,9 @@ hu:
label_permissions: "Jogosultságok"
label_permissions_report: "Jogosultságok riportálása"
label_personalize_page: "A lap testreszabása"
label_placeholder_user: "Placeholder user"
label_placeholder_user_new: "New placeholder user"
label_placeholder_user_plural: "Placeholder users"
label_placeholder_user: "Teszt felhasználó"
label_placeholder_user_new: "Új teszt felhasználó"
label_placeholder_user_plural: "Teszt felhasználók"
label_planning: "Tervezés"
label_please_login: "Kérjük, jelentkezzen be"
label_plugins: "Modulok"
@ -1938,7 +1940,7 @@ hu:
added_by:
without_message: "%{user} hozzáadott tagként a projekthez %{project}"
with_message: "%{user} added you as a member to the project '%{project}' writing:"
roles: "You have the following roles:"
roles: "A következő jogosultságaid vannak :"
mail_member_updated_project:
subject: "%{project} A szerepköreid frissítve lettek"
body:
@ -2058,7 +2060,7 @@ hu:
permission_add_messages: "Elküldött üzenetek"
permission_add_project: "Projekt létrehozása"
permission_manage_user: "Felhasználó létrehozása és szerkesztése"
permission_manage_placeholder_user: "Create, edit, and delete placeholder users"
permission_manage_placeholder_user: "Teszt felhasználók létrehozása, módosítása, törlése"
permission_add_subprojects: "Alprojektek létrehozása"
permission_add_work_package_watchers: "Megfigyelő hozzáadása"
permission_assign_versions: "Hozzárendelt verziók."

@ -170,6 +170,8 @@ id:
enabled_in_project: 'Diaktifkan pada proyek'
contained_in_type: 'Terkandung dalam jenis'
confirm_destroy_option: "Menghapus sebuah pilihan akan menghapus semua kemunculan yang terjadi (mis. dalam paket pekerjaan). Apakah Anda yakin ingin menghapusnya?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Tidak ada bidang kustom saat ini.
no_results_content_text: Buat bidang kustom baru

@ -167,6 +167,8 @@ it:
enabled_in_project: 'Abilitato nel progetto'
contained_in_type: 'Contenuto nel tipo'
confirm_destroy_option: "L'eliminazione di un'opzione eliminerà tutte le sue occorrenze (ad es. nelle macro-attività). Procedere all'eliminazione?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Al momento non esistono campi personalizzati.
no_results_content_text: Crea un nuovo campo personalizzato

@ -168,6 +168,8 @@ ja:
enabled_in_project: 'プロジェクトで有効'
contained_in_type: 'タイプに含まれる'
confirm_destroy_option: "オプションを削除すると、そのすべての派生 (例えばワークパッケージ) が削除されます。削除してもよろしいですか?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: 現在、カスタム フィールドはありません。
no_results_content_text: 新しいカスタム フィールドを作成

@ -522,6 +522,7 @@ ar:
all: 'الجميع'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ ar:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "أريد إعلامك بالتغييرات التي فعلتها"
password_confirmation:
field_description: 'تحتاج إلى إدخال كلمة مرور حسابك لتأكيد هذا التغيير.'
title: 'أكِّد كلمة مرورك للمواصلة'

@ -522,6 +522,7 @@ bg:
all: 'All'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ bg:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Искам да бъдете уведомявани за промени, които аз правя"
password_confirmation:
field_description: 'You need to enter your account password to confirm this change.'
title: 'Confirm your password to continue'

@ -522,6 +522,7 @@ ca:
all: 'Tot'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ ca:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Vull ser notificat de canvis que faig jo mateix"
password_confirmation:
field_description: 'Necessita introduir la contrasenya del seu compte per a confirmar aquest canvi.'
title: 'Confirmi la seva contrasenya per continuar'

@ -522,6 +522,7 @@ cs:
all: 'Vše'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ cs:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Chci být informován o změnách, které dělám sám"
password_confirmation:
field_description: 'Pro potvrzení této změny je třeba zadat heslo k účtu.'
title: 'Pro pokračování potvrďte vaše heslo'

@ -521,6 +521,7 @@ da:
all: 'All'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -537,7 +538,6 @@ da:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "I want to be notified of changes that I make myself"
password_confirmation:
field_description: 'You need to enter your account password to confirm this change.'
title: 'Confirm your password to continue'

@ -521,6 +521,7 @@ de:
all: 'Alle'
center:
mark_all_read: 'Alles als gelesen markieren'
text_update_date: "%{date} by"
total_count_warning: "Zeige die %{newest_count} neuesten Benachrichtigungen. %{more_count} weitere werden nicht angezeigt."
settings:
default_all_projects: 'Standard für alle Projekte'
@ -537,7 +538,6 @@ de:
already_selected: 'Dieses Projekt ist bereits ausgewählt'
remove_projects: 'Lösche Einstellungen für alle Projekte'
title: "Benachrichtigungseinstellungen"
self_notify: "Ich möchte über Änderungen benachrichtigt werden, die ich selbst vornehme"
password_confirmation:
field_description: 'Sie müssen Ihr Kennwort eingeben um diese Änderungen zu speichern.'
title: 'Geben Sie Ihr Kennwort ein um fortzufahren'

@ -521,6 +521,7 @@ el:
all: 'Όλα'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -537,7 +538,6 @@ el:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Θέλω να ειδοποιούμαι για τις δικές μου αλλαγές"
password_confirmation:
field_description: 'Πρέπει να εισάγετε τον κωδικό πρόσβασης του λογαριασμού σας για να επιβεβαιώσετε αυτή την αλλαγή.'
title: 'Επιβεβαιώστε τον κωδικό πρόσβασης σας για να συνεχίσετε'

@ -522,6 +522,7 @@ es:
all: 'Todo'
center:
mark_all_read: 'Marcar todos como leído'
text_update_date: "%{date} by"
total_count_warning: "Mostrando las %{newest_count} notificaciones más recientes. %{more_count} más no se muestran."
settings:
default_all_projects: 'Por defecto para todos los proyectos'
@ -538,7 +539,6 @@ es:
already_selected: 'This project is already selected'
remove_projects: 'Eliminar notificaciones de proyectos'
title: "Ajustes de notificación"
self_notify: "Deseo ser notificado de los cambios realizados por mí"
password_confirmation:
field_description: 'Necesitas introducir la contraseña de tu cuenta para confirmar este cambio.'
title: 'Confirma tu contraseña para continuar'

@ -522,6 +522,7 @@ fi:
all: 'Kaikki'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ fi:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Ilmoitukset omista muutoksista"
password_confirmation:
field_description: 'Sinun täytyy syöttää tilisi salasana vahvistaaksesi tämän muutoksen.'
title: 'Vahvista salasanasi jatkaaksesi'

@ -522,6 +522,7 @@ fil:
all: 'All'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ fil:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Gusto kong mapa-alam sa mga pagbabago na ginagawa ko sa aking sarili"
password_confirmation:
field_description: 'Kailangan mong ipasok ang iyong akwant password upang kumpirmahin ang pagbabago niito.'
title: 'Kumpirmahin ang iyong password upanh magpatuloy'

@ -522,6 +522,7 @@ fr:
all: 'Toutes'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ fr:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Je veux être notifié des modifications que je fais moi-même"
password_confirmation:
field_description: 'Vous devez saisir le mot de passe de votre compte pour confirmer ce changement.'
title: 'Confirmez votre mot de passe pour continuer'

@ -522,6 +522,7 @@ hr:
all: 'Svi'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ hr:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Želim biti obavješten o promjenama koje sam izvršio"
password_confirmation:
field_description: 'You need to enter your account password to confirm this change.'
title: 'Potvrdite lozinku za nastavak'

@ -40,7 +40,7 @@ hu:
note: >
A new backup will override any previous one. Only a limited number of backups per day can be requested.
last_backup: 'Utolsó biztonsági mentés:'
last_backup_from: Last backup from
last_backup_from: 'Legutóbbi biztonsági mentés: '
title: Backup OpenProject
options: Beállítások
include_attachments: Include attachments
@ -95,7 +95,7 @@ hu:
add_new: 'Új kártya hozzáadása'
highlighting:
inline: 'Highlight inline:'
entire_card_by: 'Entire card by'
entire_card_by: 'Egész sor alapján'
remove_from_list: 'Kártya eltávolítása a listából'
caption_rate_history: "Díj előzmények"
clipboard:
@ -521,6 +521,7 @@ hu:
all: 'Mind'
center:
mark_all_read: 'Mindegyik megjelölése olvasottként'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Alapértelmezett az összes projektnél'
@ -537,7 +538,6 @@ hu:
already_selected: 'A projekt már ki lett választva'
remove_projects: 'Értesítések kikapcsolása a projektekhez'
title: "Értesítési beállítások"
self_notify: "Értesítés küldése a saját magam által által végrehajtott módosításokról"
password_confirmation:
field_description: 'A változás megerősítéséhez adja meg jelszavát.'
title: 'Adja meg a jelszavát a folytatáshoz'
@ -1029,7 +1029,7 @@ hu:
invite_to_project: 'Invite %{type} to %{project}'
User: 'Felhasználó'
Group: 'csoport'
PlaceholderUser: 'placeholder user'
PlaceholderUser: 'Teszt felhasználó'
invite_principal_to_project: 'Invite %{principal} to %{project}'
project:
label: 'Projekt'
@ -1048,7 +1048,7 @@ hu:
title: 'Csoport'
description: 'Permissions based on the assigned role in the selected project'
placeholder:
title: 'Placeholder user'
title: 'Teszt felhasználó'
title_no_ee: 'Placeholder user (Enterprise Edition only feature)'
description: 'Has no access to the project and no emails are sent out.'
description_no_ee: 'Has no access to the project and no emails are sent out. <br>Check out the <a href="%{eeHref}" target="_blank">Enterprise Edition</a>'
@ -1059,8 +1059,8 @@ hu:
already_member_message: 'Already a member of %{project}'
no_results_user: 'Nem található felhasználó.'
invite_user: 'Meghívás:'
no_results_placeholder: 'No placeholders were found'
create_new_placeholder: 'Create new placeholder:'
no_results_placeholder: 'Ben találhatók teszt felhasználók'
create_new_placeholder: 'Új teszt felhasználó létrehozása'
no_results_group: 'Nem találhatók csoportok.'
next_button: ' Következő'
required:
@ -1088,7 +1088,7 @@ hu:
next_button: 'Folytatás'
forms:
submit_success_message: 'Jelentés sikeresen elküldve'
load_error_message: 'There was an error loading the form'
load_error_message: 'Hiba történt az eredmény betöltésekor.'
validation_error_message: 'Please fix the errors present in the form'
advanced_settings: 'Speciális beállítások'

@ -522,6 +522,7 @@ id:
all: 'Semua'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ id:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "I want to be notified of changes that I make myself"
password_confirmation:
field_description: 'Anda harus mengisi kata sandi akun untuk mengkonfirmasi perubahan.'
title: 'Konfirmasi kata sandi anda untuk melanjutkan'

@ -522,6 +522,7 @@ it:
all: 'Tutti'
center:
mark_all_read: 'Segna tutti come letti'
text_update_date: "%{date} by"
total_count_warning: "Mostrando le %{newest_count} notifiche più recenti. %{more_count} altre non vengono visualizzate."
settings:
default_all_projects: 'Predefinito per tutti i progetti'
@ -538,7 +539,6 @@ it:
already_selected: 'This project is already selected'
remove_projects: 'Rimuovi notifiche per i progetti'
title: "Impostazioni notifiche"
self_notify: "Voglio essere informato delle modifiche che faccio io stesso"
password_confirmation:
field_description: 'È necessario inserire la password dell''account per confermare la modifica.'
title: 'Confermare la password per continuare'

@ -523,6 +523,7 @@ ja:
all: '全て'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -539,7 +540,6 @@ ja:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "自分自身による変更の通知を希望"
password_confirmation:
field_description: 'この変更を確認するには、アカウントのパスワードを入力する必要があります。'
title: '続行するにはパスワードを確認してください'

@ -522,6 +522,7 @@ ko:
all: '모두'
center:
mark_all_read: '모두 읽은 상태로 표시'
text_update_date: "%{date} by"
total_count_warning: "%{newest_count} 개의 가장 최근 알림을 표시합니다. %{more_count} 개의 알림은 표시되지 않습니다."
settings:
default_all_projects: '모든 프로젝트 기본값'
@ -538,7 +539,6 @@ ko:
already_selected: 'This project is already selected'
remove_projects: '프로젝트에 알림 제거'
title: "알림 설정"
self_notify: "내가 만든 것이 변경되면 알림을 받고 싶습니다."
password_confirmation:
field_description: '이 변경 내용을 확인하려면 계정 암호를 입력해야 합니다.'
title: '계속하려면 암호 확인'

@ -522,6 +522,7 @@ lt:
all: 'Visi'
center:
mark_all_read: 'Pažymėti visus kaip perskaitytus'
text_update_date: "%{date} "
total_count_warning: "Rodomi %{newest_count} paskutiniai pranešimai. Dar %{more_count} yra nerodomi."
settings:
default_all_projects: 'Numatytasis visiems projektams'
@ -538,7 +539,6 @@ lt:
already_selected: 'Šis projektas jau pasirinktas'
remove_projects: 'Pašalinti pranešimus projektams'
title: "Pranešimų nustatymai"
self_notify: "Noriu būti informuotas apie pakeitimus, kuriuos pats atlieku"
password_confirmation:
field_description: 'Norėdami patvirtinti šį pakeitimą turite įvesti savo paskyros slaptažodį.'
title: 'Norėdami tęsti, patvirtinkite savo slaptažodį'

@ -522,6 +522,7 @@ nl:
all: 'Alle'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ nl:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Ik wil op de hoogte worden gesteld van wijzigingen die ik zelf maak"
password_confirmation:
field_description: 'U moet uw wachtwoord invoeren om deze wijziging te bevestigen.'
title: 'Voer uw wachtwoord in om door te gaan'

@ -522,6 +522,7 @@
all: 'All'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "I want to be notified of changes that I make myself"
password_confirmation:
field_description: 'You need to enter your account password to confirm this change.'
title: 'Confirm your password to continue'

@ -522,6 +522,7 @@ pl:
all: 'Wszystko'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ pl:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Chcę być powiadamiany o zmianach, które sam wprowadzam"
password_confirmation:
field_description: 'Musisz wprowadzić hasło do twojego konta aby potwierdzić te zmiany.'
title: 'Potwierdź hasło aby kontynuować'

@ -521,6 +521,7 @@ pt:
all: 'Todos'
center:
mark_all_read: 'Marcar tudo como lido'
text_update_date: "%{date} by"
total_count_warning: "Mostrando as %{newest_count} notificações mais recentes. Mas %{more_count} não são exibidas."
settings:
default_all_projects: 'Padrão para todos os projetos'
@ -537,7 +538,6 @@ pt:
already_selected: 'Este projeto já está selecionado'
remove_projects: 'Remover notificações para projetos'
title: "Configurações de Notificação"
self_notify: "Quero ser notificado das alterações que eu faço"
password_confirmation:
field_description: 'Você precisa digitar sua senha para confirmar essa alteração.'
title: 'Confirme sua senha para continuar'

@ -521,6 +521,7 @@ ro:
all: 'Toate'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -537,7 +538,6 @@ ro:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Vreau să fiu notificat pentru modificările pe care le fac eu"
password_confirmation:
field_description: 'Trebuie să introduceţi parola contului dumneavoastră pentru a confirma această schimbare.'
title: 'Vă rugăm să confirmați parola dumneavoastră pentru a continua'

@ -521,6 +521,7 @@ ru:
all: 'Bсе'
center:
mark_all_read: 'Отметить всё как прочитанное'
text_update_date: "%{date} от"
total_count_warning: "Показаны %{newest_count} самые последние уведомления. Ещё %{more_count} не отображаются."
settings:
default_all_projects: 'По умолчанию для всех проектов'
@ -537,7 +538,6 @@ ru:
already_selected: 'Этот проект уже выбран'
remove_projects: 'Удалить уведомления для проектов'
title: "Настройки уведомлений"
self_notify: "Хочу получать уведомления об изменениях, которые делаю сам"
password_confirmation:
field_description: 'Необходимо ввести свой пароль для подтверждения изменений.'
title: 'Для продолжения введите свой пароль'

@ -522,6 +522,7 @@ sk:
all: 'Všetky'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ sk:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Chcem byť upozornený na zmeny, ktoré som urobil sám"
password_confirmation:
field_description: 'Ak chcete potvrdiť túto zmenu, musíte zadať heslo svojho účtu.'
title: 'Ak chcete pokračovať, potvrďte svoje heslo'

@ -521,6 +521,7 @@ sl:
all: 'Vse'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -537,7 +538,6 @@ sl:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Želim biti opozorjen(a) za spremembe, ki jih naredim sam(a)"
password_confirmation:
field_description: 'Prosimo vnesite svoje uporabniško geslo za potrditev te spremembe. '
title: 'Potrdite svoje geslo za nadaljevanje'

@ -521,6 +521,7 @@ sv:
all: 'Alla'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -537,7 +538,6 @@ sv:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Jag vill bli meddelad om ändringar som jag själv gör"
password_confirmation:
field_description: 'Du behöver ange ditt lösenord för att bekräfta ändringen.'
title: 'Bekräfta ditt lösenord för att fortsätta'

@ -522,6 +522,7 @@ tr:
all: 'Hepsi'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ tr:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Yaptığım değişiklikleri kendim bildirmek istiyorum"
password_confirmation:
field_description: 'Bu değişikliği onaylamak için hesap parolanızı girmeniz gerekir.'
title: 'Devam etmek için parolanızı doğrulayın'

@ -522,6 +522,7 @@ uk:
all: 'Всі'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ uk:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "Я хочу отримувати повідомлення про зміни, які я роблю сам"
password_confirmation:
field_description: 'Вам необхідно ввести пароль облікового запису, щоб підтвердити цю зміну.'
title: 'Підтвердіть ваш пароль щоб продовжити'

@ -521,6 +521,7 @@ vi:
all: 'Toàn bộ'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -537,7 +538,6 @@ vi:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "I want to be notified of changes that I make myself"
password_confirmation:
field_description: 'You need to enter your account password to confirm this change.'
title: 'Confirm your password to continue'

@ -127,7 +127,7 @@ zh-CN:
preview: '切换预览模式'
source_code: '切换标记源模式'
error_saving_failed: '保存文档失败,出现以下错误:%{error}'
ckeditor_error: 'An error occurred within CKEditor'
ckeditor_error: 'CKEditor 发生错误'
mode:
manual: '切换到标记源'
wysiwyg: '切换到 WYSIWYG 编辑器'
@ -480,7 +480,7 @@ zh-CN:
got_it: '知道了'
steps:
help_menu: 'The Help (?) menu provides <b>additional help resources</b>. Here you can find a user guide and helpful how-to videos and more. <br> Enjoy your work with OpenProject!'
members: 'Invite new <b>members</b> to join your project.'
members: '邀请新<b>成员</b>加入您的项目。'
project_selection: 'Please click on one of the demo projects that we have prepared. Demo data is currently only available in English. <br> The general <b>demo project</b> suits best for classical project management, while the <b>Scrum project</b> is better for agile project management.'
quick_add_button: 'Click on the plus (+) icon in the header navigation to <b>create a new project</b> or to <b>invite coworkers</b>.'
sidebar_arrow: "Use the return arrow in the top left corner to return to the project’s <b>main menu</b>."
@ -522,6 +522,7 @@ zh-CN:
all: '全部'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -538,7 +539,6 @@ zh-CN:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "我想要自己的更改通知"
password_confirmation:
field_description: '输入密码以确认更改'
title: '输入密码以继续'

@ -521,6 +521,7 @@ zh-TW:
all: '全部'
center:
mark_all_read: 'Mark all as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings:
default_all_projects: 'Default for all projects'
@ -537,7 +538,6 @@ zh-TW:
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "我想要在我自己有變更的時候被通知"
password_confirmation:
field_description: '您需要輸入您的帳戶密碼,以確認此更改。'
title: '確認您的密碼以便繼續'

@ -170,6 +170,8 @@ ko:
enabled_in_project: '프로젝트에 사용 가능함.'
contained_in_type: '타입에 포함됨.'
confirm_destroy_option: "옵션을 삭제하면 모든 해당 항목(예: 작업 패키지 내 모든 항목)이 삭제됩니다. 그래도 삭제하시겠습니까?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: 사용자 필드가 없습니다.
no_results_content_text: 새 사용자 필드 생성

@ -167,6 +167,8 @@ lt:
enabled_in_project: 'Įgalintas projekte'
contained_in_type: 'Laikomas tipe'
confirm_destroy_option: "Ištrinant savybę, visi jos panaudojimai (pvz., darbų paketuose) taip pat bus ištrinti. Ar Jūs tikrai norite ištrinti?"
reorder_alphabetical: "Perrikiuoti reikšmes alfabeto tvarka"
reorder_confirmation: "Dėmesio: Dabartinė galimų reikšmių tvarka bus prarasta. Tęsti?"
tab:
no_results_title_text: Nėra jokių pasirinktinų laukų.
no_results_content_text: Sukurti naują pasirinktinį lauką
@ -1438,7 +1440,7 @@ lt:
label_check_uncheck_all_in_column: "Žymėti/atžymėti visus stulpelyje"
label_check_uncheck_all_in_row: "Žymėti/atžymėti visus eilutėje"
label_child_element: "Vaiko elementas"
label_chronological_order: "Oldest first"
label_chronological_order: "Seniausias pirmas"
label_close_versions: "Uždaryti užbaigtas versijas"
label_closed_work_packages: "uždarytas"
label_collapse: "Sutraukti"
@ -1742,7 +1744,7 @@ lt:
label_required: 'reikalingas'
label_requires: 'turi turėti'
label_result_plural: "Rezultatai"
label_reverse_chronological_order: "Newest first"
label_reverse_chronological_order: "Naujausias pirmas"
label_revision: "Revizija"
label_revision_id: "Revizija %{value}"
label_revision_plural: "Revizijos"

@ -170,6 +170,8 @@ nl:
enabled_in_project: 'Ingeschakeld in project'
contained_in_type: 'Opgenomen in type'
confirm_destroy_option: "Het verwijderen van een optie zal alle voorkomingen verwijderen. Weet u zeker dat u deze optie wilt verwijderen?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Momenteel zijn er geen aangepaste velden.
no_results_content_text: Maak een nieuw aangepast veld

@ -170,6 +170,8 @@
enabled_in_project: 'Aktivert i prosjekt'
contained_in_type: 'Contained in type'
confirm_destroy_option: "Deleting an option will delete all of its occurrences (e.g. in work packages). Are you sure you want to delete it?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: There are currently no custom fields.
no_results_content_text: Opprett nytt egendefinert felt

@ -167,6 +167,8 @@ pl:
enabled_in_project: 'Włączone w projekcie'
contained_in_type: 'Zawartość'
confirm_destroy_option: "Usunięcie tej opcji spowoduje usunięcie wszystkich powiązanych wystąpień (np. w Work Package). Czy na pewno chcesz to usunąć?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Nie ma jeszcze żadnych pól użytkownika.
no_results_content_text: Utwórz pole użytkownika

@ -169,6 +169,8 @@ pt:
enabled_in_project: 'Habilitado no projeto'
contained_in_type: 'Contido no tipo'
confirm_destroy_option: "Removendo uma opção removerá todas as suas ocorrências (ex. em pacotes de trabalho). Tem certeza que você quer removê-la?"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values will be lost. Continue?"
tab:
no_results_title_text: Atualmente, não há campos personalizados.
no_results_content_text: Criar um novo campo personalizado

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save