Merge remote-tracking branch 'origin/dev' into feature/38520-Sidebar-in-Notification-Center-with-project-filter

pull/9581/head
Oliver Günther 3 years ago
commit fc31d31f6d
No known key found for this signature in database
GPG Key ID: 88872239EB414F99
  1. 2
      .pkgr.yml
  2. 4
      .rubocop.yml
  3. 21
      Gemfile
  4. 117
      Gemfile.lock
  5. 18
      app/contracts/journals/update_contract.rb
  6. 42
      app/contracts/notifications/create_contract.rb
  7. 14
      app/contracts/params_contract.rb
  8. 9
      app/contracts/user_preferences/base_contract.rb
  9. 60
      app/contracts/user_preferences/params_contract.rb
  10. 2
      app/contracts/work_packages/update_contract.rb
  11. 2
      app/controllers/admin/settings/notifications_settings_controller.rb
  12. 18
      app/helpers/mail_digest_helper.rb
  13. 23
      app/helpers/mail_notification_helper.rb
  14. 4
      app/helpers/messages_helper.rb
  15. 77
      app/helpers/search_helper.rb
  16. 11
      app/helpers/settings_helper.rb
  17. 49
      app/mailers/application_mailer.rb
  18. 2
      app/mailers/digest_mailer.rb
  19. 219
      app/mailers/user_mailer.rb
  20. 88
      app/mailers/work_package_mailer.rb
  21. 6
      app/models/enterprise_token.rb
  22. 14
      app/models/journal.rb
  23. 11
      app/models/notification.rb
  24. 46
      app/models/notification_setting.rb
  25. 3
      app/models/notification_settings/scopes/applicable.rb
  26. 8
      app/models/notifications/scopes/mail_alert_unsent.rb
  27. 6
      app/models/notifications/scopes/mail_reminder_unsent.rb
  28. 9
      app/models/notifications/scopes/unsent_reminders_before.rb
  29. 2
      app/models/project.rb
  30. 6
      app/models/queries/notifications/filters/reason_filter.rb
  31. 2
      app/models/queries/notifications/group_bys/group_by_reason.rb
  32. 2
      app/models/queries/notifications/orders/reason_order.rb
  33. 5
      app/models/user.rb
  34. 4
      app/models/user_preference.rb
  35. 144
      app/models/users/scopes/having_reminder_mail_to_send.rb
  36. 33
      app/models/users/scopes/notified_globally.rb
  37. 4
      app/seeders/admin_user_seeder.rb
  38. 43
      app/services/attachments/base_service.rb
  39. 2
      app/services/attachments/build_service.rb
  40. 100
      app/services/attachments/create_service.rb
  41. 10
      app/services/base_services/base_contracted.rb
  42. 2
      app/services/base_services/write.rb
  43. 28
      app/services/journals/create_service.rb
  44. 31
      app/services/journals/set_attributes_service.rb
  45. 31
      app/services/journals/update_service.rb
  46. 116
      app/services/notifications/create_from_model_service.rb
  47. 6
      app/services/notifications/create_from_model_service/comment_strategy.rb
  48. 6
      app/services/notifications/create_from_model_service/message_strategy.rb
  49. 6
      app/services/notifications/create_from_model_service/news_strategy.rb
  50. 10
      app/services/notifications/create_from_model_service/wiki_content_strategy.rb
  51. 6
      app/services/notifications/create_from_model_service/work_package_strategy.rb
  52. 9
      app/services/notifications/create_service.rb
  53. 70
      app/services/notifications/mail_service.rb
  54. 13
      app/services/notifications/mail_service/comment_strategy.rb
  55. 13
      app/services/notifications/mail_service/message_strategy.rb
  56. 13
      app/services/notifications/mail_service/news_strategy.rb
  57. 11
      app/services/notifications/mail_service/wiki_content_strategy.rb
  58. 40
      app/services/notifications/mail_service/work_package_strategy.rb
  59. 2
      app/services/projects/enabled_modules_service.rb
  60. 2
      app/services/projects/schedule_deletion_service.rb
  61. 27
      app/services/user_preferences/update_service.rb
  62. 4
      app/services/users/set_attributes_service.rb
  63. 4
      app/services/users/update_service.rb
  64. 8
      app/views/admin/settings/mail_notifications_settings/show.html.erb
  65. 22
      app/views/admin/settings/notifications_settings/show.html.erb
  66. 148
      app/views/digest_mailer/work_packages.html.erb
  67. 9
      app/views/digest_mailer/work_packages.text.erb
  68. 7
      app/views/enterprises/_current.html.erb
  69. 34
      app/views/layouts/mailer.html.erb
  70. 71
      app/views/mailer/_notification_mailer_header.html.erb
  71. 144
      app/views/mailer/_notification_row.html.erb
  72. 5
      app/views/search/index.html.erb
  73. 2
      app/views/user_mailer/message_posted.html.erb
  74. 2
      app/views/user_mailer/message_posted.text.erb
  75. 8
      app/views/wiki/show.html.erb
  76. 28
      app/views/work_package_mailer/_work_package_details.html.erb
  77. 32
      app/views/work_package_mailer/_work_package_details.text.erb
  78. 55
      app/views/work_package_mailer/mentioned.html.erb
  79. 13
      app/views/work_package_mailer/mentioned.text.erb
  80. 10
      app/views/work_package_mailer/watcher_changed.html.erb
  81. 6
      app/views/work_package_mailer/watcher_changed.text.erb
  82. 15
      app/workers/mails/member_job.rb
  83. 55
      app/workers/mails/reminder_job.rb
  84. 13
      app/workers/mails/watcher_job.rb
  85. 46
      app/workers/notifications/schedule_reminder_mails_job.rb
  86. 55
      app/workers/notifications/with_marked_notifications.rb
  87. 43
      app/workers/notifications/workflow_job.rb
  88. 17
      config.ru
  89. 13
      config/configuration.yml.example
  90. 5
      config/initializers/06-pending_migrations_check.rb
  91. 3
      config/initializers/cronjobs.rb
  92. 9
      config/initializers/database_pool_size.rb
  93. 6
      config/initializers/rack-cors.rb
  94. 43
      config/initializers/rack_timeout.rb
  95. 4
      config/initializers/register_mail_interceptors.rb
  96. 29
      config/locales/crowdin/ar.yml
  97. 25
      config/locales/crowdin/bg.yml
  98. 25
      config/locales/crowdin/ca.yml
  99. 27
      config/locales/crowdin/cs.yml
  100. 25
      config/locales/crowdin/da.yml
  101. Some files were not shown because too many files have changed in this diff Show More

@ -13,6 +13,8 @@ targets:
- imagemagick
debian-10:
<<: *debian9
debian-11:
<<: *debian9
ubuntu-16.04:
<<: *debian9
ubuntu-18.04:

@ -100,6 +100,10 @@ Naming/AccessorMethodName:
Naming/AsciiIdentifiers:
Enabled: false
Naming/ClassAndModuleCamelCase:
AllowedNames:
- V2_1
Naming/FileName:
Enabled: false

@ -112,7 +112,7 @@ gem 'multi_json', '~> 1.15.0'
gem 'oj', '~> 3.13.0'
gem 'daemons'
gem 'delayed_cron_job', '~> 0.7.4'
gem 'delayed_cron_job', '~> 0.8.0'
gem 'delayed_job_active_record', '~> 4.1.5'
gem 'rack-protection', '~> 2.1.0'
@ -142,7 +142,7 @@ gem 'structured_warnings', '~> 0.4.0'
# catch exceptions and send them to any airbrake compatible backend
# don't require by default, instead load on-demand when actually configured
gem 'airbrake', '~> 11.0.0', require: false
gem 'airbrake', '~> 12.0.0', require: false
gem 'prawn', '~> 2.2'
gem 'prawn-markup', '~> 0.3.0'
@ -156,22 +156,17 @@ group :production do
# we use dalli as standard memcache client
# requires memcached 1.4+
gem 'dalli', '~> 2.7.10'
# Unicorn worker killer to restart unicorn child workers
gem 'unicorn-worker-killer', require: false
end
gem 'i18n-js', '~> 3.9.0'
gem 'rails-i18n', '~> 6.0.0'
gem 'sprockets', '~> 3.7.0'
# required by Procfile, for deployment on heroku or packaging with packager.io.
# also, better than thin since we can control worker concurrency.
gem 'unicorn'
gem 'puma', '~> 5.4.0' # used for development and optionally for production
gem 'puma', '~> 5.5'
gem 'rack-timeout', '~> 0.6.0', require: "rack/timeout/base"
gem 'puma-plugin-statsd', '~> 2.0'
gem 'nokogiri', '~> 1.12.0'
gem 'nokogiri', '~> 1.12.5'
gem 'carrierwave', '~> 1.3.1'
gem 'carrierwave_direct', '~> 2.1.0'
@ -181,7 +176,7 @@ gem 'aws-sdk-core', '~> 3.107'
# File upload via fog + screenshots on travis
gem 'aws-sdk-s3', '~> 1.91'
gem 'openproject-token', '~> 2.1.1'
gem 'openproject-token', '~> 2.2.0'
gem 'plaintext', '~> 0.3.2'
@ -289,7 +284,7 @@ group :development, :test do
gem 'danger-brakeman'
end
gem 'bootsnap', '~> 1.8.0', require: false
gem 'bootsnap', '~> 1.9.1', require: false
# API gems
gem 'grape', '~> 1.5.0'

@ -177,7 +177,7 @@ PATH
remote: modules/xls_export
specs:
openproject-xls_export (1.0.0)
spreadsheet (~> 1.2.6)
spreadsheet (~> 1.3.0)
GEM
remote: https://rubygems.org/
@ -267,9 +267,9 @@ GEM
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.1.0)
afm (0.2.2)
airbrake (11.0.3)
airbrake-ruby (~> 5.1)
airbrake-ruby (5.2.1)
airbrake (12.0.0)
airbrake-ruby (~> 6.0)
airbrake-ruby (6.0.0)
rbtree3 (~> 0.5)
ast (2.4.2)
attr_required (1.0.1)
@ -278,8 +278,8 @@ GEM
awesome_nested_set (3.4.0)
activerecord (>= 4.0.0, < 7.0)
aws-eventstream (1.2.0)
aws-partitions (1.500.0)
aws-sdk-core (3.121.0)
aws-partitions (1.510.0)
aws-sdk-core (3.121.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
@ -287,7 +287,7 @@ GEM
aws-sdk-kms (1.48.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.102.0)
aws-sdk-s3 (1.103.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
@ -300,7 +300,7 @@ GEM
bindata (2.4.10)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
bootsnap (1.8.1)
bootsnap (1.9.1)
msgpack (~> 1.0)
brakeman (5.1.1)
browser (5.3.1)
@ -344,7 +344,7 @@ GEM
open4 (~> 1.3)
coderay (1.1.3)
colored2 (3.1.2)
commonmarker (0.23.1)
commonmarker (0.23.2)
compare-xml (0.66)
nokogiri (~> 1.8)
concurrent-ruby (1.1.9)
@ -358,7 +358,7 @@ GEM
rest-client (~> 2.0)
daemons (1.4.1)
dalli (2.7.11)
danger (8.3.1)
danger (8.4.0)
claide (~> 1.0)
claide-plugins (>= 0.9.2)
colored2 (~> 3.1)
@ -392,8 +392,9 @@ GEM
declarative-builder (0.1.0)
declarative-option (< 0.2.0)
declarative-option (0.1.0)
delayed_cron_job (0.7.4)
delayed_cron_job (0.8.0)
delayed_job (>= 4.1)
fugit (>= 1.5)
delayed_job (4.1.9)
activesupport (>= 3.0, < 6.2)
delayed_job_active_record (4.1.6)
@ -408,7 +409,7 @@ GEM
uber (< 0.2.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.5.2)
doorkeeper (5.5.3)
railties (>= 5)
dry-configurable (0.13.0)
concurrent-ruby (~> 1.0)
@ -448,9 +449,11 @@ GEM
temple
erubi (1.10.0)
escape_utils (1.2.1)
et-orbi (1.2.5)
tzinfo
eventmachine (1.2.7)
eventmachine_httpserver (0.2.1)
excon (0.85.0)
excon (0.86.0)
factory_bot (6.2.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.2.0)
@ -458,7 +461,7 @@ GEM
railties (>= 5.0.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
faraday (1.7.1)
faraday (1.8.0)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -495,17 +498,18 @@ GEM
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-xml (0.1.3)
fog-xml (0.1.4)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
formatador (0.3.0)
friendly_id (5.4.2)
activerecord (>= 4.0.0)
fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4)
fuubar (2.5.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
get_process_mem (0.2.7)
ffi (~> 1.0)
git (1.9.1)
rchardet (~> 1.8)
globalid (0.5.2)
@ -563,7 +567,6 @@ GEM
json_spec (1.1.5)
multi_json (~> 1.0)
rspec (>= 2.0, < 4.0)
kgio (2.11.4)
kramdown (2.3.1)
rexml
kramdown-parser-gfm (1.1.0)
@ -597,7 +600,7 @@ GEM
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
marcel (1.0.1)
marcel (1.0.2)
messagebird-rest (1.4.2)
meta-tags (2.15.0)
actionpack (>= 3.2.0, < 6.2)
@ -623,13 +626,13 @@ GEM
netrc (0.11.0)
nio4r (2.5.8)
no_proxy_fix (0.1.2)
nokogiri (1.12.4)
nokogiri (1.12.5)
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
octokit (4.21.0)
faraday (>= 0.9)
sawyer (~> 0.8.0, >= 0.5.3)
oj (3.13.6)
oj (3.13.8)
okcomputer (1.18.4)
omniauth-saml (1.10.3)
omniauth (~> 1.3, >= 1.3.2)
@ -645,10 +648,10 @@ GEM
validate_email
validate_url
webfinger (>= 1.0.1)
openproject-token (2.1.3)
openproject-token (2.2.0)
activemodel
parallel (1.21.0)
parallel_tests (3.7.1)
parallel_tests (3.7.3)
parallel
parser (3.0.2.0)
ast (~> 2.4.1)
@ -699,8 +702,11 @@ GEM
eventmachine_httpserver
http_parser.rb (~> 0.6.0)
multi_json
puma (5.4.0)
puma (5.5.0)
nio4r (~> 2.0)
puma-plugin-statsd (2.0.0)
puma (>= 5.0, < 6)
raabro (1.4.0)
racc (1.5.2)
rack (2.2.3)
rack-accept (0.4.5)
@ -711,7 +717,7 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (2.3.3)
rack (>= 1.2.0)
rack-oauth2 (1.18.0)
rack-oauth2 (1.19.0)
activesupport
attr_required
httpclient
@ -721,6 +727,7 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rack_session_access (0.2.0)
builder (>= 2.0.0)
rack (>= 1.0.0)
@ -758,7 +765,6 @@ GEM
rake (>= 0.13)
thor (~> 1.0)
rainbow (3.0.0)
raindrops (0.19.2)
rake (13.0.6)
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
@ -790,7 +796,7 @@ GEM
roar (1.1.0)
representable (~> 3.0.0)
rotp (6.2.0)
rouge (3.26.0)
rouge (3.26.1)
rspec (3.10.0)
rspec-core (~> 3.10.0)
rspec-expectations (~> 3.10.0)
@ -814,24 +820,23 @@ GEM
rspec-retry (0.6.2)
rspec-core (> 3.3)
rspec-support (3.10.2)
rubocop (1.20.0)
rubocop (1.22.1)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.9.1, < 2.0)
rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.11.0)
rubocop-ast (1.12.0)
parser (>= 3.0.1.1)
rubocop-rails (2.12.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.4.0)
rubocop (~> 1.0)
rubocop-ast (>= 1.1.0)
rubocop-rspec (2.5.0)
rubocop (~> 1.19)
ruby-duration (3.2.3)
activesupport (>= 3.0.0)
i18n
@ -867,23 +872,23 @@ GEM
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
semantic (1.6.1)
sentry-delayed_job (4.7.2)
sentry-delayed_job (4.7.3)
delayed_job (>= 4.0)
sentry-ruby-core (~> 4.7.0)
sentry-rails (4.7.2)
sentry-rails (4.7.3)
railties (>= 5.0)
sentry-ruby-core (~> 4.7.0)
sentry-ruby (4.7.2)
sentry-ruby (4.7.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
faraday (>= 1.0)
sentry-ruby-core (= 4.7.2)
sentry-ruby-core (4.7.2)
sentry-ruby-core (= 4.7.3)
sentry-ruby-core (4.7.3)
concurrent-ruby
faraday
shoulda-context (2.0.0)
shoulda-matchers (5.0.0)
activesupport (>= 5.2.0)
spreadsheet (1.2.9)
spreadsheet (1.3.0)
ruby-ole
spring (3.0.0)
spring-commands-rspec (1.0.4)
@ -900,7 +905,7 @@ GEM
stringex (2.8.5)
structured_warnings (0.4.0)
svg-graph (2.2.1)
swd (1.2.0)
swd (1.3.0)
activesupport (>= 3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
@ -908,7 +913,7 @@ GEM
ffi (~> 1.1)
table_print (1.5.7)
temple (0.8.2)
terminal-table (3.0.1)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
test-prof (1.0.7)
thor (1.1.0)
@ -919,19 +924,13 @@ GEM
rails (>= 5.0.4)
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.1)
tzinfo-data (1.2021.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (2.0.0)
unicorn (6.0.0)
kgio (~> 2.6)
raindrops (~> 0.7)
unicorn-worker-killer (0.4.5)
get_process_mem (~> 0)
unicorn (>= 4, < 7)
unf_ext (0.0.8)
unicode-display_width (2.1.0)
uri_template (0.7.0)
validate_email (0.1.6)
activemodel (>= 3.0)
@ -947,7 +946,7 @@ GEM
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (>= 3.0, < 4.0)
webfinger (1.1.0)
webfinger (1.2.0)
activesupport
httpclient (>= 2.4)
webmock (3.14.0)
@ -976,13 +975,13 @@ DEPENDENCIES
acts_as_list (~> 1.0.1)
acts_as_tree (~> 2.9.0)
addressable (~> 2.8.0)
airbrake (~> 11.0.0)
airbrake (~> 12.0.0)
auto_strip_attributes (~> 2.5)
awesome_nested_set (~> 3.4.0)
aws-sdk-core (~> 3.107)
aws-sdk-s3 (~> 1.91)
bcrypt (~> 3.1.6)
bootsnap (~> 1.8.0)
bootsnap (~> 1.9.1)
brakeman (~> 5.1.0)
browser (~> 5.3.0)
budgets!
@ -1002,7 +1001,7 @@ DEPENDENCIES
database_cleaner (~> 2.0)
date_validator (~> 0.12.0)
deckar01-task_list (~> 2.3.1)
delayed_cron_job (~> 0.7.4)
delayed_cron_job (~> 0.8.0)
delayed_job_active_record (~> 4.1.5)
disposable (~> 0.4.7)
doorkeeper (~> 5.5.0)
@ -1036,7 +1035,7 @@ DEPENDENCIES
multi_json (~> 1.15.0)
my_page!
net-ldap (~> 0.17.0)
nokogiri (~> 1.12.0)
nokogiri (~> 1.12.5)
oj (~> 3.13.0)
okcomputer (~> 1.18.1)
omniauth!
@ -1058,7 +1057,7 @@ DEPENDENCIES
openproject-pdf_export!
openproject-recaptcha!
openproject-reporting!
openproject-token (~> 2.1.1)
openproject-token (~> 2.2.0)
openproject-translations!
openproject-two_factor_authentication!
openproject-webhooks!
@ -1075,12 +1074,14 @@ DEPENDENCIES
pry-rescue (~> 1.5.2)
pry-stack_explorer (~> 0.6.0)
puffing-billy (~> 2.4.0)
puma (~> 5.4.0)
puma (~> 5.5)
puma-plugin-statsd (~> 2.0)
rack-attack (~> 6.5.0)
rack-cors (~> 1.1.1)
rack-mini-profiler
rack-protection (~> 2.1.0)
rack-test (~> 1.1.0)
rack-timeout (~> 0.6.0)
rack_session_access
rails (~> 6.1.3)
rails-controller-testing (~> 1.0.2)
@ -1126,8 +1127,6 @@ DEPENDENCIES
timecop (~> 0.9.0)
typed_dag (~> 2.0.2)
tzinfo-data (~> 1.2021.1)
unicorn
unicorn-worker-killer
warden (~> 1.2)
warden-basic_auth (~> 0.2.1)
webdrivers (~> 4.6.0)

@ -26,16 +26,16 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject::Documents::Patches::NotifiablePatch
def self.included(base)
class << base
prepend ClassMethods
end
end
module Journals
class UpdateContract < BaseContract
attribute :notes
validate :user_allowed_to_edit
private
module ClassMethods
def all
super + [::OpenProject::Notifiable.new('document_added')]
def user_allowed_to_edit
errors.add(:base, :error_unauthorized) unless model.editable_by?(user)
end
end
end

@ -28,52 +28,38 @@
module Notifications
class CreateContract < ::ModelContract
CHANNELS = %i[ian mail mail_digest].freeze
attribute :recipient
attribute :subject
attribute :reason_ian
attribute :reason_mail
attribute :reason_mail_digest
attribute :reason
attribute :project
attribute :actor
attribute :resource
attribute :journal
attribute :resource_type
attribute :read_ian
attribute :read_mail
attribute :read_mail_digest
attribute :mail_reminder_sent
attribute :mail_alert_sent
validate :validate_recipient_present
validate :validate_reason_present
validate :validate_channels
def validate_recipient_present
errors.add(:recipient, :blank) if model.recipient.blank?
end
validate :validate_read
validate :validate_sent
def validate_reason_present
CHANNELS.each do |channel|
errors.add(:"reason_#{channel}", :no_notification_reason) if read_channel_without_reason?(channel)
end
def validate_read
errors.add(:read_ian, :read_on_creation) if model.read_ian
end
def validate_channels
if CHANNELS.map { |channel| read_channel(channel) }.compact.empty?
errors.add(:base, :at_least_one_channel)
end
CHANNELS.each do |channel|
errors.add(:"read_#{channel}", :read_on_creation) if read_channel(channel)
end
def validate_sent
errors.add(:mail_reminder_sent, :set_on_creation) if model.mail_reminder_sent
errors.add(:mail_alert_sent, :set_on_creation) if model.mail_alert_sent
end
def read_channel_without_reason?(channel)
read_channel(channel) == false && model.send(:"reason_#{channel}").nil?
def validate_recipient_present
errors.add(:recipient, :blank) if model.recipient.blank?
end
def read_channel(channel)
model.send(:"read_#{channel}")
def validate_reason_present
errors.add(:reason, :no_notification_reason) if model.reason.nil?
end
end
end

@ -27,13 +27,13 @@
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
describe OpenProject::Notifiable do
describe '#all' do
it 'includes document_added' do
expect(described_class.all.map(&:name))
.to include('document_added')
end
class ParamsContract < BaseContract
attr_reader :params
def initialize(model, user, params:, options: {})
super(model, user, options: options)
@params = params
end
end

@ -48,6 +48,9 @@ module UserPreferences
validate :full_hour_reminder_time,
if: -> { model.daily_reminders.present? }
validate :no_duplicate_workdays,
if: -> { model.workdays.present? }
protected
def time_zone_correctness
@ -68,5 +71,11 @@ module UserPreferences
errors.add :daily_reminders, :full_hour
end
end
def no_duplicate_workdays
unless model.workdays.uniq.length == model.workdays.length
errors.add :workdays, :no_duplicates
end
end
end
end

@ -28,28 +28,46 @@
# See COPYRIGHT and LICENSE files for more details.
#++
# Return all users who want to be notified on every activity within a project.
# If there is only the global notification setting in place, that one is authoritative.
# If there is a project specific setting in place, it is the project specific setting instead.
module Users::Scopes
module NotifiedOnAll
extend ActiveSupport::Concern
class_methods do
def notified_on_all(project)
global_settings = NotificationSetting
.where(all: true, project: nil)
project_settings_not_all = NotificationSetting
.where(project: project)
.group(:user_id)
.having('NOT bool_or("all")')
project_settings = NotificationSetting
.where(all: true, project: project)
where(id: global_settings.select(:user_id))
.where.not(id: project_settings_not_all.select(:user_id))
.or(User.where(id: project_settings.select(:user_id)))
module UserPreferences
class ParamsContract < ::ParamsContract
validate :only_one_global_setting,
if: -> { notifications.present? }
validate :global_email_alerts,
if: -> { notifications.present? }
protected
def only_one_global_setting
if global_notifications.count > 1
errors.add :notification_settings, :only_one_global_setting
end
end
def global_email_alerts
if project_notifications.any?(method(:email_alerts_set?))
errors.add :notification_settings, :email_alerts_global
end
end
##
# Check if the given notification hash has email-only settings set
def email_alerts_set?(notification_setting)
NotificationSetting.email_settings.any? do |setting|
notification_setting[setting] == true
end
end
def global_notifications
notifications.select { |notification| notification[:project_id].nil? }
end
def project_notifications
notifications.select { |notification| notification[:project_id].present? }
end
def notifications
params[:notification_settings]
end
end
end

@ -28,8 +28,6 @@
# See COPYRIGHT and LICENSE files for more details.
#++
require 'work_packages/base_contract'
module WorkPackages
class UpdateContract < BaseContract
include UnchangedProject

@ -35,8 +35,6 @@ module Admin::Settings
end
def show
@notifiables = OpenProject::Notifiable.all
respond_to :html
end

@ -52,10 +52,10 @@ module MailDigestHelper
end
def digest_comment_text(notification)
if notification.reason_mail_digest === "mentioned"
sanitize I18n.t(:'mail.digests.work_packages.mentioned')
if notification.reason_mentioned?
sanitize I18n.t(:'mail.work_packages.mentioned')
else
sanitize I18n.t(:'mail.digests.work_packages.comment_added')
sanitize I18n.t(:'mail.work_packages.comment_added')
end
end
@ -65,14 +65,14 @@ module MailDigestHelper
value = journal.initial? ? "created" : "updated"
if extended
sanitize(
"#{I18n.t(:"mail.digests.work_packages.#{value}")} #{I18n.t(:"mail.digests.work_packages.#{value}_at",
user: user,
timestamp: journal.created_at.strftime(
I18n.t(:'time.formats.time')
))}"
"#{I18n.t(:"mail.work_packages.#{value}")} #{I18n.t(:"mail.work_packages.#{value}_at",
user: user,
timestamp: journal.created_at.strftime(
I18n.t(:'time.formats.time')
))}"
)
else
sanitize(I18n.t(:"mail.digests.work_packages.#{value}_at",
sanitize(I18n.t(:"mail.work_packages.#{value}_at",
user: user,
timestamp: journal.created_at.strftime(I18n.t(:'time.formats.time'))))
end

@ -55,7 +55,7 @@ module MailNotificationHelper
def unique_reasons_of_notifications(notifications)
notifications
.map(&:reason_mail_digest)
.map(&:reason)
.uniq
end
@ -72,4 +72,25 @@ module MailNotificationHelper
color_id = selected_color(status)
Color.find(color_id).color_styles.map { |k, v| "#{k}:#{v};" }.join(' ') if color_id
end
def placeholder_table_styles(options = {})
default_options = {
style: 'table-layout:fixed;border-collapse:separate;border-spacing:0;font-family:Helvetica;' <<
(options[:style].present? ? options.delete(:style) : ''),
cellspacing: "0",
cellpadding: "0"
}
default_options.merge(options).map { |k, v| "#{k}=#{v}" }.join(' ')
end
def placeholder_cell(number, vertical:)
style = if vertical
"max-width:#{number}; min-width:#{number}; width:#{number}"
else
"line-height:#{number}; max-width:0; min-width:0; height:#{number}; width:0; font-size:#{number}"
end
content_tag('td', '&nbsp;'.html_safe, style: style)
end
end

@ -34,4 +34,8 @@ module MessagesHelper
current_user: current_user,
embed_links: true)
end
def message_url(message)
topic_url(message.root, r: message.id, anchor: "message-#{message.id}")
end
end

@ -29,35 +29,43 @@
#++
module SearchHelper
def highlight_tokens(text, tokens)
return text unless text && tokens && !tokens.empty?
def highlight_tokens(text, tokens, text_on_not_found: false)
split_text = text_split_by_token(text, tokens)
return nil unless split_text.length > 1 || text_on_not_found
re_tokens = tokens.map { |t| Regexp.escape(t) }
regexp = Regexp.new "(#{re_tokens.join('|')})", Regexp::IGNORECASE
result = ''
text.split(regexp).each_with_index do |words, i|
split_text.each_with_index do |words, i|
if result.length > 1200
# maximum length of the preview reached
result << '...'
break
end
if i.even?
result << h(words.length > 100 ? "#{words.slice(0..44)} ... #{words.slice(-45..-1)}" : words)
else
t = (tokens.index(words.downcase) || 0) % 4
result << content_tag('span', h(words), class: "search-highlight token-#{t}")
end
result << if i.even?
abbreviated_text(words)
else
token_span(tokens, words)
end
end
result.html_safe
end
def highlight_first(texts, tokens)
texts.each do |text|
if has_tokens? text, tokens
return highlight_tokens text, tokens
end
end
highlight_tokens texts[-1], tokens
def highlight_tokens_in_event(event, tokens)
# This way, the attachments are only loaded in case the tokens are not found inside
# the journal notes.
highlight_tokens(last_journal(event).try(:notes), tokens) or
highlight_tokens(attachment_fulltexts(event), tokens) or
highlight_tokens(attachment_filenames(event), tokens) or
highlight_tokens(event.event_description, tokens, text_on_not_found: true)
end
def text_split_by_token(text, tokens)
return [text].compact unless text && tokens && !tokens.empty?
re_tokens = tokens.map { |t| Regexp.escape(t) }
regexp = Regexp.new "(#{re_tokens.join('|')})", Regexp::IGNORECASE
text.split(regexp)
end
def has_tokens?(text, tokens)
@ -88,14 +96,6 @@ module SearchHelper
end
end
def attachment_fulltexts(event)
attachment_strings_for(:fulltext, event)
end
def attachment_filenames(event)
attachment_strings_for(:filename, event)
end
def type_label(t)
OpenProject::GlobalSearch.tab_name(t)
end
@ -124,9 +124,30 @@ module SearchHelper
private
def attachment_strings_for(attribute_name, event)
def attachment_fulltexts(event)
only_if_tsv_supported(event) do
Attachment.where(id: event.attachment_ids).pluck(:fulltext).join(' ')
end
end
def attachment_filenames(event)
only_if_tsv_supported(event) do
event.attachments&.map(&:filename)&.join(' ')
end
end
def only_if_tsv_supported(event)
if EnterpriseToken.allows_to?(:attachment_filters) && OpenProject::Database.allows_tsv? && event.respond_to?(:attachments)
event.attachments&.map(&attribute_name)&.join(' ')
yield
end
end
def token_span(tokens, words)
t = (tokens.index(words.downcase) || 0) % 4
content_tag('span', h(words), class: "search-highlight token-#{t}")
end
def abbreviated_text(words)
h(words.length > 100 ? "#{words.slice(0..44)} ... #{words.slice(-45..-1)}" : words)
end
end

@ -186,17 +186,6 @@ module SettingsHelper
setting_label(setting, options) + wrap_field_outer(options, &block)
end
# Renders a notification field for an OpenProject::Notifiable option
def notification_field(notifiable, options = {})
content_tag(:label, class: 'form--label-with-check-box') do
styled_check_box_tag('settings[notified_events][]',
notifiable.name,
Setting.notified_events.include?(notifiable.name),
options.merge(id: nil)) +
l_or_humanize(notifiable.name, prefix: 'label_')
end
end
private
def wrap_field_outer(options, &block)

@ -73,12 +73,6 @@ class ApplicationMailer < ActionMailer::Base
"#{hash}@#{host}"
end
def remove_self_notifications(message, author)
if author.pref && message.to.present?
message.to = message.to.reject { |address| address == author.mail }
end
end
def mail_timestamp(object)
object.send(object.respond_to?(:created_at) ? :created_at : :updated_at)
end
@ -114,6 +108,10 @@ class ApplicationMailer < ActionMailer::Base
headers['Message-ID'] = "<#{self.class.generate_message_id(object, user)}>"
end
def references(object, user)
headers['References'] = "<#{self.class.generate_message_id(object, user)}>"
end
# Prepends given fields with 'X-OpenProject-' to save some duplication
def open_project_headers(hash)
hash.each { |key, value| headers["X-OpenProject-#{key}"] = value.to_s }
@ -125,4 +123,43 @@ class ApplicationMailer < ActionMailer::Base
format.html unless Setting.plain_text_mail?
format.text
end
def send_mail(user, subject)
with_locale_for(user) do
mail to: user.mail, subject: subject
end
end
end
##
# Interceptors
#
# These are registered in config/initializers/register_mail_interceptors.rb
#
# Unfortunately, this results in changes on the interceptor classes during development mode
# not being reflected until a server restart.
class DefaultHeadersInterceptor
def self.delivering_email(mail)
mail.headers(default_headers)
end
def self.default_headers
{
'X-Mailer' => 'OpenProject',
'X-OpenProject-Host' => Setting.host_name,
'X-OpenProject-Site' => Setting.app_title,
'Precedence' => 'bulk',
'Auto-Submitted' => 'auto-generated'
}
end
end
class DoNotSendMailsWithoutReceiverInterceptor
def self.delivering_email(mail)
receivers = [mail.to, mail.cc, mail.bcc]
# the above fields might be empty arrays (if entries have been removed
# by another interceptor) or nil, therefore checking for blank?
mail.perform_deliveries = false if receivers.all?(&:blank?)
end
end

@ -68,7 +68,7 @@ class DigestMailer < ApplicationMailer
@mentioned_count = @aggregated_notifications
.values
.flatten
.map(&:reason_mail_digest)
.map(&:reason)
.compact
.count("mentioned")

@ -29,39 +29,25 @@
#++
class UserMailer < ApplicationMailer
def test_mail(user)
@welcome_url = url_for(controller: '/homescreen')
include MessagesHelper
headers['X-OpenProject-Type'] = 'Test'
helper_method :message_url
with_locale_for(user) do
mail to: "\"#{user.name}\" <#{user.mail}>", subject: 'OpenProject Test'
end
end
def work_package_watcher_changed(work_package, user, watcher_changer, action)
User.execute_as user do
@issue = work_package
@watcher_changer = watcher_changer
@action = action
def test_mail(user)
@welcome_url = url_for(controller: '/homescreen')
set_work_package_headers(work_package)
message_id work_package, user
references work_package, user
open_project_headers 'Type' => 'Test'
with_locale_for(user) do
mail to: user.mail, subject: subject_for_work_package(work_package)
end
end
send_mail(user,
'OpenProject Test')
end
def backup_ready(user)
User.execute_as user do
@download_url = admin_backups_url
with_locale_for(user) do
mail to: user.mail, subject: I18n.t("mail_subject_backup_ready")
end
send_mail(recipient,
I18n.t("mail_subject_backup_ready"))
end
end
@ -71,9 +57,8 @@ class UserMailer < ApplicationMailer
@waiting_period = waiting_period
User.execute_as recipient do
with_locale_for(recipient) do
mail to: recipient.mail, subject: I18n.t("mail_subject_backup_token_reset")
end
send_mail(recipient,
I18n.t("mail_subject_backup_token_reset"))
end
end
@ -87,14 +72,11 @@ class UserMailer < ApplicationMailer
open_project_headers 'Type' => 'Account'
user = @token.user
with_locale_for(user) do
subject = t(:mail_subject_lost_password, value: Setting.app_title)
mail to: user.mail, subject: subject
end
send_mail(token.user,
t(:mail_subject_lost_password, value: Setting.app_title))
end
def news_added(user, news, author)
def news_added(user, news)
@news = news
open_project_headers 'Type' => 'News'
@ -102,11 +84,10 @@ class UserMailer < ApplicationMailer
message_id @news, user
with_locale_for(user) do
subject = "#{News.model_name.human}: #{@news.title}"
subject = "[#{@news.project.name}] #{subject}" if @news.project
mail_for_author author, to: user.mail, subject: subject
end
subject = "#{News.model_name.human}: #{@news.title}"
subject = "[#{@news.project.name}] #{subject}" if @news.project
send_mail(user, subject)
end
def user_signed_up(token)
@ -120,14 +101,11 @@ class UserMailer < ApplicationMailer
open_project_headers 'Type' => 'Account'
user = token.user
with_locale_for(user) do
subject = t(:mail_subject_register, value: Setting.app_title)
mail to: user.mail, subject: subject
end
send_mail(token.user,
t(:mail_subject_register, value: Setting.app_title))
end
def news_comment_added(user, comment, author)
def news_comment_added(user, comment)
@comment = comment
@news = @comment.commented
@ -136,29 +114,23 @@ class UserMailer < ApplicationMailer
message_id @comment, user
references @news, user
with_locale_for(user) do
subject = "#{News.model_name.human}: #{@news.title}"
subject = "Re: [#{@news.project.name}] #{subject}" if @news.project
mail_for_author author, to: user.mail, subject: subject
end
subject = "#{News.model_name.human}: #{@news.title}"
subject = "Re: [#{@news.project.name}] #{subject}" if @news.project
send_mail(user, subject)
end
def wiki_content_added(user, wiki_content, author)
def wiki_content_added(user, wiki_content)
@wiki_content = wiki_content
open_project_headers 'Project' => @wiki_content.project.identifier,
'Wiki-Page-Id' => @wiki_content.page.id,
'Type' => 'Wiki'
open_project_wiki_headers @wiki_content
message_id @wiki_content, user
with_locale_for(user) do
subject = "[#{@wiki_content.project.name}] #{t(:mail_subject_wiki_content_added, id: @wiki_content.page.title)}"
mail_for_author author, to: user.mail, subject: subject
end
send_mail(user,
"[#{@wiki_content.project.name}] #{t(:mail_subject_wiki_content_added, id: @wiki_content.page.title)}")
end
def wiki_content_updated(user, wiki_content, author)
def wiki_content_updated(user, wiki_content)
@wiki_content = wiki_content
@wiki_diff_url = url_for(controller: '/wiki',
action: :diff,
@ -166,33 +138,22 @@ class UserMailer < ApplicationMailer
id: wiki_content.page.slug,
version: wiki_content.version)
open_project_headers 'Project' => @wiki_content.project.identifier,
'Wiki-Page-Id' => @wiki_content.page.id,
'Type' => 'Wiki'
open_project_wiki_headers @wiki_content
message_id @wiki_content, user
with_locale_for(user) do
subject = "[#{@wiki_content.project.name}] #{t(:mail_subject_wiki_content_updated, id: @wiki_content.page.title)}"
mail_for_author author, to: user.mail, subject: subject
end
send_mail(user,
"[#{@wiki_content.project.name}] #{t(:mail_subject_wiki_content_updated, id: @wiki_content.page.title)}")
end
def message_posted(user, message, author)
@message = message
@message_url = topic_url(@message.root, r: @message.id, anchor: "message-#{@message.id}")
open_project_headers 'Project' => @message.project.identifier,
'Wiki-Page-Id' => @message.parent_id || @message.id,
'Type' => 'Forum'
def message_posted(user, message)
@message = message
open_project_message_headers(@message)
message_id @message, user
references @message.parent, user if @message.parent
with_locale_for(user) do
subject = "[#{@message.forum.project.name} - #{@message.forum.name} - msg#{@message.root.id}] #{@message.subject}"
mail_for_author author, to: user.mail, subject: subject
end
send_mail(user,
"[#{@message.forum.project.name} - #{@message.forum.name} - msg#{@message.root.id}] #{@message.subject}")
end
def account_activated(user)
@ -200,10 +161,8 @@ class UserMailer < ApplicationMailer
open_project_headers 'Type' => 'Account'
with_locale_for(user) do
subject = t(:mail_subject_register, value: Setting.app_title)
mail to: user.mail, subject: subject
end
send_mail(user,
t(:mail_subject_register, value: Setting.app_title))
end
def account_information(user, password)
@ -212,10 +171,8 @@ class UserMailer < ApplicationMailer
open_project_headers 'Type' => 'Account'
with_locale_for(user) do
subject = t(:mail_subject_register, value: Setting.app_title)
mail to: user.mail, subject: subject
end
send_mail(user,
t(:mail_subject_register, value: Setting.app_title))
end
def account_activation_requested(admin, user)
@ -227,10 +184,8 @@ class UserMailer < ApplicationMailer
open_project_headers 'Type' => 'Account'
with_locale_for(admin) do
subject = t(:mail_subject_account_activation_request, value: Setting.app_title)
mail to: admin.mail, subject: subject
end
send_mail(admin,
t(:mail_subject_account_activation_request, value: Setting.app_title))
end
def reminder_mail(user, issues, days, group = nil)
@ -250,14 +205,13 @@ class UserMailer < ApplicationMailer
open_project_headers 'Type' => 'Issue'
with_locale_for(user) do
subject = if @group
t(:mail_subject_group_reminder, count: @issues.size, days: @days, group: @group.name)
else
t(:mail_subject_reminder, count: @issues.size, days: @days)
end
mail to: user.mail, subject: subject
end
subject = if @group
t(:mail_subject_group_reminder, count: @issues.size, days: @days, group: @group.name)
else
t(:mail_subject_reminder, count: @issues.size, days: @days)
end
send_mail(user, subject)
end
##
@ -268,74 +222,21 @@ class UserMailer < ApplicationMailer
def activation_limit_reached(user_email, admin)
@email = user_email
with_locale_for(admin) do
mail to: admin.mail, subject: t("mail_user_activation_limit_reached.subject")
end
send_mail(admin, t("mail_user_activation_limit_reached.subject"))
end
private
def subject_for_work_package(wp)
"#{wp.project.name} - #{wp.status.name} #{wp.type.name} ##{wp.id}: #{wp.subject}"
end
# like #mail, but contains special author based filters
# currently only:
# - remove_self_notifications
# might be refactored at a later time to be as generic as Interceptors
def mail_for_author(author, headers = {}, &block)
message = mail headers, &block
self.class.remove_self_notifications(message, author)
message
end
def references(object, user)
headers['References'] = "<#{self.class.generate_message_id(object, user)}>"
end
def set_work_package_headers(work_package)
open_project_headers 'Project' => work_package.project.identifier,
'Issue-Id' => work_package.id,
'Issue-Author' => work_package.author.login,
'Type' => 'WorkPackage'
if work_package.assigned_to
open_project_headers 'Issue-Assignee' => work_package.assigned_to.login
end
end
end
##
# Interceptors
#
# These are registered in config/initializers/register_mail_interceptors.rb
#
# Unfortunately, this results in changes on the interceptor classes during development mode
# not being reflected until a server restart.
class DefaultHeadersInterceptor
def self.delivering_email(mail)
mail.headers(default_headers)
def open_project_wiki_headers(wiki_content)
open_project_headers 'Project' => wiki_content.project.identifier,
'Wiki-Page-Id' => wiki_content.page.id,
'Type' => 'Wiki'
end
def self.default_headers
{
'X-Mailer' => 'OpenProject',
'X-OpenProject-Host' => Setting.host_name,
'X-OpenProject-Site' => Setting.app_title,
'Precedence' => 'bulk',
'Auto-Submitted' => 'auto-generated'
}
def open_project_message_headers(message)
open_project_headers 'Project' => message.project.identifier,
'Message-Id' => message.parent_id || message.id,
'Type' => 'Forum'
end
end
class DoNotSendMailsWithoutReceiverInterceptor
def self.delivering_email(mail)
receivers = [mail.to, mail.cc, mail.bcc]
# the above fields might be empty arrays (if entries have been removed
# by another interceptor) or nil, therefore checking for blank?
mail.perform_deliveries = false if receivers.all?(&:blank?)
end
end

@ -0,0 +1,88 @@
#-- 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 COPYRIGHT and LICENSE files for more details.
#++
class WorkPackageMailer < ApplicationMailer
helper :mail_notification
def mentioned(recipient, journal)
@user = recipient
@work_package = journal.journable
@journal = journal
author = journal.user
User.execute_as author do
set_work_package_headers(@work_package)
message_id journal, recipient
with_locale_for(recipient) do
mail to: recipient.mail,
subject: I18n.t(:'mail.mention.subject',
user_name: author.name,
id: @work_package.id,
subject: @work_package.subject)
end
end
end
def watcher_changed(work_package, user, watcher_changer, action)
User.execute_as user do
@work_package = work_package
@watcher_changer = watcher_changer
@action = action
set_work_package_headers(work_package)
message_id work_package, user
with_locale_for(user) do
mail to: user.mail, subject: subject_for_work_package(work_package)
end
end
end
private
def subject_for_work_package(work_package)
"#{work_package.project.name} - #{work_package.status.name} #{work_package.type.name} " +
"##{work_package.id}: #{work_package.subject}"
end
def set_work_package_headers(work_package)
open_project_headers 'Project' => work_package.project.identifier,
'WorkPackage-Id' => work_package.id,
'WorkPackage-Author' => work_package.author.login,
'Type' => 'WorkPackage'
if work_package.assigned_to
open_project_headers 'WorkPackage-Assignee' => work_package.assigned_to.login
end
end
end

@ -69,6 +69,8 @@ class EnterpriseToken < ApplicationRecord
:issued_at,
:starts_at,
:expires_at,
:reprieve_days,
:reprieve_days_left,
:restrictions,
to: :token_object
@ -86,8 +88,8 @@ class EnterpriseToken < ApplicationRecord
RequestStore.delete :current_ee_token
end
def expired?
token_object.expired? || invalid_domain?
def expired?(reprieve: true)
token_object.expired?(reprieve: reprieve) || invalid_domain?
end
##

@ -110,10 +110,14 @@ class Journal < ApplicationRecord
private
def predecessor
@predecessor ||= self.class
.where(journable_type: journable_type, journable_id: journable_id)
.where("#{self.class.table_name}.version < ?", version)
.order("#{self.class.table_name}.version DESC")
.first
@predecessor ||= if initial?
nil
else
self.class
.where(journable_type: journable_type, journable_id: journable_id)
.where("#{self.class.table_name}.version < ?", version)
.order(version: :desc)
.first
end
end
end

@ -12,9 +12,8 @@ class Notification < ApplicationRecord
responsible: 9
}.freeze
enum reason_ian: REASONS, _prefix: :ian
enum reason_mail: REASONS, _prefix: :mail
enum reason_mail_digest: REASONS, _prefix: :mail_digest
enum reason: REASONS,
_prefix: true
belongs_to :recipient, class_name: 'User'
belongs_to :actor, class_name: 'User'
@ -23,8 +22,8 @@ class Notification < ApplicationRecord
belongs_to :resource, polymorphic: true
include Scopes::Scoped
scopes :mail_digest_before,
:unread_mail,
:unread_mail_digest,
scopes :unsent_reminders_before,
:mail_reminder_unsent,
:mail_alert_unsent,
:recipient
end

@ -1,11 +1,51 @@
class NotificationSetting < ApplicationRecord
enum channel: { in_app: 0, mail: 1, mail_digest: 2 }
WATCHED = :watched
INVOLVED = :involved
MENTIONED = :mentioned
WORK_PACKAGE_CREATED = :work_package_created
WORK_PACKAGE_COMMENTED = :work_package_commented
WORK_PACKAGE_PROCESSED = :work_package_processed
WORK_PACKAGE_PRIORITIZED = :work_package_prioritized
WORK_PACKAGE_SCHEDULED = :work_package_scheduled
NEWS_ADDED = :news_added
NEWS_COMMENTED = :news_commented
DOCUMENT_ADDED = :document_added
FORUM_MESSAGES = :forum_messages
WIKI_PAGE_ADDED = :wiki_page_added
WIKI_PAGE_UPDATED = :wiki_page_updated
MEMBERSHIP_ADDED = :membership_added
MEMBERSHIP_UPDATED = :membership_updated
def self.all_settings
[
WATCHED,
INVOLVED,
MENTIONED,
WORK_PACKAGE_CREATED,
WORK_PACKAGE_COMMENTED,
WORK_PACKAGE_PROCESSED,
WORK_PACKAGE_PRIORITIZED,
WORK_PACKAGE_SCHEDULED,
*email_settings
]
end
def self.email_settings
[
NEWS_ADDED,
NEWS_COMMENTED,
DOCUMENT_ADDED,
FORUM_MESSAGES,
WIKI_PAGE_ADDED,
WIKI_PAGE_UPDATED,
MEMBERSHIP_ADDED,
MEMBERSHIP_UPDATED
]
end
belongs_to :project
belongs_to :user
include Scopes::Scoped
scopes :applicable
validates :channel, uniqueness: { scope: %i[project user] }
end

@ -45,8 +45,7 @@ module NotificationSettings::Scopes
.where(global_notifications[:project_id].eq(nil))
.join(project_notifications, Arel::Nodes::OuterJoin)
.on(project_notifications[:project_id].eq(project.id),
global_notifications[:user_id].eq(project_notifications[:user_id]),
global_notifications[:channel].eq(project_notifications[:channel]))
global_notifications[:user_id].eq(project_notifications[:user_id]))
.project(global_notifications.coalesce(project_notifications[:id], global_notifications[:id]))
where(global_notifications[:id].in(subselect))

@ -28,14 +28,14 @@
# See COPYRIGHT and LICENSE files for more details.
#++
# Return mail notifications that are unread (have read_mail: false)
# Return alert mail notifications that are unread (have mail_alert_sent: false)
module Notifications::Scopes
module UnreadMail
module MailAlertUnsent
extend ActiveSupport::Concern
class_methods do
def unread_mail
where(read_mail: false)
def mail_alert_unsent
where(mail_alert_sent: false)
end
end
end

@ -30,12 +30,12 @@
# Return digest mail notifications that are unread (have read_digest_mail: false)
module Notifications::Scopes
module UnreadMailDigest
module MailReminderUnsent
extend ActiveSupport::Concern
class_methods do
def unread_mail_digest
where(read_mail_digest: false)
def mail_reminder_unsent
where(mail_reminder_sent: false)
end
end
end

@ -29,16 +29,17 @@
#++
module Notifications::Scopes
module MailDigestBefore
module UnsentRemindersBefore
extend ActiveSupport::Concern
class_methods do
# Return notifications of the user for which mail digest is to be sent and that is created before
# Return notifications for the user for who email reminders shall be sent and that were created before
# the specified time.
def mail_digest_before(recipient:, time:)
def unsent_reminders_before(recipient:, time:)
where(Notification.arel_table[:created_at].lteq(time))
.where(recipient: recipient)
.where(read_mail_digest: false)
.where("read_ian IS NULL OR read_ian IS FALSE")
.where(mail_reminder_sent: false)
end
end
end

@ -44,7 +44,7 @@ class Project < ApplicationRecord
has_many :members, -> {
# TODO: check whether this should
# remaint to be limited to User only
# remain to be limited to User only
includes(:principal, :roles)
.merge(Principal.not_locked.user)
.references(:principal, :roles)

@ -30,7 +30,7 @@
class Queries::Notifications::Filters::ReasonFilter < Queries::Notifications::Filters::NotificationFilter
def allowed_values
Notification.reason_ians.keys.map { |reason| [reason, reason] }
Notification.reasons.keys.map { |reason| [reason, reason] }
end
def type
@ -38,7 +38,7 @@ class Queries::Notifications::Filters::ReasonFilter < Queries::Notifications::Fi
end
def where
id_values = values.map { |value| Notification.reason_ians[value] }
operator_strategy.sql_for_field(id_values, self.class.model.table_name, :reason_ian)
id_values = values.map { |value| Notification.reasons[value] }
operator_strategy.sql_for_field(id_values, self.class.model.table_name, :reason)
end
end

@ -36,6 +36,6 @@ class Queries::Notifications::GroupBys::GroupByReason < Queries::GroupBys::Base
end
def name
:reason_ian
:reason
end
end

@ -36,6 +36,6 @@ class Queries::Notifications::Orders::ReasonOrder < Queries::Orders::Base
end
def name
:reason_ian
:reason
end
end

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

@ -126,6 +126,10 @@ class UserPreference < ApplicationRecord
super.presence || { mentioned: false }.with_indifferent_access
end
def workdays
super.presence || [1, 2, 3, 4, 5]
end
def canonical_time_zone
return if time_zone.nil?

@ -0,0 +1,144 @@
#-- 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 COPYRIGHT and LICENSE files for more details.
#++
module Users::Scopes
module HavingReminderMailToSend
extend ActiveSupport::Concern
class_methods do
# Returns all users for which a reminder mails should be sent now. A user will be included if:
# * That user has an unread notification
# * The user hasn't been informed about the unread notification before
# * The user has configured reminder mails to be within the time frame between the provided time and now.
# This assumes that users only have full hours specified for the times they desire
# to receive a reminder mail at.
# @param [DateTime] earliest_time The earliest time to consider as a matching slot. All quarter hours from that time
# to now are included.
# Only the time part is used which is moved forward to the next quarter hour (e.g. 2021-05-03 10:34:12+02:00 -> 08:45:00).
# This is done because time zones always have a mod(15) == 0 minutes offset.
# Needs to be before the current time.
def having_reminder_mail_to_send(earliest_time)
local_times = local_times_from(earliest_time)
return none if local_times.empty?
# Left outer join as not all user instances have preferences associated
# but we still want to select them.
recipient_candidates = User
.active
.left_joins(:preference)
.joins(local_time_join(local_times))
subscriber_ids = Notification
.unsent_reminders_before(recipient: recipient_candidates, time: Time.current)
.group(:recipient_id)
.select(:recipient_id)
where(id: subscriber_ids)
end
def local_time_join(local_times)
# Joins the times local to the user preferences and then checks whether:
# * reminders are enabled
# * any of the configured reminder time is the local time
# If no time zone is present, utc is assumed.
# If no reminder settings are present, sending a reminder at 08:00 local time is assumed.
times_sql = arel_table
.grouping(Arel::Nodes::ValuesList.new(local_times))
.as('t(time, zone, workday)')
<<~SQL.squish
JOIN (SELECT * FROM #{times_sql.to_sql}) AS local_times
ON COALESCE(user_preferences.settings->>'time_zone', 'UTC') = local_times.zone
AND (
user_preferences.settings->'workdays' @> to_jsonb(local_times.workday)
OR (
user_preferences.settings->'workdays' IS NULL
AND local_times.workday BETWEEN 1 AND 5
)
)
AND (
(
user_preferences.settings->'daily_reminders'->'times' IS NULL
AND local_times.time = '08:00:00+00:00'
)
OR
(
(user_preferences.settings->'daily_reminders'->'enabled')::boolean
AND user_preferences.settings->'daily_reminders'->'times' ? local_times.time
)
)
SQL
end
def local_times_from(earliest_time)
times = quarters_between_earliest_and_now(earliest_time)
times_for_zones(times)
end
def times_for_zones(times)
ActiveSupport::TimeZone
.all
.map do |z|
times.map do |time|
local_time = time.in_time_zone(z)
# Get the iso weekday of the current time to check
# which users have it enabled as a workday
workday = local_time.to_date.cwday
# Since only full hours can be configured, we can disregard any local time that is not
# a full hour.
next if local_time.min != 0
[local_time.strftime('%H:00:00+00:00'), z.name.gsub("'", "''"), workday]
end
end
.flatten(1)
.compact
end
def quarters_between_earliest_and_now(earliest_time)
latest_time = Time.current
raise ArgumentError if latest_time < earliest_time || (latest_time - earliest_time) > 1.day
quarters = ((latest_time - earliest_time) / 60 / 15).floor
(1..quarters).each_with_object([next_quarter_hour(earliest_time)]) do |_, times|
times << (times.last + 15.minutes)
end
end
def next_quarter_hour(time)
(time + (time.min % 15 == 0 ? 0.minutes : (15 - (time.min % 15)).minutes))
end
end
end
end

@ -28,26 +28,21 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
NOTIFIABLE = [
%w(news_added),
%w(news_comment_added),
%w(message_posted),
%w(wiki_content_added),
%w(wiki_content_updated),
%w(membership_added),
%w(membership_updated)
].freeze
# Return all users who have a global setting configured
# Does not take into consideration local overrides, as
# that is currently not available for non-work-package settings
module Users::Scopes
module NotifiedGlobally
extend ActiveSupport::Concern
Notifiable = Struct.new(:name) do
def to_s
name
end
# TODO: Plugin API for adding a new notification?
def self.all
OpenProject::NOTIFIABLE.map do |event_strings|
Notifiable.new(*event_strings)
class_methods do
def notified_globally(setting)
where(
id: NotificationSetting
.where(setting => true)
.where(project: nil)
.select(:user_id)
)
end
end
end

@ -57,9 +57,7 @@ class AdminUserSeeder < Seeder
user.language = I18n.locale.to_s
user.status = User.statuses[:active]
user.force_password_change = force_password_change?
user.notification_settings.build(channel: :mail, involved: true, mentioned: true, watched: true)
user.notification_settings.build(channel: :in_app, involved: true, mentioned: true, watched: true)
user.notification_settings.build(channel: :mail_digest, involved: true, mentioned: true, watched: true)
user.notification_settings.build(involved: true, mentioned: true, watched: true)
end
end

@ -0,0 +1,43 @@
#-- 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 Attachments
class BaseService < ::BaseServices::Create
##
# Create an attachment service bypassing the user-provided whitelist
# for internal purposes such as exporting data.
#
# @param user The user to call the service with
# @param whitelist A custom whitelist to validate with, or empty to disable validation
#
# Warning: When passing an empty whitelist, this results in no validations on the content type taking place.
def self.bypass_whitelist(user:, whitelist: [])
new(user: user, contract_options: { whitelist: whitelist.map(&:to_s) })
end
end
end

@ -27,7 +27,7 @@
#++
module Attachments
class BuildService < ::BaseServices::Create
class BuildService < BaseService
private
def persist(service_result)

@ -26,74 +26,64 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Attachments::CreateService < ::BaseServices::Create
include Attachments::TouchContainer
module Attachments
class CreateService < BaseService
include TouchContainer
around_call :error_wrapped_call
around_call :error_wrapped_call
##
# Create an attachment service bypassing the user-provided whitelist
# for internal purposes such as exporting data.
#
# @param user The user to call the service with
# @param whitelist A custom whitelist to validate with, or empty to disable validation
#
# Warning: When passing an empty whitelist, this results in no validations on the content type taking place.
def self.bypass_whitelist(user:, whitelist: [])
new(user: user, contract_options: { whitelist: whitelist.map(&:to_s) })
end
def persist(call)
attachment = call.result
if attachment.container
in_container_mutex(attachment.container) { super }
else
super
def persist(call)
attachment = call.result
if attachment.container
in_container_mutex(attachment.container) { super }
else
super
end
end
end
def in_container_mutex(container)
OpenProject::Mutex.with_advisory_lock_transaction(container) do
yield.tap do
# Get the latest attachments to ensure having them all for journalization.
# We just created an attachment and a different worker might have added attachments
# in the meantime, e.g when bulk uploading.
container.attachments.reload
def in_container_mutex(container)
OpenProject::Mutex.with_advisory_lock_transaction(container) do
yield.tap do
# Get the latest attachments to ensure having them all for journalization.
# We just created an attachment and a different worker might have added attachments
# in the meantime, e.g when bulk uploading.
container.attachments.reload
end
end
end
end
def after_perform(call)
attachment = call.result
container = attachment.container
def after_perform(call)
attachment = call.result
container = attachment.container
touch(container) unless container.nil?
touch(container) unless container.nil?
OpenProject::Notifications.send(
OpenProject::Events::ATTACHMENT_CREATED,
attachment: attachment
)
OpenProject::Notifications.send(
OpenProject::Events::ATTACHMENT_CREATED,
attachment: attachment
)
call
end
call
end
def error_wrapped_call
yield
rescue StandardError => e
log_attachment_saving_error(e)
def error_wrapped_call
yield
rescue StandardError => e
log_attachment_saving_error(e)
message =
if e&.class&.to_s == 'Errno::EACCES'
I18n.t('api_v3.errors.unable_to_create_attachment_permissions')
else
I18n.t('api_v3.errors.unable_to_create_attachment')
end
raise message
end
message =
if e&.class&.to_s == 'Errno::EACCES'
I18n.t('api_v3.errors.unable_to_create_attachment_permissions')
else
I18n.t('api_v3.errors.unable_to_create_attachment')
end
raise message
end
def log_attachment_saving_error(error)
message = "Failed to save attachment: #{error&.class} - #{error&.message || 'Unknown error'}"
def log_attachment_saving_error(error)
message = "Failed to save attachment: #{error&.class} - #{error&.message || 'Unknown error'}"
OpenProject.logger.error message
OpenProject.logger.error message
end
end
end

@ -58,8 +58,8 @@ module BaseServices
def perform(params = nil)
service_context do
service_call = before_perform(params)
service_call = validate_params(params)
service_call = before_perform(params, service_call) if service_call.success?
service_call = validate_contract(service_call) if service_call.success?
service_call = after_validate(params, service_call) if service_call.success?
service_call = persist(service_call) if service_call.success?
@ -69,7 +69,11 @@ module BaseServices
end
end
def before_perform(_params)
def validate_params(_params)
ServiceResult.new(success: true, result: model)
end
def before_perform(*)
ServiceResult.new(success: true, result: model)
end

@ -49,7 +49,7 @@ module BaseServices
service_result
end
def before_perform(params)
def before_perform(params, _service_result)
set_attributes(params)
end

@ -1,3 +1,31 @@
#-- 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 COPYRIGHT and LICENSE files for more details.
#++
# Will create journals for a journable (e.g. WorkPackage and Meeting)
# As a journal is basically a copy of the current state of the database, consisting of the journable as well as its
# custom values and attachments, those entries are copied in the database.

@ -0,0 +1,31 @@
#-- 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 COPYRIGHT and LICENSE files for more details.
#++
module Journals
class SetAttributesService < ::BaseServices::SetAttributes; end
end

@ -0,0 +1,31 @@
#-- 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 COPYRIGHT and LICENSE files for more details.
#++
module Journals
class UpdateService < ::BaseServices::Update; end
end

@ -48,8 +48,8 @@ class Notifications::CreateFromModelService
return result if result.failure?
notification_receivers.each do |recipient_id, channel_reasons|
call = create_notification(recipient_id, channel_reasons)
notification_receivers.each do |recipient_id, reasons|
call = create_notification(recipient_id, reasons)
result.add_dependent!(call)
end
@ -60,47 +60,24 @@ class Notifications::CreateFromModelService
attr_accessor :model
def create_notification(recipient_id, channel_reasons)
def create_notification(recipient_id, reasons)
notification_attributes = {
recipient_id: recipient_id,
project: project,
resource: resource,
journal: journal,
actor: user_with_fallback
}.merge(channel_attributes(channel_reasons))
actor: user_with_fallback,
reason: reasons.first,
read_ian: strategy.supports_ian? ? false : nil,
mail_reminder_sent: strategy.supports_mail_digest? ? false : nil,
mail_alert_sent: strategy.supports_mail? ? false : nil
}
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
@ -118,65 +95,66 @@ class Notifications::CreateFromModelService
end
def settings_of_mentioned
applicable_settings(mentioned_ids,
project,
:mentioned)
project_applicable_settings(mentioned_ids,
project,
NotificationSetting::MENTIONED)
end
def settings_of_assigned
applicable_settings(User.where(id: group_or_user_ids(journal.data.assigned_to)),
project,
:involved)
project_applicable_settings(User.where(id: group_or_user_ids(journal.data.assigned_to)),
project,
NotificationSetting::INVOLVED)
end
def settings_of_responsible
applicable_settings(User.where(id: group_or_user_ids(journal.data.responsible)),
project,
:involved)
project_applicable_settings(User.where(id: group_or_user_ids(journal.data.responsible)),
project,
NotificationSetting::INVOLVED)
end
def settings_of_subscribed
applicable_settings(strategy.subscribed_users(model),
project,
:all)
# Subscribed is a collection of events for non-work packages
# which currently ignore project-specific overrides
settings_for_allowed_users(strategy.subscribed_users(model),
strategy.subscribed_notification_reason(model))
end
def settings_of_watched
applicable_settings(strategy.watcher_users(model),
project,
:watched)
project_applicable_settings(strategy.watcher_users(model),
project,
NotificationSetting::WATCHED)
end
def settings_of_commented
return NotificationSetting.none unless journal.notes?
applicable_settings(User.all,
project,
:work_package_commented)
project_applicable_settings(User.all,
project,
NotificationSetting::WORK_PACKAGE_COMMENTED)
end
def settings_of_created
return NotificationSetting.none unless journal.initial?
applicable_settings(User.all,
project,
:work_package_created)
project_applicable_settings(User.all,
project,
NotificationSetting::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)
project_applicable_settings(User.all,
project,
NotificationSetting::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)
project_applicable_settings(User.all,
project,
NotificationSetting::WORK_PACKAGE_PRIORITIZED)
end
def settings_of_scheduled
@ -184,14 +162,18 @@ class Notifications::CreateFromModelService
return NotificationSetting.none
end
applicable_settings(User.all,
project,
:work_package_scheduled)
project_applicable_settings(User.all,
project,
NotificationSetting::WORK_PACKAGE_SCHEDULED)
end
def applicable_settings(user_scope, project, reason)
NotificationSetting
def project_applicable_settings(user_scope, project, reason)
settings_for_allowed_users(user_scope, reason)
.applicable(project)
end
def settings_for_allowed_users(user_scope, reason)
NotificationSetting
.where(reason => true)
.where(user: user_scope.where(id: User.allowed(strategy.permission, project)))
end
@ -269,7 +251,7 @@ class Notifications::CreateFromModelService
def add_receiver(receivers, collection, reason)
collection.each do |notification|
receivers[notification.user_id][notification.channel] << reason
receivers[notification.user_id] << reason
end
end
@ -279,9 +261,7 @@ class Notifications::CreateFromModelService
def receivers_hash
Hash.new do |hash, user|
hash[user] = Hash.new do |channel_hash, channel|
channel_hash[channel] = []
end
hash[user] = []
end
end

@ -50,7 +50,11 @@ module Notifications::CreateFromModelService::CommentStrategy
end
def self.subscribed_users(comment)
User.notified_on_all(project(comment))
User.notified_globally subscribed_notification_reason(comment)
end
def self.subscribed_notification_reason(_comment)
NotificationSetting::NEWS_COMMENTED
end
def self.watcher_users(comment)

@ -50,7 +50,11 @@ module Notifications::CreateFromModelService::MessageStrategy
end
def self.subscribed_users(journal)
User.notified_on_all(journal.data.project)
User.notified_globally subscribed_notification_reason(journal)
end
def self.subscribed_notification_reason(_journal)
NotificationSetting::FORUM_MESSAGES
end
def self.watcher_users(journal)

@ -51,13 +51,17 @@ module Notifications::CreateFromModelService::NewsStrategy
def self.subscribed_users(journal)
if journal.initial?
User.notified_on_all(journal.data.project)
User.notified_globally subscribed_notification_reason(journal)
else
# No notification on updating a news
User.none
end
end
def self.subscribed_notification_reason(_journal)
NotificationSetting::NEWS_ADDED
end
def self.project(journal)
journal.data.project
end

@ -50,7 +50,15 @@ module Notifications::CreateFromModelService::WikiContentStrategy
end
def self.subscribed_users(journal)
User.notified_on_all(journal.data.project)
User.notified_globally subscribed_notification_reason(journal)
end
def self.subscribed_notification_reason(journal)
if journal.initial?
NotificationSetting::WIKI_PAGE_ADDED
else
NotificationSetting::WIKI_PAGE_UPDATED
end
end
def self.watcher_users(journal)

@ -30,7 +30,7 @@
module Notifications::CreateFromModelService::WorkPackageStrategy
def self.reasons
%i(mentioned assigned responsible watched subscribed commented created processed prioritized scheduled)
%i(mentioned assigned responsible watched commented created processed prioritized scheduled)
end
def self.permission
@ -49,10 +49,6 @@ module Notifications::CreateFromModelService::WorkPackageStrategy
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

@ -29,4 +29,13 @@
#++
class Notifications::CreateService < ::BaseServices::Create
protected
def persist(service_result)
super
rescue ActiveRecord::InvalidForeignKey
service_result.success = false
service_result.errors.add(:journal_id, :does_not_exist)
service_result
end
end

@ -26,45 +26,55 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Notifications::MailService
def initialize(notification)
self.notification = notification
end
module Notifications
class MailService
include WithMarkedNotifications
def call
return unless supported?
return if ian_read?
def initialize(notification)
self.notification = notification
end
strategy.send_mail(notification)
end
def call
return unless supported?
return if ian_read?
private
with_marked_notifications(notification.id) do
strategy.send_mail(notification)
end
end
attr_accessor :notification
private
def ian_read?
notification.read_ian
end
attr_accessor :notification
def strategy
@strategy ||= if self.class.const_defined?("#{strategy_model}Strategy")
"#{self.class}::#{strategy_model}Strategy".constantize
end
end
def ian_read?
notification.read_ian
end
def strategy_model
journal&.journable_type || resource&.class
end
def strategy
@strategy ||= if self.class.const_defined?("#{strategy_model}Strategy")
"#{self.class}::#{strategy_model}Strategy".constantize
end
end
def journal
notification.journal
end
def strategy_model
journal&.journable_type || resource&.class
end
def resource
notification.resource
end
def journal
notification.journal
end
def resource
notification.resource
end
def supported?
strategy.present?
end
def supported?
strategy.present?
def notification_marked_attribute
:mail_alert_sent
end
end
end

@ -29,21 +29,12 @@
module Notifications::MailService::CommentStrategy
class << self
def send_mail(notification)
return if notification_disabled?
UserMailer
.news_comment_added(
notification.recipient,
notification.resource,
notification.resource.author || DeletedUser.first
notification.resource
)
.deliver_later
end
private
def notification_disabled?
Setting.notified_events.exclude?('news_comment_added')
.deliver_now
end
end
end

@ -29,21 +29,12 @@
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
notification.resource
)
.deliver_later
end
private
def notification_disabled?
Setting.notified_events.exclude?('message_posted')
.deliver_now
end
end
end

@ -29,21 +29,14 @@
module Notifications::MailService::NewsStrategy
class << self
def send_mail(notification)
return if notification_disabled? || !notification.journal.initial?
return unless notification.journal.initial?
UserMailer
.news_added(
notification.recipient,
notification.journal.journable,
notification.journal.user || DeletedUser.first
notification.journal.journable
)
.deliver_later
end
private
def notification_disabled?
Setting.notified_events.exclude?('news_added')
.deliver_now
end
end
end

@ -31,14 +31,11 @@ module Notifications::MailService::WikiContentStrategy
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
notification.journal.journable)
.deliver_now
end
private
@ -50,9 +47,5 @@ module Notifications::MailService::WikiContentStrategy
:wiki_content_updated
end
end
def notification_disabled?(name)
Setting.notified_events.exclude?(name)
end
end
end

@ -0,0 +1,40 @@
#-- 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 COPYRIGHT and LICENSE files for more details.
#++
module Notifications::MailService::WorkPackageStrategy
class << self
def send_mail(notification)
return unless notification.reason_mentioned?
return unless notification.recipient.pref.immediate_reminders[:mentioned]
WorkPackageMailer
.mentioned(notification.recipient, notification.journal)
.deliver_later
end
end
end

@ -40,7 +40,7 @@ module Projects
private
def before_perform(params)
def before_perform(params, _service_result)
model.enabled_module_names = params[:enabled_modules]
super
end

@ -41,7 +41,7 @@ module Projects
private
def before_perform(_params)
def before_perform(_params, _service_result)
Projects::ArchiveService
.new(user: user, model: model)
.call

@ -34,7 +34,15 @@ module UserPreferences
attr_accessor :notifications
def before_perform(params)
def validate_params(params)
contract = ParamsContract.new(model, user, params: params)
ServiceResult.new success: contract.valid?,
errors: contract.errors,
result: model
end
def before_perform(params, _service_result)
self.notifications = params&.delete(:notification_settings)
super
@ -51,11 +59,11 @@ module UserPreferences
def persist_notifications
global, project = notifications
.map { |item| item.merge(user_id: model.user_id) }
.partition { |setting| setting[:project_id].nil? }
.map { |item| item.merge(user_id: model.user_id) }
.partition { |setting| setting[:project_id].nil? }
global_ids = upsert_notifications(global, %i[user_id channel], 'project_id IS NULL')
project_ids = upsert_notifications(project, %i[user_id channel project_id], 'project_id IS NOT NULL')
global_ids = upsert_notifications(global, %i[user_id], 'project_id IS NULL')
project_ids = upsert_notifications(project, %i[user_id project_id], 'project_id IS NOT NULL')
global_ids + project_ids
end
@ -91,7 +99,14 @@ module UserPreferences
work_package_processed
work_package_prioritized
work_package_scheduled
all]
news_added
news_commented
document_added
forum_messages
wiki_page_added
wiki_page_updated
membership_added
membership_updated]
},
validate: false
).ids

@ -54,9 +54,7 @@ module Users
end
def initialize_notification_settings
NotificationSetting.channels.each_key do |channel|
model.notification_settings.build(channel: channel, involved: true, mentioned: true, watched: true)
end
model.notification_settings.build(involved: true, mentioned: true, watched: true)
end
end
end

@ -34,12 +34,12 @@ module Users
protected
def before_perform(params)
def before_perform(params, _service_result)
call_hook :service_update_user_before_save,
params: params,
user: model
super(params)
super
end
def persist(service_result)

@ -37,14 +37,6 @@ See COPYRIGHT and LICENSE files for more details.
<div class="form--field"><%= setting_text_field :mail_from, size: 60, container_class: '-middle' %></div>
<div class="form--field"><%= setting_check_box :bcc_recipients %></div>
<div class="form--field"><%= setting_check_box :plain_text_mail %></div>
<div class="form--field">
<%= setting_time_field :notification_email_digest_time,
container_class: '-xslim',
unit: 'UTC' %>
<div class="form--field-instructions">
<%= t(:'settings.notifications.email_digest_explanation') %>
</div>
</div>
</section>
<fieldset id="emails_decorators" class="form--fieldset"><legend class="form--fieldset-legend"><%= t(:setting_emails_header) %> & <%= t(:setting_emails_footer) %></legend>

@ -50,27 +50,5 @@ See COPYRIGHT and LICENSE files for more details.
</span>
</div>
<fieldset id="notified_events" class="form--fieldset">
<legend class="form--fieldset-legend">
<%= t(:text_select_notifications) %>
</legend>
<div class="form--toolbar">
<span class="form--toolbar-item">
(<%= check_all_links 'notified_events' %>)
</span>
</div>
<%= hidden_field_tag 'settings[notified_events][]', '' %>
<div class="form--field">
<div class="form--field-container -vertical">
<% @notifiables.each do |notifiable| %>
<%= notification_field notifiable %>
<% end %>
</div>
<span class="form--field-instructions -no-margin">
<%= t(:'settings.notifications.events_explanation') %>
</span>
</div>
</fieldset>
<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<% end %>

@ -1,64 +1,98 @@
<%= render partial: 'mailer/notification_mailer_header',
locals: {
summary: "#{I18n.t(:'mail.digests.you_have')} #{digest_summary_text(@notification_ids.length, @mentioned_count)}"
} %>
<table <%= placeholder_table_styles %>>
<tr>
<%= placeholder_cell('20px', vertical: false) %>
</tr>
</table>
<% @aggregated_notifications.first(DigestMailer::MAX_SHOWN_WORK_PACKAGES).each do | work_package, notifications_by_work_package| %>
<%= render layout: 'mailer/notification_row',
locals: {
work_package: work_package,
notifications_by_work_package: notifications_by_work_package
} do %>
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="font-size: 12px;">
<% notifications_by_work_package.each do | notification | %>
<% if notification.journal.notes.present? %>
<tr style="color: #878787; line-height: 20px; font-size: 14px;">
<td>
<%= digest_comment_text(notification) %>
<%= digest_notification_timestamp_text(notification) %>
</td>
</tr>
<% end %>
<% notification.journal.details.each do |detail| %>
<tr style="color: #878787; line-height: 20px; font-size: 14px;">
<td>
<%= notification.journal.render_detail(detail, only_path: false) %>
<%= digest_notification_timestamp_text(notification) %>
</td>
</tr>
<table <%= placeholder_table_styles(width:'100%',style: 'width:100%;max-width:700px;margin-left:auto;margin-right:auto') %>>
<tr>
<td>
<%= render partial: 'mailer/notification_mailer_header',
locals: {
summary: "#{I18n.t(:'mail.digests.you_have')} #{digest_summary_text(@notification_ids.length, @mentioned_count)}"
} %>
<% @aggregated_notifications.first(DigestMailer::MAX_SHOWN_WORK_PACKAGES).each do | work_package, notifications_by_work_package| %>
<%= render layout: 'mailer/notification_row',
locals: {
work_package: work_package,
unique_reasons: unique_reasons_of_notifications(notifications_by_work_package),
show_count: true,
count: notifications_by_work_package.length
} do %>
<table <%= placeholder_table_styles %>>
<% notifications_by_work_package.each do | notification | %>
<% if notification.journal.notes.present? %>
<tr>
<td style="color: #878787; line-height: 24px; font-size: 14px; white-space: normal;">
<%= digest_comment_text(notification) %>
<%= digest_notification_timestamp_text(notification) %>
</td>
</tr>
<% end %>
<% notification.journal.details.each do |detail| %>
<tr>
<td style="color: #878787; line-height: 24px; font-size: 14px; white-space: normal;">
<%= notification.journal.render_detail(detail, only_path: false) %>
<%= digest_notification_timestamp_text(notification) %>
</td>
</tr>
<% end %>
<% end %>
</table>
<% end %>
<% end %>
</table>
<% end %>
<% end %>
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin: 30px 0;">
<tr>
<td width="100%">
<% if @aggregated_notifications.length > DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<span style="font-size: 14px; line-height: 28px">
<% number_of_overflowing_work_packages = @aggregated_notifications.length - DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<% if number_of_overflowing_work_packages === 1 %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_singular') %>
<% else %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_plural', number: number_of_overflowing_work_packages) %>
<% end %>
</span>
<a
target="_blank"
style="background-color: #D1E5F5;
padding: 8px 12px;
color: #1A67A3;
border: 1px solid #1A67A3;
border-radius: 16px;
text-decoration: none;
white-space: nowrap;">
<%= I18n.t(:'mail.digests.work_packages.see_all') %>
</a>
<% end %>
</td>
<td>
<%= render partial: 'mailer/notification_settings_button' %>
<table <%= placeholder_table_styles(style: 'font-size:14px;') %>>
<tr>
<td>
<table <%= placeholder_table_styles(width:'100%',style: 'width:100%;') %>>
<tr>
<td>
<% if @aggregated_notifications.length > DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<table>
<tr>
<td>
<span style="font-size:14px;">
<% number_of_overflowing_work_packages = @aggregated_notifications.length - DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<% count = number_of_overflowing_work_packages === 1 ? 'one' : 'other' %>
<%= I18n.t(:"mail.work_packages.more_to_see.#{count}", count: number_of_overflowing_work_packages) %>
</span>
</td>
<%= placeholder_cell('10px', vertical: true) %>
<td>
<a href="<%= notifications_center_url %>"
target="_blank"
style="background-color: #D1E5F5;
padding: 8px 12px;
color: #1A67A3;
border: 1px solid #1A67A3;
border-radius: 16px;
text-decoration: none;
font-size:14px;
white-space: nowrap;">
<%= I18n.t(:'mail.work_packages.see_all') %>
</a>
</td>
</tr>
</table>
<% end %>
</td>
</tr>
</table>
</td>
<%= placeholder_cell('10px', vertical: true) %>
<td>
<%= render partial: 'mailer/notification_settings_button' %>
</td>
</tr>
</table>
<table>
<tr>
<%= placeholder_cell('40px', vertical: false) %>
</tr>
</table>
</td>
</tr>
</table>

@ -15,7 +15,7 @@
<%= digest_notification_timestamp_text(
notification,
html: false,
extended_text: true) %> (<% unique_reasons.each_with_index do |reason, index| %><%= I18n.t(:"mail.digests.work_packages.reason.#{reason || :unknown}", default: '-') %><%= ', ' unless unique_reasons.size-1 == index %><% end %>)
extended_text: true) %> (<% unique_reasons.each_with_index do |reason, index| %><%= I18n.t(:"mail.work_packages.reason.#{reason || :unknown}", default: '-') %><%= ', ' unless unique_reasons.size-1 == index %><% end %>)
<% journal = notification.journal %>
<% if journal.notes.present? %>
@ -35,9 +35,6 @@
<% if @aggregated_notifications.length > DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<% number_of_overflowing_work_packages = @aggregated_notifications.length - DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<% if number_of_overflowing_work_packages === 1 %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_singular') %> <%= I18n.t(:'mail.digests.work_packages.login_to_see_all') %>
<% else %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_plural', number: number_of_overflowing_work_packages) %> <%= I18n.t(:'mail.digests.work_packages.login_to_see_all') %>
<% end %>
<% count = number_of_overflowing_work_packages === 1 ? 'one' : 'other' %>
<%= I18n.t(:"mail.work_packages.more_to_see.#{count}", count: number_of_overflowing_work_packages) %>
<% end %>

@ -5,10 +5,13 @@
data-domain="<%= @current_token.try(:domain) %>"
data-user-count="<%= @current_token.restrictions.nil? ? t('js.admin.enterprise.upsale.unlimited') : @current_token.restrictions[:active_user_count] %>"
data-starts-at="<%= format_date @current_token.starts_at %>"
data-expires-at="<%= (!@current_token.will_expire?) ? t('js.admin.enterprise.upsale.unlimited') : (format_date @current_token.expires_at) %>">
data-expires-at="<%= (!@current_token.will_expire?) ? t('js.admin.enterprise.upsale.unlimited') : (format_date @current_token.expires_at) %>"
data-is-expired="<%= @current_token.expired?(reprieve: false) %>"
data-reprieve-days-left="<%= @current_token.reprieve_days_left %>"
>
</enterprise-active-saved-trial>
<%= form_tag({}, method: :delete) do %>
<%= form_tag(enterprise_path, method: :delete) do %>
<confirm-form-submit></confirm-form-submit>
<%= styled_button_tag t(:button_delete),
method: :delete,

@ -68,22 +68,48 @@ See COPYRIGHT and LICENSE files for more details.
margin-left: 2.4em;
padding-left: 0.6em;
}
pre {
white-space: normal;
}
img {
width: 100%;
}
.footer {
font-size: 0.8em;
font-style: italic;
max-width: 700px;
margin-left: auto;
margin-right: auto;
border-top-width: 1px;
border-top-color: #ccc;
border-top-style: solid;
}
a.user-mention:before {
content: '@';
color: #878787;
}
</style>
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="initial-scale=1.0">
</head>
<body>
<span class="header"><%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_header) %></span>
<body style="padding: 0">
<table class="header">
<tr>
<td>
<%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_header) %>
</td>
</tr>
</table>
<%= call_hook(:view_layouts_mailer_html_before_content, self.assigns) %>
<%= yield %>
<%= call_hook(:view_layouts_mailer_html_after_content, self.assigns) %>
<hr />
<span class="footer"><%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_footer) %></span>
<table class="footer">
<tr>
<td>
<%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_footer) %>
</td>
</tr>
</table>
</body>
</html>

@ -1,30 +1,57 @@
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-bottom: 1px solid #cccccc; margin-bottom: 32px;">
<table <%= placeholder_table_styles(width:'100%',style: "width:100%;min-width:100%") %>>
<tr>
<td width="100%" style="padding-top: 8px; padding-left: 12px;">
<table>
<td>
<table <%= placeholder_table_styles %>>
<tr>
<td style="font-size: 24px; color: #333333; padding-bottom: 5px;">
<%= I18n.t(:'mail.salutation', user: @user.firstname) %>
<%= placeholder_cell('12px', vertical: true) %>
<td>
<table <%= placeholder_table_styles(width:'100%',style: "width:100%") %>>
<tr>
<td>
<span style="font-size: 24px; color: #333333;">
<%= I18n.t(:'mail.salutation', user: @user.firstname) %>
</span>
</td>
</tr>
<tr>
<%= placeholder_cell('8px', vertical: false) %>
</tr>
<tr>
<td>
<span style="font-size:16px; color: #1A67A3; font-weight: bold;">
<%= summary %>
</span>
</td>
</tr>
<tr>
<%= placeholder_cell('24px', vertical: false) %>
</tr>
<tr>
<td>
<a href="<%= notifications_center_url %>"
target="_blank"
style="background: #D1E5F5; padding: 8px 12px; color: #1A67A3; border: 1px solid #1A67A3; border-radius: 16px; text-decoration: none;font-size: 14px;">
<%= I18n.t(:'mail.notification.center') %>
</a>
</td>
</tr>
<tr>
<%= placeholder_cell('40px', vertical: false) %>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-size:16px; color: #1A67A3; font-weight: bold; padding-bottom: 10px;">
<%= summary %>
</td>
</tr>
<tr>
<td style="padding: 10px 0 32px 0;">
<a href="<%= notifications_center_url %>"
target="_blank"
style="background: #D1E5F5; padding: 8px 12px; color: #1A67A3; border: 1px solid #1A67A3; border-radius: 16px; text-decoration: none;">
<%= I18n.t(:'mail.notification.center') %>
</a>
<%= placeholder_cell('16px', vertical: true) %>
<td style="vertical-align: top;">
<table <%= placeholder_table_styles %>>
<tr>
<td style="width: 96px; height: 96px;">
<%= logo_tag({ alt: "#{Setting.app_title} #{I18n.t(:'mail.logo_alt_text')}", style: "height: 96px;max-width: 240px;"}) %>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td style="width: 96px; height: 96px; vertical-align: top;">
<%= logo_tag({ alt: "#{Setting.app_title} #{I18n.t(:'mail.logo_alt_text')}", style: "height: 96px;"}) %>
</td>
</tr>
</table>
</table>

@ -1,57 +1,107 @@
<a style="border: 1px solid #E0E0E0;
margin-bottom: 16px;
padding: 12px 12px 16px 12px;
border-radius: 10px;
text-decoration: none;
display: block;"
<a style="text-decoration: none;display: block;"
href="<%= notifications_path(work_package.id) %>"
target="_blank">
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin-bottom: 9px; font-size: 14px;">
<table <%= placeholder_table_styles(width:'100%',style: "width:100%;border-width:1px;border-color:#E0E0E0;border-style:solid;border-radius:10px") %>>
<tr>
<td >
<div style="color: #333333;
background-color: #FFFFFF;
<%= status_colors(work_package.status) %>
white-space: nowrap;
padding: 2px 12px;
height: 16px;">
<%= work_package.status %>
</div>
</td>
<td width="100%" style="padding-left: 8px;
color: #878787;">
#<%= work_package.id %> - <%= work_package.project %>
<% unique_reasons = unique_reasons_of_notifications(notifications_by_work_package) %>
<%= ' - ' unless unique_reasons.length === 1 && unique_reasons.first.nil? %>
<% unique_reasons.each_with_index do |reason, index| %>
<%= I18n.t(
:"mail.digests.work_packages.reason.#{reason || :unknown}",
default: '') %><%= ', ' unless unique_reasons.size-1 == index %>
<% end %>
</td>
<td style="text-align: right;">
<span style="background-color: #00A3FF;
color: white;
border-radius: 8px;
padding: 0px 8px;
font-size: 14px;">
<%= notifications_by_work_package.length %>
</span>
</td>
<%= placeholder_cell('12px', vertical: false) %>
</tr>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin-bottom: 9px; font-size: 16px; font-weight: bold;">
<tr>
<td style="color: <%= type_color(work_package.type, '#333333') %>;
white-space: nowrap;">
<%= work_package.type.to_s.upcase %>
</td>
<td width="100%" style="padding-left: 5px; color: #333333;">
<%= work_package.subject %>
<td>
<table <%= placeholder_table_styles(width:'100%',style: 'width:100%;font-size:14px;') %>>
<tr>
<%= placeholder_cell('12px', vertical: true) %>
<td>
<table <%= placeholder_table_styles %>>
<tr>
<td>
<table <%= placeholder_table_styles %>>
<tr>
<td style="color: #333333;
background-color: #FFFFFF;
<%= status_colors(work_package.status) %>
white-space: nowrap;
padding: 2px 12px;
height: 18px;">
<%= work_package.status %>
</td>
</tr>
</table>
</td>
<%= placeholder_cell('8px', vertical: true) %>
<td width="100%" style="color: #878787;">
#<%= work_package.id %> - <%= work_package.project %>
<%= ' - ' unless unique_reasons.length === 1 && unique_reasons.first.nil? %>
<% unique_reasons.each_with_index do |reason, index| %>
<%= I18n.t(
:"mail.work_packages.reason.#{reason || :unknown}",
default: '') %><%= ', ' unless unique_reasons.size-1 == index %>
<% end %>
</td>
<td>
<table <%= placeholder_table_styles %>>
<tr>
<% if show_count %>
<td style="background-color: #00A3FF;
color: white;
border-radius: 10px;
padding: 2px 8px;
font-size: 14px;
height: 18px;
line-height: 18px;">
<%= count %>
</td>
<% end %>
</tr>
</table>
</td>
</tr>
</table>
</td>
<%= placeholder_cell('12px', vertical: true) %>
</tr>
<tr>
<%= placeholder_cell('12px', vertical: true) %>
<%= placeholder_cell('16px', vertical: false) %>
<%= placeholder_cell('12px', vertical: true) %>
</tr>
<tr>
<%= placeholder_cell('12px', vertical: true) %>
<td>
<table <%= placeholder_table_styles(style: 'font-size:16px;font-weight:bold') %>>
<tr>
<td style="color: <%= type_color(work_package.type, '#333333') %>;white-space: nowrap;">
<%= work_package.type.to_s.upcase %>
</td>
<%= placeholder_cell('4px', vertical: true) %>
<td width="100%" style="color: #333333;">
<%= work_package.subject %>
</td>
</tr>
</table>
</td>
<%= placeholder_cell('12px', vertical: true) %>
</tr>
<tr>
<%= placeholder_cell('12px', vertical: true) %>
<%= placeholder_cell('12px', vertical: false) %>
<%= placeholder_cell('12px', vertical: true) %>
</tr>
<tr>
<%= placeholder_cell('12px', vertical: true) %>
<td><%= yield %></td>
<%= placeholder_cell('12px', vertical: true) %>
</tr>
</table>
</td>
</tr>
<tr>
<%= placeholder_cell('12px', vertical: false) %>
</tr>
</table>
<%= yield %>
<table>
<tr>
<%= placeholder_cell('20px', vertical: false) %>
</tr>
</table>
</a>

@ -63,10 +63,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= link_to highlight_tokens(truncate(e.event_title, escape: false, length: 255), @tokens), with_notes_anchor(e, @tokens) %>
</dt>
<dd>
<span class="description"><%= highlight_first([last_journal(e).try(:notes),
attachment_fulltexts(e),
attachment_filenames(e),
e.event_description], @tokens) %></span>
<span class="description"><%= highlight_tokens_in_event(e, @tokens) %></span>
<span class="author"><%= format_time(e.event_datetime) %></span>
</dd>
<% end %>

@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<h1>
<%= @message.forum.project.name %> - <%= @message.forum.name %>: <%= link_to(@message.subject, @message_url) %>
<%= @message.forum.project.name %> - <%= @message.forum.name %>: <%= link_to(@message.subject, message_url(@message)) %>
</h1>
<em><%= @message.author %></em>

@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%= @message_url %>
<%= message_url(@message) %>
<%= @message.author %>
<%= @message.content %>

@ -65,11 +65,9 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
</li>
<% end %>
<% if Setting.notified_events.include?("wiki_content_added") or Setting.notified_events.include?("wiki_content_updated") %>
<li class="toolbar-item hidden-for-mobile">
<%= watcher_link(@page, User.current) %>
</li>
<% end %>
<li class="toolbar-item hidden-for-mobile">
<%= watcher_link(@page, User.current) %>
</li>
<% if @content.version != @page.content.version %>
<li class="toolbar-item hidden-for-mobile">
<%= link_to(t(:label_history), {action: 'history', id: @page}, class: 'button icon-wiki') %>

@ -27,27 +27,27 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<h1><%= link_to issue.to_s, work_package_url(issue) %></h1>
<h1><%= link_to work_package.to_s, work_package_url(work_package) %></h1>
<ul>
<li><%= WorkPackage.human_attribute_name(:author) %>: <%= issue.author %></li>
<li><%= WorkPackage.human_attribute_name(:status) %>: <%= issue.status %></li>
<li><%= WorkPackage.human_attribute_name(:priority) %>: <%= issue.priority %></li>
<li><%= WorkPackage.human_attribute_name(:assigned_to) %>: <%= issue.assigned_to %></li>
<li><%= WorkPackage.human_attribute_name(:responsible) %>: <%= issue.responsible %></li>
<li><%= WorkPackage.human_attribute_name(:category) %>: <%= issue.category %></li>
<li><%= WorkPackage.human_attribute_name(:version) %>: <%= issue.version %></li>
<li><%= WorkPackage.human_attribute_name(:start_date) %>: <%= issue.start_date %></li>
<li><%= WorkPackage.human_attribute_name(:due_date) %>: <%= issue.due_date %></li>
<li><%= WorkPackage.human_attribute_name(:estimated_hours)%>: <%= issue.estimated_hours %></li>
<li><%= WorkPackage.human_attribute_name(:author) %>: <%= work_package.author %></li>
<li><%= WorkPackage.human_attribute_name(:status) %>: <%= work_package.status %></li>
<li><%= WorkPackage.human_attribute_name(:priority) %>: <%= work_package.priority %></li>
<li><%= WorkPackage.human_attribute_name(:assigned_to) %>: <%= work_package.assigned_to %></li>
<li><%= WorkPackage.human_attribute_name(:responsible) %>: <%= work_package.responsible %></li>
<li><%= WorkPackage.human_attribute_name(:category) %>: <%= work_package.category %></li>
<li><%= WorkPackage.human_attribute_name(:version) %>: <%= work_package.version %></li>
<li><%= WorkPackage.human_attribute_name(:start_date) %>: <%= work_package.start_date %></li>
<li><%= WorkPackage.human_attribute_name(:due_date) %>: <%= work_package.due_date %></li>
<li><%= WorkPackage.human_attribute_name(:estimated_hours)%>: <%= work_package.estimated_hours %></li>
<% if Setting.work_package_done_ratio != 'disabled' %>
<li><%= WorkPackage.human_attribute_name(:done_ratio) %>: <%= issue.done_ratio %></li>
<li><%= WorkPackage.human_attribute_name(:done_ratio) %>: <%= work_package.done_ratio %></li>
<% end %>
<% issue.custom_field_values.each do |value| %>
<% work_package.custom_field_values.each do |value| %>
<li><%= value.custom_field.name %>: <%= show_value(value) %></li>
<% end %>
</ul>
<%= format_text(issue.description, attribute: :description, only_path: false, object: issue, project: issue.project) %>
<%= format_text(work_package.description, attribute: :description, only_path: false, object: work_package, project: work_package.project) %>

@ -27,24 +27,24 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%= issue.to_s %>
<%= work_package_url(issue) %>
<%= WorkPackage.human_attribute_name(:author) %>: <%= issue.author %>
<%= WorkPackage.human_attribute_name(:status) %>: <%= issue.status %>
<%= WorkPackage.human_attribute_name(:priority) %>: <%= issue.priority %>
<%= WorkPackage.human_attribute_name(:assigned_to) %>: <%= issue.assigned_to %>
<%= WorkPackage.human_attribute_name(:responsible) %>: <%= issue.responsible %>
<%= WorkPackage.human_attribute_name(:category) %>: <%= issue.category %>
<%= WorkPackage.human_attribute_name(:version) %>: <%= issue.version %>
<%= WorkPackage.human_attribute_name(:start_date) %>: <%= issue.start_date %>
<%= WorkPackage.human_attribute_name(:due_date) %>: <%= issue.due_date %>
<%= WorkPackage.human_attribute_name(:estimated_hours)%>: <%= issue.estimated_hours %>
<%= work_package.to_s %>
<%= work_package_url(work_package) %>
<%= WorkPackage.human_attribute_name(:author) %>: <%= work_package.author %>
<%= WorkPackage.human_attribute_name(:status) %>: <%= work_package.status %>
<%= WorkPackage.human_attribute_name(:priority) %>: <%= work_package.priority %>
<%= WorkPackage.human_attribute_name(:assigned_to) %>: <%= work_package.assigned_to %>
<%= WorkPackage.human_attribute_name(:responsible) %>: <%= work_package.responsible %>
<%= WorkPackage.human_attribute_name(:category) %>: <%= work_package.category %>
<%= WorkPackage.human_attribute_name(:version) %>: <%= work_package.version %>
<%= WorkPackage.human_attribute_name(:start_date) %>: <%= work_package.start_date %>
<%= WorkPackage.human_attribute_name(:due_date) %>: <%= work_package.due_date %>
<%= WorkPackage.human_attribute_name(:estimated_hours)%>: <%= work_package.estimated_hours %>
<% if Setting.work_package_done_ratio != 'disabled' %>
<%= WorkPackage.human_attribute_name(:done_ratio) %>: <%= issue.done_ratio %>
<%= WorkPackage.human_attribute_name(:done_ratio) %>: <%= work_package.done_ratio %>
<% end %>
<% issue.custom_field_values.each do |value| %>
<% work_package.custom_field_values.each do |value| %>
<%= value.custom_field.name %>: <%= show_value(value) %>
<% end %>
<%= issue.description %>
<%= work_package.description %>

@ -0,0 +1,55 @@
<table <%= placeholder_table_styles %>>
<tr>
<%= placeholder_cell('20px', vertical: false) %>
</tr>
</table>
<table <%= placeholder_table_styles(width:'100%',style: 'width:100%;max-width:700px;margin-left:auto;margin-right:auto') %>>
<tr>
<td>
<%= render partial: 'mailer/notification_mailer_header',
locals: {
summary: "#{I18n.t(:'mail.work_packages.mentioned_by', user: @journal.user)}"
} %>
<%= render layout: 'mailer/notification_row',
locals: {
work_package: @work_package,
unique_reasons: [:mentioned],
show_count: false
} do %>
<table <%= placeholder_table_styles(width:'100%',style: 'width:100%;') %>>
<tr>
<td style="color: #878787; line-height: 24px; font-size: 14px; white-space: normal; overflow: hidden; max-width: 100%; width: 100%;">
<%= format_text @journal.notes, only_path: false %>
</td>
</tr>
</table>
<% end %>
<table <%= placeholder_table_styles(style: 'font-size:14px;') %>>
<tr>
<td>
<table <%= placeholder_table_styles(width:'100%',style: 'width:100%;') %>>
<tr>
<td>
</td>
</tr>
</table>
</td>
<%= placeholder_cell('10px', vertical: true) %>
<td>
<%= render partial: 'mailer/notification_settings_button' %>
</td>
</tr>
</table>
<table>
<tr>
<%= placeholder_cell('40px', vertical: false) %>
</tr>
</table>
</td>
</tr>
</table>

@ -0,0 +1,13 @@
<%= I18n.t(:'mail.salutation', user: @user.firstname) %>
<%= "#{I18n.t(:'mail.work_packages.mentioned_by', user: @journal.user)}" %>
<%= "-" * 100 %>
<%= "=" * (('# ' + @work_package.id.to_s + @work_package.subject).length + 4) %>
= #<%= @work_package.id %> <%= @work_package.subject %> =
<%= "=" * (('# ' + @work_package.id.to_s + @work_package.subject).length + 4) %>
<%= I18n.t(:label_comment_added) %>:
<%= strip_tags @journal.notes %>
<%= "-" * 100 %>

@ -26,12 +26,12 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<%= t("text_work_package_watcher_#{@action}", id: "##{@issue.id}", watcher_changer: @watcher_changer) %>
<%= t("text_work_package_watcher_#{@action}", id: "##{@work_package.id}", watcher_changer: @watcher_changer) %>
<hr />
<%= render partial: 'issue_details', locals: { issue: @issue } %>
<%= render partial: 'work_package_details', locals: { work_package: @work_package } %>
<p>
<%= format_text(t(:text_latest_note, note: last_work_package_note(@issue)),
<%= format_text(t(:text_latest_note, note: last_work_package_note(@work_package)),
only_path: false,
object: @issue,
project: @issue.project) %>
object: @work_package,
project: @work_package.project) %>
</p>

@ -27,8 +27,8 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%= t("text_work_package_watcher_#{@action}", id: "##{@issue.id}", watcher_changer: @watcher_changer) %>
<%= t("text_work_package_watcher_#{@action}", id: "##{@work_package.id}", watcher_changer: @watcher_changer) %>
----------------------------------------
<%= render partial: 'issue_details', locals: { issue: @issue } %>
<%= t(:text_latest_note, note: last_work_package_note(@issue)) %>
<%= render partial: 'work_package_details', locals: { work_package: @work_package } %>
<%= t(:text_latest_note, note: last_work_package_note(@work_package)) %>

@ -54,7 +54,7 @@ class Mails::MemberJob < ApplicationJob
end
def send_updated_global(current_user, member, member_message)
return if sending_disabled?(:updated, member_message)
return if sending_disabled?(:updated, member.user_id, member_message)
MemberMailer
.updated_global(current_user, member, member_message)
@ -62,7 +62,7 @@ class Mails::MemberJob < ApplicationJob
end
def send_added_project(current_user, member, member_message)
return if sending_disabled?(:added, member_message)
return if sending_disabled?(:added, member.user_id, member_message)
MemberMailer
.added_project(current_user, member, member_message)
@ -70,7 +70,7 @@ class Mails::MemberJob < ApplicationJob
end
def send_updated_project(current_user, member, member_message)
return if sending_disabled?(:updated, member_message)
return if sending_disabled?(:updated, member.user_id, member_message)
MemberMailer
.updated_project(current_user, member, member_message)
@ -85,7 +85,12 @@ class Mails::MemberJob < ApplicationJob
.each(&block)
end
def sending_disabled?(setting, message)
message.blank? && !Setting.notified_events.include?("membership_#{setting}")
def sending_disabled?(setting, user_id, message)
# In case we have an invitation message, always send a mail
return false if message.present?
NotificationSetting
.where(project_id: nil, user_id: user_id)
.exists?("membership_#{setting}" => false)
end
end

@ -28,42 +28,19 @@
# See COPYRIGHT and LICENSE files for more details.
#++
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)
class Mails::ReminderJob < Mails::DeliverJob
include ::Notifications::WithMarkedNotifications
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
private
def digest_job_already_scheduled?(notification)
Notification
.mail_digest_before(recipient: notification.recipient,
time: notification.created_at)
.where.not(id: notification.id)
.exists?
end
def notification_marked_attribute
:mail_reminder_sent
end
private
def render_mail
# Have to cast to array since the update in the subsequent block
# will result in the notification to not be found via the .mail_digest_before scope.
notification_ids = Notification.mail_digest_before(recipient: recipient, time: Time.current).pluck(:id)
# will result in the notification to not be found via the .unsent_reminders_before scope.
notification_ids = Notification.unsent_reminders_before(recipient: recipient, time: Time.current).pluck(:id)
return nil if notification_ids.empty?
@ -75,8 +52,8 @@ class Mails::DigestJob < Mails::DeliverJob
# Running the digest job will take some time to complete.
# Within this timeframe, new notifications might come in. Upon notification creation
# a job is scheduled unless there is no prior digest notification that is not yet read (read_mail_digest: true).
# If we were to only set the read_mail_digest state at the end of the mail rendering an edge case of the following
# a job is scheduled unless there is no prior digest notification that is not yet read (mail_reminder_sent: true).
# If we were to only set the mail_reminder_sent state at the end of the mail rendering an edge case of the following
# would lead to digest not being sent or at least sent unduly late:
# * Job starts and fetches the notifications for rendering. We need to fetch all notifications to be rendered to
# order them as desired.
@ -87,18 +64,4 @@ class Mails::DigestJob < Mails::DeliverJob
#
# A new job would then only be scheduled upon the creation of a new digest notification which (as unlikely as that is)
# might only happen after some days have gone by.
#
# Because we mark the notifications as read even though they in fact aren't, we do it in a transaction
# so that the change is rolled back in case of an error.
def with_marked_notifications(notification_ids)
Notification.transaction do
mark_notifications_read(notification_ids)
yield
end
end
def mark_notifications_read(notification_ids)
Notification.where(id: notification_ids).update_all(read_mail_digest: true, updated_at: Time.current)
end
end

@ -38,11 +38,11 @@ class Mails::WatcherJob < Mails::DeliverJob
end
def render_mail
UserMailer
.work_package_watcher_changed(watcher.watchable,
recipient,
sender,
action)
WorkPackageMailer
.watcher_changed(watcher.watchable,
recipient,
sender,
action)
end
private
@ -61,10 +61,9 @@ class Mails::WatcherJob < Mails::DeliverJob
.user
.notification_settings
.applicable(watcher.watchable.project)
.mail
.first
settings.watched || settings.all
settings.watched
end
def self_watching?

@ -0,0 +1,46 @@
#-- 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
class ScheduleReminderMailsJob < Cron::CronJob
# runs every quarter of an hour, so 00:00, 00:15...
self.cron_expression = '*/15 * * * *'
def perform
User.having_reminder_mail_to_send(run_at).pluck(:id).each do |user_id|
Mails::ReminderJob.perform_later(user_id)
end
end
def run_at
self.class.delayed_job.run_at
end
end
end

@ -0,0 +1,55 @@
#-- 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 COPYRIGHT and LICENSE files for more details.
#++
# Because we mark the notifications as read even though they in fact aren't, we do it in a transaction
# so that the change is rolled back in case of an error.
module Notifications
module WithMarkedNotifications
extend ActiveSupport::Concern
included do
private
def with_marked_notifications(notification_ids)
Notification.transaction do
mark_notifications_sent(notification_ids)
yield
end
end
def mark_notifications_sent(notification_ids)
Notification
.where(id: Array(notification_ids))
.update_all(notification_marked_attribute => true, updated_at: Time.current)
end
end
end
end

@ -32,9 +32,9 @@
# 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.
# In case the notification has a mentioned-reason, the mail is to be sent right away. This accepts the possibility
# of the journal being deleted later on.
# 2) After the journal aggregation time has passed direct mails are 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
@ -52,10 +52,21 @@ class Notifications::WorkflowJob < ApplicationJob
state :create_notifications,
to: :send_mails do |resource, send_notification|
Notifications::CreateFromModelService
.new(resource)
.call(send_notification)
.all_results
mentioned, delayed = Notifications::CreateFromModelService
.new(resource)
.call(send_notification)
.all_results
.partition(&:reason_mentioned?)
mentioned
.select { |n| n.mail_alert_sent == false }
.each do |notification|
Notifications::MailService
.new(notification)
.call
end
delayed
.map(&:id)
end
@ -65,19 +76,11 @@ class Notifications::WorkflowJob < ApplicationJob
Notification
.where(id: notification_ids)
.unread_mail
.each do |notification|
Notifications::MailService
.new(notification)
.call
end
Notification
.where(id: notification_ids)
.unread_mail_digest
.mail_alert_unsent
.each do |notification|
Mails::DigestJob
.schedule(notification)
end
Notifications::MailService
.new(notification)
.call
end
end
end

@ -32,23 +32,6 @@
require ::File.expand_path('config/environment', __dir__)
##
# Use the worker killer when Unicorn is being used
if defined?(Unicorn) && Rails.env.production?
require 'unicorn/worker_killer'
min_ram = ENV.fetch('OPENPROJECT_UNICORN_RAM2KILL_MIN', 340 * 1 << 20).to_i
max_ram = ENV.fetch('OPENPROJECT_UNICORN_RAM2KILL_MAX', 400 * 1 << 20).to_i
min_req = ENV.fetch('OPENPROJECT_UNICORN_REQ2KILL_MIN', 3072).to_i
max_req = ENV.fetch('OPENPROJECT_UNICORN_REQ2KILL_MAX', 4096).to_i
# Kill Workers randomly between 340 and 400 MB (per default)
# or between 3072 and 4096 requests.
# Our largest installations are starting around 200/230 MB
use Unicorn::WorkerKiller::Oom, min_ram, max_ram
use Unicorn::WorkerKiller::MaxRequests, min_req, max_req
end
subdir = OpenProject::Configuration.rails_relative_url_root.presence
map (subdir || '/') do

@ -167,6 +167,19 @@
default:
log_level: info
# web server configuration
# web:
# workers: 2
# timeout: 60
# wait_timeout: 10
# min_threads: 4
# max_threads: 16
# statsd configuration
# statsd:
# host: 127.0.0.1
# port: 8125
# Outgoing emails configuration (see examples above)
email_delivery_method: :smtp
smtp_address: smtp.example.net

@ -32,7 +32,10 @@
# since it can lead to runtime schema cache issues.
# Refusing to boot will encourage admins to fix missing migrations.
exceptions = %w(db:create db:drop db:migrate db:structure:load db:schema:load assets:precompile)
exceptions = %w(
db:create db:drop db:migrate db:structure:load db:schema:load
assets:precompile assets:clean
)
is_console = Rails.const_defined? 'Console'
if Rails.env.production? && !is_console && (exceptions & ARGV).empty?

@ -6,6 +6,7 @@ OpenProject::Application.configure do |application|
::Cron::ClearTmpCacheJob,
::Cron::ClearUploadedFilesJob,
::OAuth::CleanupJob,
::Attachments::CleanupUncontaineredJob
::Attachments::CleanupUncontaineredJob,
::Notifications::ScheduleReminderMailsJob
end
end

@ -0,0 +1,9 @@
config = Rails.env.production? && Rails.application.config.database_configuration[Rails.env]
pool_size = config && [OpenProject::Configuration.web_max_threads + 1, config['pool'].to_i].max
# make sure we have enough connections in the pool for each thread and then some
if pool_size && pool_size > ActiveRecord::Base.connection_pool.size
Rails.logger.debug { "Increasing database pool size to #{pool_size} to match max threads" }
ActiveRecord::Base.establish_connection config.merge(pool: pool_size)
end

@ -35,5 +35,11 @@ Rails.application.config.middleware.insert_after Rails::Rack::Logger, Rack::Cors
methods: :any,
credentials: true,
if: proc { ::API::V3::CORS.enabled? }
resource '/oauth/*',
headers: :any,
methods: :any,
credentials: true,
if: proc { ::API::V3::CORS.enabled? }
end
end

@ -0,0 +1,43 @@
# Use rack-timeout if we run in clustered mode with at least 2 workers
# so that workers, should a timeout occur, can be restarted without interruption.
if OpenProject::Configuration.web_workers >= 2
timeout = Integer(ENV['RACK_TIMEOUT_SERVICE_TIMEOUT'].presence || OpenProject::Configuration.web_timeout)
wait_timeout = Integer(ENV['RACK_TIMEOUT_WAIT_TIMEOUT'].presence || OpenProject::Configuration.web_wait_timeout)
Rails.logger.debug { "Enabling Rack::Timeout (service=#{timeout}s wait=#{wait_timeout}s)" }
Rails.application.config.middleware.insert_before(
::Rack::Runtime,
::Rack::Timeout,
service_timeout: timeout, # time after which a request being served times out
wait_timeout: wait_timeout, # time after which a request waiting to be served times out
term_on_timeout: 1 # shut down worker (gracefully) right away on timeout to be restarted
)
# remove default logger (logging uninteresting extra info with each not timed out request)
Rack::Timeout.unregister_state_change_observer(:logger)
Rack::Timeout.register_state_change_observer(:wait_timeout_logger) do |env|
details = env[Rack::Timeout::ENV_INFO_KEY]
if details.state == :timed_out && details.wait.present?
::OpenProject.logger.error "Request timed out waiting to be served!"
end
end
# The timeout itself is already reported so no need to
# report the generic internal server error too as it doesn't
# add any more information. Even worse, it's not immediately
# clear that the two reports are related.
module SuppressInternalErrorReportOnTimeout
def op_handle_error(message_or_exception, context = {})
return if request && request.env[Rack::Timeout::ENV_INFO_KEY].try(:state) == :timed_out
super
end
end
OpenProjectErrorHelper.prepend SuppressInternalErrorReportOnTimeout
else
Rails.logger.debug { "Not enabling Rack::Timeout since we are not running in cluster mode with at least 2 workers" }
end

@ -31,6 +31,6 @@
# Register interceptors defined in app/mailers/user_mailer.rb
# Do this here, so they aren't registered multiple times due to reloading in development mode.
UserMailer.register_interceptor(DefaultHeadersInterceptor)
ApplicationMailer.register_interceptor(DefaultHeadersInterceptor)
# following needs to be the last interceptor
UserMailer.register_interceptor(DoNotSendMailsWithoutReceiverInterceptor)
ApplicationMailer.register_interceptor(DoNotSendMailsWithoutReceiverInterceptor)

@ -631,16 +631,16 @@ ar:
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail_digest:
read_on_creation: 'cannot be set to true on notification creation.'
reason_ian:
mail_reminder_sent:
set_on_creation: 'cannot be set to true on notification creation.'
reason:
no_notification_reason: 'cannot be blank as IAN is chosen as a channel.'
reason_mail:
no_notification_reason: 'cannot be blank as mail is chosen as a channel.'
reason_mail_digest:
no_notification_reason: 'cannot be blank as mail digest is chosen as a channel.'
notification_settings:
only_one_global_setting: 'There must only be one global notification setting.'
email_alerts_global: 'The email notification settings can only be set globally.'
format: "%{message}"
parse_schema_filter_params_service:
attributes:
base:
@ -861,7 +861,6 @@ ar:
firstname: "الاسم الأول"
group: "مجموعة"
groups: "المجموعات"
name: "الاسم"
id: "الهوية المُعَرِّفة"
is_default: "القيمة الافتراضية"
is_for_all: "لكافة المشاريع"
@ -871,6 +870,7 @@ ar:
lastname: "الاسم الأخير"
login: "Username"
mail: "البريد الإكتروني"
name: "الاسم"
password: "كلمة المرور"
priority: "الأولوية"
project: "مشروع"
@ -1982,8 +1982,13 @@ ar:
created_at: 'at %{timestamp} by %{user} '
login_to_see_all: 'Log in to see all notifications.'
mentioned: 'You have been <b>mentioned in a comment</b>'
more_to_see_singular: 'There is 1 more work package with notifications.'
more_to_see_plural: 'There are %{number} more work packages with notifications.'
more_to_see:
zero: 'There are %{count} more work packages with notifications.'
one: 'There is 1 more work package with notifications.'
two: 'There are %{count} more work packages with notifications.'
few: 'There are %{count} more work packages with notifications.'
many: 'There are %{count} more work packages with notifications.'
other: 'There are %{count} more work packages with notifications.'
reason:
watched: 'Watched'
assigned: 'Assigned'
@ -2427,7 +2432,6 @@ ar:
setting_enabled_scm: "تمكين SCM"
setting_enabled_projects_columns: "Visible in project list"
setting_notification_retention_period_days: "Notification retention period"
setting_notification_email_digest_time: "Email digest time"
setting_feeds_enabled: "تمكين التغذية"
setting_feeds_limit: "حد محتوى تغذية"
setting_file_max_size_displayed: "عرض الحد الأقصى لحجم الملفات النصية المضمنة في السطر"
@ -2502,8 +2506,8 @@ ar:
notifications:
retention_text: >
Set the number of days notification events for users (the source for in-app notifications) will be kept in the system. Any events older than this time will be deleted.
email_digest_explanation: "Once a day, an email digest can be sent out containing a collection of all the notification users subscribed to. The setting is relative to each users configured time zone, so e.g. 8:00 will be executed at 7:00 UTC for users in UTC+1 and 9:00 UTC for those in UTC-1."
events_explanation: 'Governs for which event an email is sent out. Work packages are excluded from this list as the notifications for them can be configured specifically for every user.'
delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification."
display:
first_date_of_week_and_year_set: >
If either options "%{day_of_week_setting_name}" or "%{first_week_setting_name}" are set, the other has to be set as well to avoid inconsistencies in the frontend.
@ -2614,7 +2618,6 @@ ar:
text_regexp_info: "على سبيل المثال. ^[A-Z0-9] + $"
text_regexp_multiline: 'يتم تطبيق التعبير الاعتيادي في وضع متعدد الأسطر. على سبيل المثال: ^---\s+'
text_repository_usernames_mapping: "قم باختيار أو تحديث مستخدم \"OpenProject: المشروع المفتوح\" الذي تم تعيينه لكل اسم مستخدم وُجِد في سجل المستودع.\nالمستخدمون الذين لديهم نفس اسم المستخدم أو عنوان البريد الإلكتروني في المشروع المفتوح OpenProject والمستودع يتم تعيينهم تلقائيًّا."
text_select_notifications: "Select actions for which notifications should be sent."
text_status_changed_by_changeset: "تطبيق التغييرات %{value}."
text_table_difference_description: "في هذا الجدول يتم إظهار %{entries} الفردية. تستطيع أن تشاهد الفرق بين أي اثنين من المدخلات بواسطة اختيار الخانات المتوافقة في الجدول أولًا. عند الضغط على الزر في أسفل الجدول، سيتم عرض الاختلافات."
text_time_logged_by_changeset: "تطبيق التغييرات %{value}."

@ -627,16 +627,16 @@ bg:
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail_digest:
read_on_creation: 'cannot be set to true on notification creation.'
reason_ian:
mail_reminder_sent:
set_on_creation: 'cannot be set to true on notification creation.'
reason:
no_notification_reason: 'cannot be blank as IAN is chosen as a channel.'
reason_mail:
no_notification_reason: 'cannot be blank as mail is chosen as a channel.'
reason_mail_digest:
no_notification_reason: 'cannot be blank as mail digest is chosen as a channel.'
notification_settings:
only_one_global_setting: 'There must only be one global notification setting.'
email_alerts_global: 'The email notification settings can only be set globally.'
format: "%{message}"
parse_schema_filter_params_service:
attributes:
base:
@ -841,7 +841,6 @@ bg:
firstname: "Собствено име"
group: "Група"
groups: "Групи"
name: "Име"
id: "ID"
is_default: "Стойност по подразбиране"
is_for_all: "За всички проекти"
@ -851,6 +850,7 @@ bg:
lastname: "Фамилно име"
login: "Username"
mail: "E-mail"
name: "Име"
password: "Парола"
priority: "Приоритет"
project: "Проект"
@ -1914,8 +1914,9 @@ bg:
created_at: 'at %{timestamp} by %{user} '
login_to_see_all: 'Log in to see all notifications.'
mentioned: 'You have been <b>mentioned in a comment</b>'
more_to_see_singular: 'There is 1 more work package with notifications.'
more_to_see_plural: 'There are %{number} more work packages with notifications.'
more_to_see:
one: 'There is 1 more work package with notifications.'
other: 'There are %{count} more work packages with notifications.'
reason:
watched: 'Watched'
assigned: 'Assigned'
@ -2357,7 +2358,6 @@ bg:
setting_enabled_scm: "Enabled SCM"
setting_enabled_projects_columns: "Вижда се в списъка с проекти"
setting_notification_retention_period_days: "Notification retention period"
setting_notification_email_digest_time: "Email digest time"
setting_feeds_enabled: "Enable Feeds"
setting_feeds_limit: "Feed content limit"
setting_file_max_size_displayed: "Max size of text files displayed inline"
@ -2432,8 +2432,8 @@ bg:
notifications:
retention_text: >
Set the number of days notification events for users (the source for in-app notifications) will be kept in the system. Any events older than this time will be deleted.
email_digest_explanation: "Once a day, an email digest can be sent out containing a collection of all the notification users subscribed to. The setting is relative to each users configured time zone, so e.g. 8:00 will be executed at 7:00 UTC for users in UTC+1 and 9:00 UTC for those in UTC-1."
events_explanation: 'Governs for which event an email is sent out. Work packages are excluded from this list as the notifications for them can be configured specifically for every user.'
delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification."
display:
first_date_of_week_and_year_set: >
If either options "%{day_of_week_setting_name}" or "%{first_week_setting_name}" are set, the other has to be set as well to avoid inconsistencies in the frontend.
@ -2544,7 +2544,6 @@ bg:
text_regexp_info: "eg. ^[A-Z0-9]+$"
text_regexp_multiline: 'Уеднаквяването се прилага в многоредов режим. например ^---\s+'
text_repository_usernames_mapping: "Select or update the OpenProject user mapped to each username found in the repository log.\nUsers with the same OpenProject and repository username or email are automatically mapped."
text_select_notifications: "Select actions for which notifications should be sent."
text_status_changed_by_changeset: "Applied in changeset %{value}."
text_table_difference_description: "In this table the single %{entries} are shown. You can view the difference between any two entries by first selecting the according checkboxes in the table. When clicking on the button below the table the differences are shown."
text_time_logged_by_changeset: "Applied in changeset %{value}."

@ -627,16 +627,16 @@ ca:
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail_digest:
read_on_creation: 'cannot be set to true on notification creation.'
reason_ian:
mail_reminder_sent:
set_on_creation: 'cannot be set to true on notification creation.'
reason:
no_notification_reason: 'cannot be blank as IAN is chosen as a channel.'
reason_mail:
no_notification_reason: 'cannot be blank as mail is chosen as a channel.'
reason_mail_digest:
no_notification_reason: 'cannot be blank as mail digest is chosen as a channel.'
notification_settings:
only_one_global_setting: 'There must only be one global notification setting.'
email_alerts_global: 'The email notification settings can only be set globally.'
format: "%{message}"
parse_schema_filter_params_service:
attributes:
base:
@ -841,7 +841,6 @@ ca:
firstname: "Nom"
group: "Grup"
groups: "Grups"
name: "Nom"
id: "Id"
is_default: "Valor per defecte"
is_for_all: "Per tots els projectes"
@ -851,6 +850,7 @@ ca:
lastname: "Cognom"
login: "Nom d'usuari"
mail: "Correu electrònic"
name: "Nom"
password: "Contrasenya"
priority: "Prioritat"
project: "Projecte"
@ -1914,8 +1914,9 @@ ca:
created_at: 'at %{timestamp} by %{user} '
login_to_see_all: 'Log in to see all notifications.'
mentioned: 'You have been <b>mentioned in a comment</b>'
more_to_see_singular: 'There is 1 more work package with notifications.'
more_to_see_plural: 'There are %{number} more work packages with notifications.'
more_to_see:
one: 'There is 1 more work package with notifications.'
other: 'There are %{count} more work packages with notifications.'
reason:
watched: 'Watched'
assigned: 'Assigned'
@ -2355,7 +2356,6 @@ ca:
setting_enabled_scm: "Activar SCM"
setting_enabled_projects_columns: "Visible in project list"
setting_notification_retention_period_days: "Notification retention period"
setting_notification_email_digest_time: "Email digest time"
setting_feeds_enabled: "Habilita els canals"
setting_feeds_limit: "Límit de contingut del canals"
setting_file_max_size_displayed: "Mida màxima dels fitxers de text mostrats en línia"
@ -2430,8 +2430,8 @@ ca:
notifications:
retention_text: >
Set the number of days notification events for users (the source for in-app notifications) will be kept in the system. Any events older than this time will be deleted.
email_digest_explanation: "Once a day, an email digest can be sent out containing a collection of all the notification users subscribed to. The setting is relative to each users configured time zone, so e.g. 8:00 will be executed at 7:00 UTC for users in UTC+1 and 9:00 UTC for those in UTC-1."
events_explanation: 'Governs for which event an email is sent out. Work packages are excluded from this list as the notifications for them can be configured specifically for every user.'
delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification."
display:
first_date_of_week_and_year_set: >
If either options "%{day_of_week_setting_name}" or "%{first_week_setting_name}" are set, the other has to be set as well to avoid inconsistencies in the frontend.
@ -2542,7 +2542,6 @@ ca:
text_regexp_info: "ex. ^[A-Z0-9]+$"
text_regexp_multiline: 'S''aplica l''expressió regular en mode de múltilínia. Per exemple, ^---\s+'
text_repository_usernames_mapping: "Seleccioneu l'assignació entre els usuaris del OpenProject i cada nom d'usuari trobat al repositori.\nEls usuaris amb el mateix nom d'usuari o correu del OpenProject i del repositori s'assignaran automàticament."
text_select_notifications: "Select actions for which notifications should be sent."
text_status_changed_by_changeset: "Aplicat en el conjunt de canvis %{value}."
text_table_difference_description: "In this table the single %{entries} are shown. You can view the difference between any two entries by first selecting the according checkboxes in the table. When clicking on the button below the table the differences are shown."
text_time_logged_by_changeset: "Aplicat en el conjunt de canvis %{value}."

@ -629,16 +629,16 @@ cs:
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail_digest:
read_on_creation: 'cannot be set to true on notification creation.'
reason_ian:
mail_reminder_sent:
set_on_creation: 'cannot be set to true on notification creation.'
reason:
no_notification_reason: 'cannot be blank as IAN is chosen as a channel.'
reason_mail:
no_notification_reason: 'cannot be blank as mail is chosen as a channel.'
reason_mail_digest:
no_notification_reason: 'cannot be blank as mail digest is chosen as a channel.'
notification_settings:
only_one_global_setting: 'There must only be one global notification setting.'
email_alerts_global: 'The email notification settings can only be set globally.'
format: "%{message}"
parse_schema_filter_params_service:
attributes:
base:
@ -851,7 +851,6 @@ cs:
firstname: "Křestní jméno"
group: "Skupina"
groups: "Skupiny"
name: "Jméno"
id: "ID"
is_default: "Výchozí hodnota"
is_for_all: "Pro všechny projekty"
@ -861,6 +860,7 @@ cs:
lastname: "Příjmení"
login: "Uživatelské jméno"
mail: "E-mail"
name: "Jméno"
password: "Heslo"
priority: "Priorita"
project: "Projekt"
@ -1948,8 +1948,11 @@ cs:
created_at: 'at %{timestamp} by %{user} '
login_to_see_all: 'Log in to see all notifications.'
mentioned: 'You have been <b>mentioned in a comment</b>'
more_to_see_singular: 'There is 1 more work package with notifications.'
more_to_see_plural: 'There are %{number} more work packages with notifications.'
more_to_see:
one: 'There is 1 more work package with notifications.'
few: 'There are %{count} more work packages with notifications.'
many: 'There are %{count} more work packages with notifications.'
other: 'There are %{count} more work packages with notifications.'
reason:
watched: 'Watched'
assigned: 'Assigned'
@ -2392,7 +2395,6 @@ cs:
setting_enabled_scm: "Povolit SCM"
setting_enabled_projects_columns: "Visible in project list"
setting_notification_retention_period_days: "Notification retention period"
setting_notification_email_digest_time: "Email digest time"
setting_feeds_enabled: "Povolit kanály"
setting_feeds_limit: "Limit obsahu kanálů"
setting_file_max_size_displayed: "Maximální velikost textových souborů zobrazených přímo na stránce"
@ -2467,8 +2469,8 @@ cs:
notifications:
retention_text: >
Set the number of days notification events for users (the source for in-app notifications) will be kept in the system. Any events older than this time will be deleted.
email_digest_explanation: "Once a day, an email digest can be sent out containing a collection of all the notification users subscribed to. The setting is relative to each users configured time zone, so e.g. 8:00 will be executed at 7:00 UTC for users in UTC+1 and 9:00 UTC for those in UTC-1."
events_explanation: 'Governs for which event an email is sent out. Work packages are excluded from this list as the notifications for them can be configured specifically for every user.'
delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification."
display:
first_date_of_week_and_year_set: >
If either options "%{day_of_week_setting_name}" or "%{first_week_setting_name}" are set, the other has to be set as well to avoid inconsistencies in the frontend.
@ -2579,7 +2581,6 @@ cs:
text_regexp_info: "např. ^[A-Z0-9]+$"
text_regexp_multiline: 'regex je použit v režimu více řádků. např.: ^---\s+'
text_repository_usernames_mapping: "Vyberte nebo aktualizujte mapovaný uživatel OpenProject ke každému uživatelskému jménu nalezenému v protokolu repozitáře.\nUživatelé se stejným OpenProject a repozitářovým jménem nebo e-mailem jsou automaticky mapováni."
text_select_notifications: "Select actions for which notifications should be sent."
text_status_changed_by_changeset: "Aplikováno v sadě změn %{value}."
text_table_difference_description: "V této tabulce je zobrazeno jediné %{entries} . Rozdíl mezi dvěma položkami můžete zobrazit prvním výběrem zaškrtávacích polí v tabulce. Při kliknutí na tlačítko níže jsou rozdíly zobrazeny."
text_time_logged_by_changeset: "Aplikováno v sadě změn %{value}."

@ -625,16 +625,16 @@ da:
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail_digest:
read_on_creation: 'cannot be set to true on notification creation.'
reason_ian:
mail_reminder_sent:
set_on_creation: 'cannot be set to true on notification creation.'
reason:
no_notification_reason: 'cannot be blank as IAN is chosen as a channel.'
reason_mail:
no_notification_reason: 'cannot be blank as mail is chosen as a channel.'
reason_mail_digest:
no_notification_reason: 'cannot be blank as mail digest is chosen as a channel.'
notification_settings:
only_one_global_setting: 'There must only be one global notification setting.'
email_alerts_global: 'The email notification settings can only be set globally.'
format: "%{message}"
parse_schema_filter_params_service:
attributes:
base:
@ -839,7 +839,6 @@ da:
firstname: "Fornavn"
group: "Gruppe"
groups: "Grupper"
name: "Navn"
id: "Id"
is_default: "Forhåndsvalgt værdi"
is_for_all: "For alle projekter"
@ -849,6 +848,7 @@ da:
lastname: "Efternavn"
login: "Username"
mail: "E-mail"
name: "Navn"
password: "Adgangskode"
priority: "Prioritet"
project: "Prjoekt"
@ -1912,8 +1912,9 @@ da:
created_at: 'at %{timestamp} by %{user} '
login_to_see_all: 'Log in to see all notifications.'
mentioned: 'You have been <b>mentioned in a comment</b>'
more_to_see_singular: 'There is 1 more work package with notifications.'
more_to_see_plural: 'There are %{number} more work packages with notifications.'
more_to_see:
one: 'There is 1 more work package with notifications.'
other: 'There are %{count} more work packages with notifications.'
reason:
watched: 'Watched'
assigned: 'Assigned'
@ -2353,7 +2354,6 @@ da:
setting_enabled_scm: "Aktiver SCM"
setting_enabled_projects_columns: "Visible in project list"
setting_notification_retention_period_days: "Notification retention period"
setting_notification_email_digest_time: "Email digest time"
setting_feeds_enabled: "Aktiver feeds"
setting_feeds_limit: "Grænse for indhold af feeds"
setting_file_max_size_displayed: "Maksimal størrelse af tekstfiler, der vises inline"
@ -2428,8 +2428,8 @@ da:
notifications:
retention_text: >
Set the number of days notification events for users (the source for in-app notifications) will be kept in the system. Any events older than this time will be deleted.
email_digest_explanation: "Once a day, an email digest can be sent out containing a collection of all the notification users subscribed to. The setting is relative to each users configured time zone, so e.g. 8:00 will be executed at 7:00 UTC for users in UTC+1 and 9:00 UTC for those in UTC-1."
events_explanation: 'Governs for which event an email is sent out. Work packages are excluded from this list as the notifications for them can be configured specifically for every user.'
delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification."
display:
first_date_of_week_and_year_set: >
If either options "%{day_of_week_setting_name}" or "%{first_week_setting_name}" are set, the other has to be set as well to avoid inconsistencies in the frontend.
@ -2540,7 +2540,6 @@ da:
text_regexp_info: "fx ^[A-Z0-9]+$"
text_regexp_multiline: 'The regex is applied in a multi-line mode. e.g., ^---\s+'
text_repository_usernames_mapping: "Vælg eller opdatér den OpenProject-bruger, der er tilknyttet hvert brugernavn i loggen for projektarkivet.\nBrugere med det samme brugernavn eller mailadresse i OpenProject og i projektarkivet vil automatisk blive tilknyttet."
text_select_notifications: "Select actions for which notifications should be sent."
text_status_changed_by_changeset: "Indføjet i pakken af ændringer, %{value}."
text_table_difference_description: "In this table the single %{entries} are shown. You can view the difference between any two entries by first selecting the according checkboxes in the table. When clicking on the button below the table the differences are shown."
text_time_logged_by_changeset: "Indføjet i pakken af ændringer, %{value}."

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

Loading…
Cancel
Save