From a10205750638f56bccb20d77d66c085014ed32f0 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Fri, 15 Apr 2016 23:56:10 +0200 Subject: [PATCH] Added slack integration. --- Gemfile | 3 + Gemfile.lock | 2 + .../controllers/_integration/icinga.coffee | 40 ++- .../_integration/mattermost.coffee | 12 - .../controllers/_integration/nagios.coffee | 40 ++- .../app/controllers/_integration/slack.coffee | 73 ++++++ .../_profile/linked_accounts.coffee | 22 +- .../app/controllers/_settings/area.coffee | 94 +++++++- .../app/controllers/integrations.coffee | 45 +++- .../javascripts/app/controllers/manage.coffee | 1 + .../app/controllers/package.coffee | 2 +- .../javascripts/app/models/setting.coffee | 10 + .../app/views/integration/base.jst.eco | 17 ++ .../app/views/integration/index.jst.eco | 29 +++ .../app/views/settings/form.jst.eco | 21 ++ .../app/views/settings/item.jst.eco | 6 +- app/assets/stylesheets/zammad.scss | 1 + app/models/observer/ticket/notification.rb | 180 -------------- app/models/observer/transaction.rb | 181 +++++++++++++- app/models/ticket.rb | 18 +- app/models/transaction.rb | 3 + app/models/transaction/background_job.rb | 36 +++ .../notification.rb} | 69 +++--- app/models/transaction/slack.rb | 227 ++++++++++++++++++ app/views/mailer/ticket_create/de.html.erb | 2 +- app/views/mailer/ticket_create/en.html.erb | 2 +- .../mailer/ticket_escalation/de.html.erb | 2 +- .../mailer/ticket_escalation/en.html.erb | 2 +- .../ticket_escalation_warning/de.html.erb | 2 +- .../ticket_escalation_warning/en.html.erb | 6 +- .../ticket_reminder_reached/de.html.erb | 2 +- .../ticket_reminder_reached/en.html.erb | 4 +- app/views/mailer/ticket_update/de.html.erb | 2 +- app/views/mailer/ticket_update/en.html.erb | 2 +- app/views/slack/application.md.erb | 1 + app/views/slack/ticket_create/en.md.erb | 9 + app/views/slack/ticket_escalation/en.md.erb | 7 + .../slack/ticket_escalation_warning/en.md.erb | 7 + .../slack/ticket_reminder_reached/en.md.erb | 7 + app/views/slack/ticket_update/en.md.erb | 11 + config/application.rb | 4 +- .../20160415000001_add_slack_integration.rb | 144 +++++++++++ db/seeds.rb | 59 ++++- lib/notification_factory.rb | 24 +- lib/notification_factory/slack.rb | 54 +++++ lib/notification_factory/template.rb | 23 +- test/integration/slack_test.rb | 192 +++++++++++++++ test/unit/ticket_notification_test.rb | 12 +- 48 files changed, 1403 insertions(+), 309 deletions(-) delete mode 100644 app/assets/javascripts/app/controllers/_integration/mattermost.coffee create mode 100644 app/assets/javascripts/app/controllers/_integration/slack.coffee create mode 100644 app/assets/javascripts/app/views/integration/base.jst.eco create mode 100644 app/assets/javascripts/app/views/integration/index.jst.eco create mode 100644 app/assets/javascripts/app/views/settings/form.jst.eco delete mode 100644 app/models/observer/ticket/notification.rb create mode 100644 app/models/transaction.rb create mode 100644 app/models/transaction/background_job.rb rename app/models/{observer/ticket/notification/background_job.rb => transaction/notification.rb} (82%) create mode 100644 app/models/transaction/slack.rb create mode 100644 app/views/slack/application.md.erb create mode 100644 app/views/slack/ticket_create/en.md.erb create mode 100644 app/views/slack/ticket_escalation/en.md.erb create mode 100644 app/views/slack/ticket_escalation_warning/en.md.erb create mode 100644 app/views/slack/ticket_reminder_reached/en.md.erb create mode 100644 app/views/slack/ticket_update/en.md.erb create mode 100644 db/migrate/20160415000001_add_slack_integration.rb create mode 100644 lib/notification_factory/slack.rb create mode 100644 test/integration/slack_test.rb diff --git a/Gemfile b/Gemfile index ecc9a8afd..a188559e6 100644 --- a/Gemfile +++ b/Gemfile @@ -88,6 +88,9 @@ gem 'writeexcel' gem 'icalendar' gem 'browser' +# integrations +gem 'slack-notifier' + # event machine gem 'eventmachine' gem 'em-websocket' diff --git a/Gemfile.lock b/Gemfile.lock index c0deb2051..f568c61ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -267,6 +267,7 @@ GEM simplecov-html (0.10.0) simplecov-rcov (0.2.3) simplecov (>= 0.4.1) + slack-notifier (1.5.1) slop (3.6.0) spring (1.6.4) sprockets (3.5.2) @@ -361,6 +362,7 @@ DEPENDENCIES simple-rss simplecov simplecov-rcov + slack-notifier spring sprockets sqlite3 diff --git a/app/assets/javascripts/app/controllers/_integration/icinga.coffee b/app/assets/javascripts/app/controllers/_integration/icinga.coffee index a5ad04487..3c486a06b 100644 --- a/app/assets/javascripts/app/controllers/_integration/icinga.coffee +++ b/app/assets/javascripts/app/controllers/_integration/icinga.coffee @@ -1,12 +1,30 @@ -class Icinga extends App.ControllerTabs - header: 'Icinga' - constructor: -> - super - return if !@authenticate(false, 'Admin') - @title 'Icinga', true - @tabs = [ - { name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Integration::Icinga' } } - ] - @render() +class Index extends App.ControllerIntegrationBase + featureIntegration: 'icinga_integration' + featureName: 'Icinga' + featureConfig: 'icinga_config' + description: [ + ['This service receives emails from %s and creates tickets with host and service.', 'Icinga'] + ['If the host and service is recovered again, the ticket will be closed automatically.'] + ] -App.Config.set('IntegrationIcinga', { prio: 1100, parent: '#integration', name: 'Icinga', target: '#integration/icinga', controller: Icinga, role: ['Admin'] }, 'NavBarIntegration') + form: (localeEl) -> + new App.SettingsForm( + area: 'Integration::Icinga' + el: localeEl.find('.js-form') + ) + +class State + @current: -> + App.Setting.get('icinga_integration') + +App.Config.set( + 'IntegrationIcinga' + { + name: 'Icinga' + target: '#system/integration/icinga' + description: 'A open source monitoring tool.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/_integration/mattermost.coffee b/app/assets/javascripts/app/controllers/_integration/mattermost.coffee deleted file mode 100644 index ce340da0d..000000000 --- a/app/assets/javascripts/app/controllers/_integration/mattermost.coffee +++ /dev/null @@ -1,12 +0,0 @@ -class Mattermost extends App.ControllerTabs - header: 'Mattermost' - constructor: -> - super - return if !@authenticate(false, 'Admin') - @title 'Mattermost', true - @tabs = [ - { name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Integration::Mattermost' } } - ] - @render() - -App.Config.set('IntegrationMattermost', { prio: 1000, parent: '#integration', name: 'Mattermost', target: '#integration/mattermost', controller: Mattermost, role: ['Admin'] }, 'NavBarIntegration') diff --git a/app/assets/javascripts/app/controllers/_integration/nagios.coffee b/app/assets/javascripts/app/controllers/_integration/nagios.coffee index 471fa8516..ab8bf6394 100644 --- a/app/assets/javascripts/app/controllers/_integration/nagios.coffee +++ b/app/assets/javascripts/app/controllers/_integration/nagios.coffee @@ -1,12 +1,30 @@ -class Nagios extends App.ControllerTabs - header: 'Nagios' - constructor: -> - super - return if !@authenticate(false, 'Admin') - @title 'Nagios', true - @tabs = [ - { name: 'Base', 'target': 'base', controller: App.SettingsArea, params: { area: 'Integration::Nagios' } } - ] - @render() +class Index extends App.ControllerIntegrationBase + featureIntegration: 'nagios_integration' + featureName: 'Nagios' + featureConfig: 'nagios_config' + description: [ + ['This service receives emails from %s and creates tickets with host and service.', 'Nagios'] + ['If the host and service is recovered again, the ticket will be closed automatically.'] + ] -App.Config.set('IntegrationNagios', { prio: 1200, parent: '#integration', name: 'Nagios', target: '#integration/nagios', controller: Nagios, role: ['Admin'] }, 'NavBarIntegration') + form: (localeEl) -> + new App.SettingsForm( + area: 'Integration::Nagios' + el: localeEl.find('.js-form') + ) + +class State + @current: -> + App.Setting.get('nagios_integration') + +App.Config.set( + 'IntegrationNagios' + { + name: 'Nagios' + target: '#system/integration/nagios' + description: 'A open source monitoring tool.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/_integration/slack.coffee b/app/assets/javascripts/app/controllers/_integration/slack.coffee new file mode 100644 index 000000000..de3169477 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_integration/slack.coffee @@ -0,0 +1,73 @@ +class Index extends App.ControllerIntegrationBase + featureIntegration: 'slack_integration' + featureName: 'Slack' + featureConfig: 'slack_config' + description: [ + ['This service sends notifications to your %s channel.', 'Slack'] + ['To setup this Service you need to create a new |"Incoming webhook"| in your %s integration panel, and enter the Webhook URL below.', 'Slack'] + ] + + form: (localEl) => + + params = App.Setting.get(@featureConfig) + if params && params.items + params = params.items[0] || {} + + options = + create: '1. Ticket Create' + update: '2. Ticket Update' + reminder_reached: '3. Ticket Reminder Reached' + escalation: '4. Ticket Escalation' + escalation_warning: '5. Ticket Escalation Warning' + + configureAttributes = [ + { name: 'types', display: 'Trigger', tag: 'checkbox', options: options, 'null': false, class: 'vertical', note: 'Where notification is sent.' }, + { name: 'group_id', display: 'Group', tag: 'select', relation: 'Group', multiple: true, 'null': false, note: 'Only for this groups.' }, + { name: 'webhook', display: 'Webhook', tag: 'input', type: 'text', limit: 200, 'null': false, placeholder: 'https://hooks.slack.com/services/...' }, + { name: 'username', display: 'username', tag: 'input', type: 'text', limit: 100, 'null': false, placeholder: 'username' }, + { name: 'channel', display: 'channel', tag: 'input', type: 'text', limit: 100, 'null': true, placeholder: '#channel' }, + ] + console.log('p', params) + settings = [] + for item in configureAttributes + setting = + options: + form: [item] + name: item.name + description: item.note || '' + title: item.display + settings.push setting + + formEl = $( App.view('settings/form')( + settings: settings + )) + + for setting in settings + configure_attribute = setting.options['form'] + configure_attribute[0].display = '' + value = params[setting.name] + localParams = {} + localParams[setting.name] = value + new App.ControllerForm( + el: formEl.find("[data-name=#{setting.name}]") + model: { configure_attributes: configure_attribute, className: '' } + params: localParams + ) + + localEl.find('.js-form').html(formEl) + +class State + @current: -> + App.Setting.get('slack_integration') + +App.Config.set( + 'IntegrationSlack' + { + name: 'Slack' + target: '#system/integration/slack' + description: 'A team communication tool for the 21st century.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee b/app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee index 893498d51..587b2132e 100644 --- a/app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee +++ b/app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee @@ -37,7 +37,7 @@ class Index extends App.Controller } auth_providers = [] for key, provider of auth_provider_all - if @Config.get( provider.config ) is true || @Config.get( provider.config ) is 'true' + if @Config.get(provider.config) is true || @Config.get(provider.config) is 'true' auth_providers.push provider @html App.view('profile/linked_accounts')( @@ -52,19 +52,19 @@ class Index extends App.Controller # get data @ajax( - id: 'account' - type: 'DELETE' - url: @apiPath + '/users/account' - data: JSON.stringify({ provider: provider, uid: uid }) + id: 'account' + type: 'DELETE' + url: @apiPath + '/users/account' + data: JSON.stringify(provider: provider, uid: uid) processData: true - success: @success - error: @error + success: @success + error: @error ) success: (data, status, xhr) => @notify( type: 'success' - msg: App.i18n.translateContent( 'Successfully!' ) + msg: App.i18n.translateContent('Successfully!') ) update = => @render() @@ -72,10 +72,10 @@ class Index extends App.Controller error: (xhr, status, error) => @render() - data = JSON.parse( xhr.responseText ) + data = JSON.parse(xhr.responseText) @notify( type: 'error' - msg: App.i18n.translateContent( data.message ) + msg: App.i18n.translateContent(data.message) ) -App.Config.set( 'LinkedAccounts', { prio: 4000, name: 'Linked Accounts', parent: '#profile', target: '#profile/linked', controller: Index }, 'NavBarProfile' ) +App.Config.set('LinkedAccounts', { prio: 4000, name: 'Linked Accounts', parent: '#profile', target: '#profile/linked', controller: Index }, 'NavBarProfile') diff --git a/app/assets/javascripts/app/controllers/_settings/area.coffee b/app/assets/javascripts/app/controllers/_settings/area.coffee index 36a718730..f6bdc1674 100644 --- a/app/assets/javascripts/app/controllers/_settings/area.coffee +++ b/app/assets/javascripts/app/controllers/_settings/area.coffee @@ -1,3 +1,93 @@ +class App.SettingsForm extends App.Controller + events: + 'submit form': 'update' + + constructor: -> + super + + # check authentication + return if !@authenticate() + + @subscribeId = App.Setting.subscribe(@render, initFetch: true, clear: false) + + render: => + + # serach area settings + settings = App.Setting.search( + filter: + area: @area + ) + + # filter online service settings + if App.Config.get('system_online_service') + settings = _.filter(settings, (setting) -> + return if setting.online_service + return if setting.preferences && setting.preferences.online_service_disable + setting + ) + return if _.isEmpty(settings) + + # sort by prio + settings = _.sortBy( settings, (setting) -> + return if !setting.preferences + setting.preferences.prio + ) + + localEl = $( App.view('settings/form')( + settings: settings + )) + + for setting in settings + configure_attributes = setting.options['form'] + value = App.Setting.get(setting.name) + params = {} + params[setting.name] = value + new App.ControllerForm( + el: localEl.find("[data-name=#{setting.name}]") + model: { configure_attributes: configure_attributes, className: '' } + params: params + ) + @html localEl + + update: (e) => + e.preventDefault() + @formDisable(e) + params = @formParam(e.target) + + ui = @ + count = 0 + for name, value of params + if App.Setting.findByAttribute('name', name) + count += 1 + App.Setting.set( + name, + value, + done: -> + ui.formEnable(e) + count -= 1 + if count == 0 + App.Event.trigger 'notify', { + type: 'success' + msg: App.i18n.translateContent('Update successful!') + timeout: 2000 + } + + # rerender ui || get new collections and session data + if @preferences + if @preferences.render + App.Event.trigger( 'ui:rerender' ) + + if @preferences.session_check + App.Auth.loginCheck() + + fail: (settings, details) -> + App.Event.trigger 'notify', { + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to update object!') + timeout: 2000 + } + ) + class App.SettingsArea extends App.Controller constructor: -> super @@ -115,11 +205,11 @@ class App.SettingsAreaItem extends App.Controller if @setting.preferences.session_check App.Auth.loginCheck() - fail: -> + fail: (settings, details) -> ui.formEnable(e) App.Event.trigger 'notify', { type: 'error' - msg: App.i18n.translateContent('Can\'t update item!') + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to update object!') timeout: 2000 } ) diff --git a/app/assets/javascripts/app/controllers/integrations.coffee b/app/assets/javascripts/app/controllers/integrations.coffee index 5de52cf83..57570bf1c 100644 --- a/app/assets/javascripts/app/controllers/integrations.coffee +++ b/app/assets/javascripts/app/controllers/integrations.coffee @@ -1,8 +1,41 @@ -class IndexRouter extends App.ControllerNavSidbar - authenticateRequired: true - configKey: 'NavBarIntegration' +class Index extends App.ControllerContent + constructor: -> + super -App.Config.set('integration', IndexRouter, 'Routes') -App.Config.set('integration/:target', IndexRouter, 'Routes') + # check authentication + return if !@authenticate(false, 'Admin') -App.Config.set('Integration', { prio: 1000, name: 'Integration', target: '#integration', role: ['Admin'] }, 'NavBarIntegration') + @title 'Integrations', true + + @integrationItems = App.Config.get('NavBarIntegrations') + + if !@integration + @subscribeId = App.Setting.subscribe(@render, initFetch: true, clear: false) + return + + for key, value of @integrationItems + if value.target is "#system/#{@target}/#{@integration}" + config = value + break + + new config.controller( + el: @el.closest('.main') + ) + + render: => + integrations = [] + for key, value of @integrationItems + value.key = key + integrations.push value + integrations = _.sortBy(integrations, (item) -> return item.name) + + @html App.view('integration/index')( + head: 'Integrations' + integrations: integrations + ) + + release: => + if @subscribeId + App.Setting.unsubscribe(@subscribeId) + +App.Config.set('Integration', { prio: 1000, name: 'Integrations', parent: '#system', target: '#system/integration', controller: Index, role: ['Admin'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/manage.coffee b/app/assets/javascripts/app/controllers/manage.coffee index f59d7bb72..5ab127c7d 100644 --- a/app/assets/javascripts/app/controllers/manage.coffee +++ b/app/assets/javascripts/app/controllers/manage.coffee @@ -8,6 +8,7 @@ App.Config.set('settings/:target', IndexRouter, 'Routes') App.Config.set('channels/:target', IndexRouter, 'Routes') App.Config.set('channels/:target/:channel_id', IndexRouter, 'Routes') App.Config.set('system/:target', IndexRouter, 'Routes') +App.Config.set('system/:target/:integration', IndexRouter, 'Routes') App.Config.set('Manage', { prio: 1000, name: 'Manage', target: '#manage', role: ['Admin'] }, 'NavBarAdmin') App.Config.set('Channels', { prio: 2500, name: 'Channels', target: '#channels', role: ['Admin'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/package.coffee b/app/assets/javascripts/app/controllers/package.coffee index e2c53ae30..a110789cd 100644 --- a/app/assets/javascripts/app/controllers/package.coffee +++ b/app/assets/javascripts/app/controllers/package.coffee @@ -58,4 +58,4 @@ class Index extends App.ControllerContent @load() ) -App.Config.set( 'Packages', { prio: 1000, name: 'Packages', parent: '#system', target: '#system/package', controller: Index, role: ['Admin'] }, 'NavBarAdmin' ) +App.Config.set('Packages', { prio: 1000, name: 'Packages', parent: '#system', target: '#system/package', controller: Index, role: ['Admin'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/models/setting.coffee b/app/assets/javascripts/app/models/setting.coffee index 1879df407..baedf47f0 100644 --- a/app/assets/javascripts/app/models/setting.coffee +++ b/app/assets/javascripts/app/models/setting.coffee @@ -2,3 +2,13 @@ class App.Setting extends App.Model @configure 'Setting', 'name', 'state_current' @extend Spine.Model.Ajax @url: @apiPath + '/settings' + + @get: (name) -> + setting = App.Setting.findByAttribute('name', name) + setting.state_current.value + + @set: (name, value, options = {}) -> + setting = App.Setting.findByAttribute('name', name) + setting.state_current.value = value + setting.save(options) + App.Config.set(name, value) diff --git a/app/assets/javascripts/app/views/integration/base.jst.eco b/app/assets/javascripts/app/views/integration/base.jst.eco new file mode 100644 index 000000000..d941bd965 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/base.jst.eco @@ -0,0 +1,17 @@ + +
+ <% if @description: %> + <% for item in @description: %> +

<%- @T(item[0], item[1], item[2]) %>

+ <% end %> + <% end %> +
+
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/integration/index.jst.eco b/app/assets/javascripts/app/views/integration/index.jst.eco new file mode 100644 index 000000000..feddea317 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/index.jst.eco @@ -0,0 +1,29 @@ +
+

<%- @T(@head) %>

+
+
+ + + + + + + + + + <% for integration in @integrations: %> + + + + + + <% end %> + +
<%- @T('Service') %><%- @T('Description') %>
+ <% if !integration.state.current(): %> + <%- @Icon('status', 'inactive inline') %> + <% else: %> + <%- @Icon('status', 'ok inline') %> + <% end %> + <%= integration.name %><%= integration.description %>
+
diff --git a/app/assets/javascripts/app/views/settings/form.jst.eco b/app/assets/javascripts/app/views/settings/form.jst.eco new file mode 100644 index 000000000..8596311c9 --- /dev/null +++ b/app/assets/javascripts/app/views/settings/form.jst.eco @@ -0,0 +1,21 @@ +
+
+ + + + + +<% for setting in @settings: %> + + +
<%- @T('Title') %> + <%- @T('Value') %> + <%- @T('Description') %> +
<%- @T(setting.title) %> + +

<%- @RichText(setting.description) %>

+<% end %> +
+
+ +
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/settings/item.jst.eco b/app/assets/javascripts/app/views/settings/item.jst.eco index b4b72d923..88a89dc98 100644 --- a/app/assets/javascripts/app/views/settings/item.jst.eco +++ b/app/assets/javascripts/app/views/settings/item.jst.eco @@ -1,8 +1,8 @@
-

<%- @T( @setting.title ) %>

-

<%- @RichText( @setting.description ) %>

+

<%- @T(@setting.title) %>

+

<%- @RichText(@setting.description) %>

- +
\ No newline at end of file diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index bbe837494..4c7c0eb01 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -6962,6 +6962,7 @@ output { } th, td { + vertical-align: top; padding: 10px; border: 1px solid hsl(198,18%,86%); } diff --git a/app/models/observer/ticket/notification.rb b/app/models/observer/ticket/notification.rb deleted file mode 100644 index 35ce83c91..000000000 --- a/app/models/observer/ticket/notification.rb +++ /dev/null @@ -1,180 +0,0 @@ -# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ - -require 'event_buffer' -require 'notification_factory' - -class Observer::Ticket::Notification < ActiveRecord::Observer - observe :ticket, 'ticket::_article' - - def self.transaction(params) - - return if params[:disable_notification] - - # return if we run import mode - return if Setting.get('import_mode') - - # get buffer - list = EventBuffer.list('notification') - - # reset buffer - EventBuffer.reset('notification') - - via_web = false - if ENV['RACK_ENV'] || Rails.configuration.webserver_is_active - via_web = true - end - - # get uniq objects - list_objects = get_uniq_changes(list) - list_objects.each {|_ticket_id, item| - - # send background job - Delayed::Job.enqueue(Observer::Ticket::Notification::BackgroundJob.new(item, via_web)) - } - end - -=begin - - result = get_uniq_changes(events) - - result = { - 1 => { - type: 'create', - ticket_id: 123, - article_id: 123, - }, - 9 => { - type: 'update', - ticket_id: 123, - changes: { - attribute1: [before, now], - attribute2: [before, now], - } - }, - } - - result = { - 9 => { - type: 'update', - ticket_id: 123, - article_id: 123, - changes: { - attribute1: [before, now], - attribute2: [before, now], - } - }, - } - -=end - - def self.get_uniq_changes(events) - list_objects = {} - events.each { |event| - - # get current state of objects - if event[:name] == 'Ticket::Article' - article = Ticket::Article.lookup(id: event[:id]) - - # next if article is already deleted - next if !article - - ticket = article.ticket - if !list_objects[ticket.id] - list_objects[ticket.id] = {} - end - list_objects[ticket.id][:article_id] = article.id - list_objects[ticket.id][:ticket_id] = ticket.id - - if !list_objects[ticket.id][:type] - list_objects[ticket.id][:type] = 'update' - end - - elsif event[:name] == 'Ticket' - ticket = Ticket.lookup(id: event[:id]) - - # next if ticket is already deleted - next if !ticket - - if !list_objects[ticket.id] - list_objects[ticket.id] = {} - end - list_objects[ticket.id][:ticket_id] = ticket.id - - if !list_objects[ticket.id][:type] || list_objects[ticket.id][:type] == 'update' - list_objects[ticket.id][:type] = event[:type] - end - - # merge changes - if event[:changes] - if !list_objects[ticket.id][:changes] - list_objects[ticket.id][:changes] = event[:changes] - else - event[:changes].each {|key, value| - if !list_objects[ticket.id][:changes][key] - list_objects[ticket.id][:changes][key] = value - else - list_objects[ticket.id][:changes][key][1] = value[1] - end - } - end - end - else - raise "unknown object for notification #{event[:name]}" - end - } - list_objects - end - - def after_create(record) - - # return if we run import mode - return if Setting.get('import_mode') - - # Rails.logger.info 'CREATED!!!!' - # Rails.logger.info record.inspect - e = { - name: record.class.name, - type: 'create', - data: record, - id: record.id, - } - EventBuffer.add('notification', e) - end - - def before_update(record) - - # return if we run import mode - return if Setting.get('import_mode') - - # ignore updates on articles / we just want send notifications on ticket updates - return if record.class.name == 'Ticket::Article' - - # ignore certain attributes - real_changes = {} - record.changes.each {|key, value| - next if key == 'updated_at' - next if key == 'first_response' - next if key == 'close_time' - next if key == 'last_contact_agent' - next if key == 'last_contact_customer' - next if key == 'last_contact' - next if key == 'article_count' - next if key == 'create_article_type_id' - next if key == 'create_article_sender_id' - real_changes[key] = value - } - - # do not send anything if nothing has changed - return if real_changes.empty? - - e = { - name: record.class.name, - type: 'update', - data: record, - changes: real_changes, - id: record.id, - } - EventBuffer.add('notification', e) - end - -end diff --git a/app/models/observer/transaction.rb b/app/models/observer/transaction.rb index c3a813c68..5644a23c8 100644 --- a/app/models/observer/transaction.rb +++ b/app/models/observer/transaction.rb @@ -1,10 +1,185 @@ -class Observer::Transaction +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class Observer::Transaction < ActiveRecord::Observer + observe :ticket, 'ticket::_article' def self.commit(params = {}) - # execute ticket transactions - Observer::Ticket::Notification.transaction(params) + # add attribute if execution is via web + params[:via_web] = false + if ENV['RACK_ENV'] || Rails.configuration.webserver_is_active + params[:via_web] = true + end + # execute object transactions + Observer::Transaction.perform(params) + end + + def self.perform(params) + + # return if we run import mode + return if Setting.get('import_mode') + + # get buffer + list = EventBuffer.list('transaction') + + # reset buffer + EventBuffer.reset('transaction') + + # get uniq objects + list_objects = get_uniq_changes(list) + list_objects.each {|_id, item| + + # send background job + Delayed::Job.enqueue(Transaction::BackgroundJob.new(item, params)) + } + end + +=begin + + result = get_uniq_changes(events) + + result = { + 1 => { + object: 'Ticket', + type: 'create', + ticket_id: 123, + article_id: 123, + }, + 9 => { + object: 'Ticket', + type: 'update', + ticket_id: 123, + changes: { + attribute1: [before, now], + attribute2: [before, now], + } + }, + } + + result = { + 9 => { + object: 'Ticket', + type: 'update', + ticket_id: 123, + article_id: 123, + changes: { + attribute1: [before, now], + attribute2: [before, now], + } + }, + } + +=end + + def self.get_uniq_changes(events) + list_objects = {} + events.each { |event| + + # get current state of objects + if event[:name] == 'Ticket::Article' + article = Ticket::Article.lookup(id: event[:id]) + + # next if article is already deleted + next if !article + + ticket = article.ticket + if !list_objects[ticket.id] + list_objects[ticket.id] = {} + end + list_objects[ticket.id][:object] = 'Ticket' + list_objects[ticket.id][:article_id] = article.id + list_objects[ticket.id][:ticket_id] = ticket.id + + if !list_objects[ticket.id][:type] + list_objects[ticket.id][:type] = 'update' + end + + elsif event[:name] == 'Ticket' + ticket = Ticket.lookup(id: event[:id]) + + # next if ticket is already deleted + next if !ticket + + if !list_objects[ticket.id] + list_objects[ticket.id] = {} + end + list_objects[ticket.id][:object] = 'Ticket' + list_objects[ticket.id][:ticket_id] = ticket.id + + if !list_objects[ticket.id][:type] || list_objects[ticket.id][:type] == 'update' + list_objects[ticket.id][:type] = event[:type] + end + + # merge changes + if event[:changes] + if !list_objects[ticket.id][:changes] + list_objects[ticket.id][:changes] = event[:changes] + else + event[:changes].each {|key, value| + if !list_objects[ticket.id][:changes][key] + list_objects[ticket.id][:changes][key] = value + else + list_objects[ticket.id][:changes][key][1] = value[1] + end + } + end + end + else + raise "unknown object for integration #{event[:name]}" + end + } + list_objects + end + + def after_create(record) + + # return if we run import mode + return if Setting.get('import_mode') + + e = { + name: record.class.name, + type: 'create', + data: record, + id: record.id, + } + EventBuffer.add('transaction', e) + end + + def before_update(record) + + # return if we run import mode + return if Setting.get('import_mode') + + # ignore updates on articles / we just want send integrations on ticket updates + return if record.class.name == 'Ticket::Article' + + # ignore certain attributes + real_changes = {} + record.changes.each {|key, value| + next if key == 'updated_at' + next if key == 'first_response' + next if key == 'close_time' + next if key == 'last_contact_agent' + next if key == 'last_contact_customer' + next if key == 'last_contact' + next if key == 'article_count' + next if key == 'create_article_type_id' + next if key == 'create_article_sender_id' + real_changes[key] = value + } + + # do not send anything if nothing has changed + return if real_changes.empty? + + e = { + name: record.class.name, + type: 'update', + data: record, + changes: real_changes, + id: record.id, + } + EventBuffer.add('transaction', e) end end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 830be69b5..05530bdf8 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -180,12 +180,12 @@ returns tickets.each { |ticket| # send notification - bg = Observer::Ticket::Notification::BackgroundJob.new( + Transaction::BackgroundJob.run( + object: 'Ticket', + type: 'reminder_reached', ticket_id: ticket.id, article_id: ticket.articles.last.id, - type: 'reminder_reached', ) - bg.perform result.push ticket } @@ -220,23 +220,23 @@ returns # send escalation if ticket.escalation_time < Time.zone.now - bg = Observer::Ticket::Notification::BackgroundJob.new( + Transaction::BackgroundJob.run( + object: 'Ticket', + type: 'escalation', ticket_id: ticket.id, article_id: ticket.articles.last.id, - type: 'escalation', ) - bg.perform result.push ticket next end # check if warning need to be sent - bg = Observer::Ticket::Notification::BackgroundJob.new( + Transaction::BackgroundJob.run( + object: 'Ticket', + type: 'escalation_warning', ticket_id: ticket.id, article_id: ticket.articles.last.id, - type: 'escalation_warning', ) - bg.perform result.push ticket } result diff --git a/app/models/transaction.rb b/app/models/transaction.rb new file mode 100644 index 000000000..9dc9b0b8b --- /dev/null +++ b/app/models/transaction.rb @@ -0,0 +1,3 @@ +class Transaction + +end diff --git a/app/models/transaction/background_job.rb b/app/models/transaction/background_job.rb new file mode 100644 index 000000000..263a269a3 --- /dev/null +++ b/app/models/transaction/background_job.rb @@ -0,0 +1,36 @@ +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class Transaction::BackgroundJob + def initialize(item, params = {}) + +=begin + { + object: 'Ticket', + type: 'update', + ticket_id: 123, + via_web: true, + changes: { + 'attribute1' => [before,now], + 'attribute2' => [before,now], + } + }, +=end + + @item = item + @params = params + end + + def perform + Setting.where(area: 'Transaction::Backend').order(:name).each {|setting| + backend = Setting.get(setting.name) + integration = Kernel.const_get(backend).new(@item, @params) + integration.perform + } + end + + def self.run(item, params = {}) + generic = new(item, params) + generic.perform + end + +end diff --git a/app/models/observer/ticket/notification/background_job.rb b/app/models/transaction/notification.rb similarity index 82% rename from app/models/observer/ticket/notification/background_job.rb rename to app/models/transaction/notification.rb index 72b0e197c..1d2780faa 100644 --- a/app/models/observer/ticket/notification/background_job.rb +++ b/app/models/transaction/notification.rb @@ -1,24 +1,29 @@ -# encoding: utf-8 +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ -class Observer::Ticket::Notification::BackgroundJob - def initialize(params, via_web = false) +class Transaction::Notification =begin + { + object: 'Ticket', type: 'update', ticket_id: 123, + via_web: true, changes: { - 'attribute1' => [before,now], - 'attribute2' => [before,now], + 'attribute1' => [before, now], + 'attribute2' => [before, now], } + }, =end - @p = params - @via_web = via_web + + def initialize(item, params = {}) + @item = item + @params = params end def perform - ticket = Ticket.find(@p[:ticket_id]) - if @p[:article_id] - article = Ticket::Article.find(@p[:article_id]) + ticket = Ticket.find(@item[:ticket_id]) + if @item[:article_id] + article = Ticket::Article.find(@item[:article_id]) end # find recipients @@ -59,7 +64,7 @@ class Observer::Ticket::Notification::BackgroundJob end already_checked_recipient_ids = {} possible_recipients.each {|user| - result = NotificationFactory::Mailer.notification_settings(user, ticket, @p[:type]) + result = NotificationFactory::Mailer.notification_settings(user, ticket, @item[:type]) next if !result next if already_checked_recipient_ids[result[:user].id] already_checked_recipient_ids[result[:user].id] = true @@ -73,7 +78,7 @@ class Observer::Ticket::Notification::BackgroundJob channels = item[:channels] # ignore user who changed it by him self via web - if @via_web + if @params[:via_web] next if article && article.updated_by_id == user.id next if !article && ticket.updated_by_id == user.id end @@ -83,10 +88,10 @@ class Observer::Ticket::Notification::BackgroundJob # ignore if no changes has been done changes = human_changes(user, ticket) - next if @p[:type] == 'update' && !article && (!changes || changes.empty?) + next if @item[:type] == 'update' && !article && (!changes || changes.empty?) # check if today already notified - if @p[:type] == 'reminder_reached' || @p[:type] == 'escalation' || @p[:type] == 'escalation_warning' + if @item[:type] == 'reminder_reached' || @item[:type] == 'escalation' || @item[:type] == 'escalation_warning' identifier = user.email if !identifier || identifier == '' identifier = user.login @@ -94,7 +99,7 @@ class Observer::Ticket::Notification::BackgroundJob already_notified = false History.list('Ticket', ticket.id).each {|history| next if history['type'] != 'notification' - next if history['value_to'] !~ /\(#{Regexp.escape(@p[:type])}:/ + next if history['value_to'] !~ /\(#{Regexp.escape(@item[:type])}:/ next if history['value_to'] !~ /#{Regexp.escape(identifier)}\(/ next if !history['created_at'].today? already_notified = true @@ -110,59 +115,59 @@ class Observer::Ticket::Notification::BackgroundJob created_by_id = ticket.updated_by_id || 1 # delete old notifications - if @p[:type] == 'reminder_reached' + if @item[:type] == 'reminder_reached' seen = false created_by_id = 1 - OnlineNotification.remove_by_type('Ticket', ticket.id, @p[:type], user) + OnlineNotification.remove_by_type('Ticket', ticket.id, @item[:type], user) - elsif @p[:type] == 'escalation' || @p[:type] == 'escalation_warning' + elsif @item[:type] == 'escalation' || @item[:type] == 'escalation_warning' seen = false created_by_id = 1 OnlineNotification.remove_by_type('Ticket', ticket.id, 'escalation', user) OnlineNotification.remove_by_type('Ticket', ticket.id, 'escalation_warning', user) # on updates without state changes create unseen messages - elsif @p[:type] != 'create' && (!@p[:changes] || @p[:changes].empty? || !@p[:changes]['state_id']) + elsif @item[:type] != 'create' && (!@item[:changes] || @item[:changes].empty? || !@item[:changes]['state_id']) seen = false else seen = ticket.online_notification_seen_state(user.id) end OnlineNotification.add( - type: @p[:type], + type: @item[:type], object: 'Ticket', o_id: ticket.id, seen: seen, created_by_id: created_by_id, user_id: user.id, ) - Rails.logger.debug "sent ticket online notifiaction to agent (#{@p[:type]}/#{ticket.id}/#{user.email})" + Rails.logger.debug "sent ticket online notifiaction to agent (#{@item[:type]}/#{ticket.id}/#{user.email})" end # ignore email channel notificaiton and empty emails if !channels['email'] || !user.email || user.email == '' - add_recipient_list(ticket, user, used_channels, @p[:type]) + add_recipient_list(ticket, user, used_channels, @item[:type]) next end used_channels.push 'email' - add_recipient_list(ticket, user, used_channels, @p[:type]) + add_recipient_list(ticket, user, used_channels, @item[:type]) # get user based notification template # if create, send create message / block update messages template = nil - if @p[:type] == 'create' + if @item[:type] == 'create' template = 'ticket_create' - elsif @p[:type] == 'update' + elsif @item[:type] == 'update' template = 'ticket_update' - elsif @p[:type] == 'reminder_reached' + elsif @item[:type] == 'reminder_reached' template = 'ticket_reminder_reached' - elsif @p[:type] == 'escalation' + elsif @item[:type] == 'escalation' template = 'ticket_escalation' - elsif @p[:type] == 'escalation_warning' + elsif @item[:type] == 'escalation_warning' template = 'ticket_escalation_warning' else - raise "unknown type for notification #{@p[:type]}" + raise "unknown type for notification #{@item[:type]}" end NotificationFactory::Mailer.notification( @@ -177,7 +182,7 @@ class Observer::Ticket::Notification::BackgroundJob references: ticket.get_references, main_object: ticket, ) - Rails.logger.debug "sent ticket email notifiaction to agent (#{@p[:type]}/#{ticket.id}/#{user.email})" + Rails.logger.debug "sent ticket email notifiaction to agent (#{@item[:type]}/#{ticket.id}/#{user.email})" end end @@ -200,14 +205,14 @@ class Observer::Ticket::Notification::BackgroundJob def human_changes(user, record) - return {} if !@p[:changes] + return {} if !@item[:changes] locale = user.preferences[:locale] || 'en-us' # only show allowed attributes attribute_list = ObjectManager::Attribute.by_object_as_hash('Ticket', user) #puts "AL #{attribute_list.inspect}" user_related_changes = {} - @p[:changes].each {|key, value| + @item[:changes].each {|key, value| # if no config exists, use all attributes if !attribute_list || attribute_list.empty? diff --git a/app/models/transaction/slack.rb b/app/models/transaction/slack.rb new file mode 100644 index 000000000..2499f7a18 --- /dev/null +++ b/app/models/transaction/slack.rb @@ -0,0 +1,227 @@ +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class Transaction::Slack +=begin + { + object: 'Ticket', + type: 'update', + ticket_id: 123, + via_web: true, + changes: { + 'attribute1' => [before, now], + 'attribute2' => [before, now], + } + }, +=end + def initialize(item, params = {}) + @item = item + @params = params + end + + def perform + return if @item[:object] != 'Ticket' + return if !Setting.get('slack_integration') + logo_url = "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/assets/images/#{Setting.get('product_logo')}" + + config = Setting.get('slack_config') + return if !config + return if !config['items'] + + ticket = Ticket.find(@item[:ticket_id]) + if @item[:article_id] + article = Ticket::Article.find(@item[:article_id]) + end + + # ignore if no changes has been done + changes = human_changes(ticket) + return if @item[:type] == 'update' && !article && (!changes || changes.empty?) + + # get user based notification template + # if create, send create message / block update messages + template = nil + if @item[:type] == 'create' + template = 'ticket_create' + elsif @item[:type] == 'update' + template = 'ticket_update' + elsif @item[:type] == 'reminder_reached' + template = 'ticket_reminder_reached' + elsif @item[:type] == 'escalation' + template = 'ticket_escalation' + elsif @item[:type] == 'escalation_warning' + template = 'ticket_escalation_warning' + else + raise "unknown type for notification #{@item[:type]}" + end + + user = User.find(1) + result = NotificationFactory::Slack.template( + template: template, + locale: user[:preferences][:locale], + objects: { + ticket: ticket, + article: article, + changes: changes, + }, + ) + + # good, warning, danger + color = '#000000' + ticket_state_type = ticket.state.state_type.name + if ticket.escalation_time && ticket.escalation_time > Time.zone.now + color = '#f35912' + elsif ticket_state_type == 'pending reminder' + if ticket.pending_time && ticket.pending_time < Time.zone.now + color = '#faab00' + end + elsif ticket_state_type =~ /^(new|open)$/ + color = '#faab00' + elsif ticket_state_type == 'closed' + color = '#38ad69' + end + + config['items'].each {|item| + + # check action + if item['types'] + hit = false + item['types'].each {|type| + next if type.to_s != @item[:type].to_s + hit = true + break + } + next if !hit + end + + # check group + if item['group_ids'] + hit = false + item['group_ids'].each {|group_id| + next if group_id.to_s != ticket.group_id.to_s + hit = true + break + } + next if !hit + end + + Rails.logger.debug "sent webhook (#{@item[:type]}/#{ticket.id}/#{item['webhook']})" + notifier = Slack::Notifier.new( + item['webhook'], + channel: item['channel'], + username: item['username'], + icon_url: logo_url, + mrkdwn: true, + ) + if item['expand'] + body = "#{result[:subject]}\n#{result[:body]}" + result = notifier.ping body + else + attachment = { + text: result[:body], + mrkdwn_in: ['text'], + color: color, + } + result = notifier.ping result[:subject], + attachments: [attachment] + end + if !result + Rails.logger.error "Unable to post webhook: #{item['webhook']}" + end + if result.code.to_s != '200' && result.code.to_s != '201' + Rails.logger.error "Unable to post webhook: #{item['webhook']}: #{result.inspect}" + end + Rails.logger.debug "sent webhook (#{@item[:type]}/#{ticket.id}/#{item['webhook']})" + } + + end + + def human_changes(record) + + return {} if !@item[:changes] + user = User.find(1) + locale = user.preferences[:locale] || 'en-us' + + # only show allowed attributes + attribute_list = ObjectManager::Attribute.by_object_as_hash('Ticket', user) + #puts "AL #{attribute_list.inspect}" + user_related_changes = {} + @item[:changes].each {|key, value| + + # if no config exists, use all attributes + if !attribute_list || attribute_list.empty? + user_related_changes[key] = value + + # if config exists, just use existing attributes for user + elsif attribute_list[key.to_s] + user_related_changes[key] = value + end + } + + changes = {} + user_related_changes.each {|key, value| + + # get attribute name + attribute_name = key.to_s + object_manager_attribute = attribute_list[attribute_name] + if attribute_name[-3, 3] == '_id' + attribute_name = attribute_name[ 0, attribute_name.length - 3 ].to_s + end + + # add item to changes hash + if key.to_s == attribute_name + changes[attribute_name] = value + end + + # if changed item is an _id field/reference, do an lookup for the realy values + value_id = [] + value_str = [ value[0], value[1] ] + if key.to_s[-3, 3] == '_id' + value_id[0] = value[0] + value_id[1] = value[1] + + if record.respond_to?(attribute_name) && record.send(attribute_name) + relation_class = record.send(attribute_name).class + if relation_class && value_id[0] + relation_model = relation_class.lookup(id: value_id[0]) + if relation_model + if relation_model['name'] + value_str[0] = relation_model['name'] + elsif relation_model.respond_to?('fullname') + value_str[0] = relation_model.send('fullname') + end + end + end + if relation_class && value_id[1] + relation_model = relation_class.lookup(id: value_id[1]) + if relation_model + if relation_model['name'] + value_str[1] = relation_model['name'] + elsif relation_model.respond_to?('fullname') + value_str[1] = relation_model.send('fullname') + end + end + end + end + end + + # check if we have an dedcated display name for it + display = attribute_name + if object_manager_attribute && object_manager_attribute[:display] + + # delete old key + changes.delete(display) + + # set new key + display = object_manager_attribute[:display].to_s + end + changes[display] = if object_manager_attribute && object_manager_attribute[:translate] + from = Translation.translate(locale, value_str[0]) + to = Translation.translate(locale, value_str[1]) + [from, to] + else + [value_str[0].to_s, value_str[1].to_s] + end + } + changes + end + +end diff --git a/app/views/mailer/ticket_create/de.html.erb b/app/views/mailer/ticket_create/de.html.erb index d79f442b5..8806b0875 100644 --- a/app/views/mailer/ticket_create/de.html.erb +++ b/app/views/mailer/ticket_create/de.html.erb @@ -14,7 +14,7 @@ Neues Ticket (<%= d 'ticket.title' %>)

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_create/en.html.erb b/app/views/mailer/ticket_create/en.html.erb index 06cb4bb28..7a71d711d 100644 --- a/app/views/mailer/ticket_create/en.html.erb +++ b/app/views/mailer/ticket_create/en.html.erb @@ -14,7 +14,7 @@ New Ticket (<%= d 'ticket.title' %>)

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_escalation/de.html.erb b/app/views/mailer/ticket_escalation/de.html.erb index 1b33dcddf..c32ac9300 100644 --- a/app/views/mailer/ticket_escalation/de.html.erb +++ b/app/views/mailer/ticket_escalation/de.html.erb @@ -8,7 +8,7 @@ Ticket ist eskaliert (<%= d 'ticket.title' %>)

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_escalation/en.html.erb b/app/views/mailer/ticket_escalation/en.html.erb index 017472a7e..4bda1e9c2 100644 --- a/app/views/mailer/ticket_escalation/en.html.erb +++ b/app/views/mailer/ticket_escalation/en.html.erb @@ -8,7 +8,7 @@ Ticket is escalated (<%= d 'ticket.title' %>)

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_escalation_warning/de.html.erb b/app/views/mailer/ticket_escalation_warning/de.html.erb index 4ee2e9671..b257a2142 100644 --- a/app/views/mailer/ticket_escalation_warning/de.html.erb +++ b/app/views/mailer/ticket_escalation_warning/de.html.erb @@ -8,7 +8,7 @@ Ticket wird eskalieren (<%= d 'ticket.title' %>)

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_escalation_warning/en.html.erb b/app/views/mailer/ticket_escalation_warning/en.html.erb index d376e0c20..341663a1b 100644 --- a/app/views/mailer/ticket_escalation_warning/en.html.erb +++ b/app/views/mailer/ticket_escalation_warning/en.html.erb @@ -1,14 +1,14 @@ -Ticket will escalated (<%= d 'ticket.title' %>) +Ticket will escalate (<%= d 'ticket.title' %>)

Hi <%= d 'recipient.firstname' %>,


-

a ticket (<%= d 'ticket.title' %>) from "<%= d 'ticket.customer.longname' %>" will escalate at "<%= d 'ticket.escalation_time' %>"!

+

A ticket (<%= d 'ticket.title' %>) from "<%= d 'ticket.customer.longname' %>" will escalate at "<%= d 'ticket.escalation_time' %>"!


<% if @objects[:article] %>

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_reminder_reached/de.html.erb b/app/views/mailer/ticket_reminder_reached/de.html.erb index 5e0c98911..e919e7dfa 100644 --- a/app/views/mailer/ticket_reminder_reached/de.html.erb +++ b/app/views/mailer/ticket_reminder_reached/de.html.erb @@ -8,7 +8,7 @@ Warten auf Erinnerung erreicht! (<%= d 'ticket.title' %>)

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_reminder_reached/en.html.erb b/app/views/mailer/ticket_reminder_reached/en.html.erb index 91830d753..255614252 100644 --- a/app/views/mailer/ticket_reminder_reached/en.html.erb +++ b/app/views/mailer/ticket_reminder_reached/en.html.erb @@ -2,13 +2,13 @@ Reminder reached (<%= d 'ticket.title' %>)

Hi <%= d 'recipient.firstname' %>,


-

Ticket needs attention, reminder reached for ticket (<%= d 'ticket.title' %>) with customer "<%= d 'ticket.customer.longname' %>".

+

A ticket needs attention, reminder reached for (<%= d 'ticket.title' %>) with customer "<%= d 'ticket.customer.longname' %>".


<% if @objects[:article] %>

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_update/de.html.erb b/app/views/mailer/ticket_update/de.html.erb index 8f47080e3..576be3f21 100644 --- a/app/views/mailer/ticket_update/de.html.erb +++ b/app/views/mailer/ticket_update/de.html.erb @@ -19,7 +19,7 @@ Ticket (<%= d 'ticket.title' %>) wurde von "<%= d 'ticket.updated_by.longname

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/mailer/ticket_update/en.html.erb b/app/views/mailer/ticket_update/en.html.erb index 000701a03..df29d9ac0 100644 --- a/app/views/mailer/ticket_update/en.html.erb +++ b/app/views/mailer/ticket_update/en.html.erb @@ -19,7 +19,7 @@ Ticket (<%= d 'ticket.title' %>) has been updated by "<%= d 'ticket.updated_b

<%= t 'Information' %>:

- <%= a 'article' %> + <%= a_html 'article' %>

<% end %> diff --git a/app/views/slack/application.md.erb b/app/views/slack/application.md.erb new file mode 100644 index 000000000..3fec9ad1d --- /dev/null +++ b/app/views/slack/application.md.erb @@ -0,0 +1 @@ +<%= d 'message', false %> \ No newline at end of file diff --git a/app/views/slack/ticket_create/en.md.erb b/app/views/slack/ticket_create/en.md.erb new file mode 100644 index 000000000..594fac928 --- /dev/null +++ b/app/views/slack/ticket_create/en.md.erb @@ -0,0 +1,9 @@ +# <%= d 'ticket.title' %> +_<<%= c 'http_type' %>://<%= c 'fqdn' %>/#ticket/zoom/<%= d 'ticket.id' %>|Ticket#<%= d 'ticket.number' %>>: Created by <%= d 'ticket.updated_by.longname' %> at <%= d 'ticket.updated_at' %>_ +* <%= t 'Group' %>: <%= d 'ticket.group.name' %> +* <%= t 'Owner' %>: <%= d 'ticket.owner.fullname' %> +* <%= t 'State' %>: <%= t d 'ticket.state.name' %> + +<% if @objects[:article] %> +<%= a_text 'article' %> +<% end %> diff --git a/app/views/slack/ticket_escalation/en.md.erb b/app/views/slack/ticket_escalation/en.md.erb new file mode 100644 index 000000000..330998489 --- /dev/null +++ b/app/views/slack/ticket_escalation/en.md.erb @@ -0,0 +1,7 @@ +# <%= d 'ticket.title' %> +_<<%= c 'http_type' %>://<%= c 'fqdn' %>/#ticket/zoom/<%= d 'ticket.id' %>|Ticket#<%= d 'ticket.number' %>>: Escalated at <%= d 'ticket.escalation_time' %>_ +A ticket (<%= d 'ticket.title' %>) from "<%= d 'ticket.customer.longname' %>" is escalated since "<%= d 'ticket.escalation_time' %>"! + +<% if @objects[:article] %> +<%= a_text 'article' %> +<% end %> diff --git a/app/views/slack/ticket_escalation_warning/en.md.erb b/app/views/slack/ticket_escalation_warning/en.md.erb new file mode 100644 index 000000000..15f48c0a3 --- /dev/null +++ b/app/views/slack/ticket_escalation_warning/en.md.erb @@ -0,0 +1,7 @@ +# <%= d 'ticket.title' %> +_<<%= c 'http_type' %>://<%= c 'fqdn' %>/#ticket/zoom/<%= d 'ticket.id' %>|Ticket#<%= d 'ticket.number' %>>: Will escalate at <%= d 'ticket.escalation_time' %>_ +A ticket (<%= d 'ticket.title' %>) from "<%= d 'ticket.customer.longname' %>" will escalate at "<%= d 'ticket.escalation_time' %>"! + +<% if @objects[:article] %> +<%= a_text 'article' %> +<% end %> diff --git a/app/views/slack/ticket_reminder_reached/en.md.erb b/app/views/slack/ticket_reminder_reached/en.md.erb new file mode 100644 index 000000000..8ae47fc71 --- /dev/null +++ b/app/views/slack/ticket_reminder_reached/en.md.erb @@ -0,0 +1,7 @@ +# <%= d 'ticket.title' %> +_<<%= c 'http_type' %>://<%= c 'fqdn' %>/#ticket/zoom/<%= d 'ticket.id' %>|Ticket#<%= d 'ticket.number' %>>: Reminder reached!_ +A ticket needs attention, reminder reached for (<%= d 'ticket.title' %>) with customer "*<%= d 'ticket.customer.longname' %>*". + +<% if @objects[:article] %> +<%= a_text 'article' %> +<% end %> diff --git a/app/views/slack/ticket_update/en.md.erb b/app/views/slack/ticket_update/en.md.erb new file mode 100644 index 000000000..e391ad115 --- /dev/null +++ b/app/views/slack/ticket_update/en.md.erb @@ -0,0 +1,11 @@ +# <%= d 'ticket.title' %> +_<<%= c 'http_type' %>://<%= c 'fqdn' %>/#ticket/zoom/<%= d 'ticket.id' %>|Ticket#<%= d 'ticket.number' %>>: Updated by <%= d 'ticket.updated_by.longname' %> at <%= d 'ticket.updated_at' %>_ +<% if @objects[:changes] && !@objects[:changes].empty? %> + <% @objects[:changes].each do |key, value| %> + * <%= t key %>: <%= h value[0] %> -> <%= h value[1] %> + <% end %> +<% end %> + +<% if @objects[:article] %> +<%= a_text 'article' %> +<% end %> diff --git a/config/application.rb b/config/application.rb index 59a17ebcf..4228be027 100644 --- a/config/application.rb +++ b/config/application.rb @@ -31,7 +31,6 @@ module Zammad 'observer::_ticket::_article::_communicate_facebook', 'observer::_ticket::_article::_communicate_twitter', 'observer::_ticket::_article::_signature_detection', - 'observer::_ticket::_notification', 'observer::_ticket::_reset_new_state', 'observer::_ticket::_escalation_calculation', 'observer::_ticket::_ref_object_touch', @@ -42,7 +41,8 @@ module Zammad 'observer::_user::_ticket_organization', 'observer::_user::_geo', 'observer::_organization::_ref_object_touch', - 'observer::_sla::_ticket_rebuild_escalation' + 'observer::_sla::_ticket_rebuild_escalation', + 'observer::_transaction' # REST api path config.api_path = '/api/v1' diff --git a/db/migrate/20160415000001_add_slack_integration.rb b/db/migrate/20160415000001_add_slack_integration.rb new file mode 100644 index 000000000..c9fefc581 --- /dev/null +++ b/db/migrate/20160415000001_add_slack_integration.rb @@ -0,0 +1,144 @@ +class AddSlackIntegration < ActiveRecord::Migration + def up + Setting.create_or_update( + title: 'Icinga integration', + name: 'icinga_integration', + area: 'Integration::Switch', + description: 'Define if Icinga (http://www.icinga.org) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'icinga_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { prio: 1 }, + frontend: false + ) + Setting.create_or_update( + title: 'Sender', + name: 'icinga_sender', + area: 'Integration::Icinga', + description: 'Define the sender email address of Icinga emails.', + options: { + form: [ + { + display: '', + null: false, + name: 'icinga_sender', + tag: 'input', + placeholder: 'icinga@monitoring.example.com', + }, + ], + }, + state: 'icinga@monitoring.example.com', + frontend: false, + preferences: { prio: 2 }, + ) + Setting.create_or_update( + title: 'Nagios integration', + name: 'nagios_integration', + area: 'Integration::Switch', + description: 'Define if Nagios (http://www.nagios.org) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'nagios_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { prio: 1 }, + frontend: false + ) + Setting.create_or_update( + title: 'Sender', + name: 'nagios_sender', + area: 'Integration::Nagios', + description: 'Define the sender email address of Nagios emails.', + options: { + form: [ + { + display: '', + null: false, + name: 'nagios_sender', + tag: 'input', + placeholder: 'nagios@monitoring.example.com', + }, + ], + }, + state: 'nagios@monitoring.example.com', + frontend: false, + preferences: { prio: 2 }, + ) + + Setting.create_or_update( + title: 'Define transaction backend.', + name: '0100_notification', + area: 'Transaction::Backend', + description: 'Define the transaction backend to send agent notifications.', + options: {}, + state: 'Transaction::Notification', + frontend: false + ) + Setting.create_or_update( + title: 'Define transaction backend.', + name: '6000_slack_webhook', + area: 'Transaction::Backend', + description: 'Define the transaction backend which posts messages to (http://www.slack.com).', + options: {}, + state: 'Transaction::Slack', + frontend: false + ) + Setting.create_if_not_exists( + title: 'Slack integration', + name: 'slack_integration', + area: 'Integration::Slack', + description: 'Define if Slack (http://www.slack.org) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'slack_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { prio: 1 }, + frontend: false + ) + Setting.create_or_update( + title: 'Slack config', + name: 'slack_config', + area: 'Integration::Slack', + description: 'Define the slack config.', + options: {}, + state: { + items: [] + }, + frontend: false, + preferences: { prio: 2 }, + ) + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 1d87da6a3..f13859168 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1600,7 +1600,7 @@ Setting.create_if_not_exists( Setting.create_if_not_exists( title: 'Icinga integration', name: 'icinga_integration', - area: 'Integration::Icinga', + area: 'Integration::Switch', description: 'Define if Icinga (http://www.icinga.org) is enabled or not.', options: { form: [ @@ -1632,6 +1632,7 @@ Setting.create_if_not_exists( null: false, name: 'icinga_sender', tag: 'input', + placeholder: 'icinga@monitoring.example.com', }, ], }, @@ -1685,7 +1686,7 @@ Setting.create_if_not_exists( Setting.create_if_not_exists( title: 'Nagios integration', name: 'nagios_integration', - area: 'Integration::Nagios', + area: 'Integration::Switch', description: 'Define if Nagios (http://www.nagios.org) is enabled or not.', options: { form: [ @@ -1717,6 +1718,7 @@ Setting.create_if_not_exists( null: false, name: 'nagios_sender', tag: 'input', + placeholder: 'nagios@monitoring.example.com', }, ], }, @@ -1767,6 +1769,59 @@ Setting.create_if_not_exists( preferences: { prio: 4 }, frontend: false ) +Setting.create_if_not_exists( + title: 'Define transaction backend.', + name: '0100_notification', + area: 'Transaction::Backend', + description: 'Define the transaction backend to send agent notifications.', + options: {}, + state: 'Transaction::Notification', + frontend: false +) +Setting.create_if_not_exists( + title: 'Define transaction backend.', + name: '6000_slack_webhook', + area: 'Transaction::Backend', + description: 'Define the transaction backend which posts messages to (http://www.slack.com).', + options: {}, + state: 'Transaction::Slack', + frontend: false +) +Setting.create_if_not_exists( + title: 'Slack integration', + name: 'slack_integration', + area: 'Integration::Switch', + description: 'Define if Slack (http://www.slack.org) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'slack_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { prio: 1 }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Slack config', + name: 'slack_config', + area: 'Integration::Slack', + description: 'Define the slack config.', + options: {}, + state: { + items: [] + }, + frontend: false, + preferences: { prio: 2 }, +) signature = Signature.create_if_not_exists( id: 1, diff --git a/lib/notification_factory.rb b/lib/notification_factory.rb index 0311720f5..26de217e3 100644 --- a/lib/notification_factory.rb +++ b/lib/notification_factory.rb @@ -5,8 +5,17 @@ module NotificationFactory result = NotificationFactory.template_read( template: 'password_reset', locale: 'en-us', - format: 'html', # md - type: 'mailer', # slack + format: 'html', + type: 'mailer', + ) + +or + + result = NotificationFactory.template_read( + template: 'ticket_update', + locale: 'en-us', + format: 'md', + type: 'slack', ) returns @@ -56,8 +65,15 @@ returns =begin string = NotificationFactory.application_template_read( - format: 'html', # md - type: 'mailer', # slack + format: 'html', + type: 'mailer', + ) + +or + + string = NotificationFactory.application_template_read( + format: 'md', + type: 'slack', ) returns diff --git a/lib/notification_factory/slack.rb b/lib/notification_factory/slack.rb new file mode 100644 index 000000000..895b2ff05 --- /dev/null +++ b/lib/notification_factory/slack.rb @@ -0,0 +1,54 @@ +class NotificationFactory::Slack + +=begin + + result = NotificationFactory::Slack.template( + template: 'ticket_update', + locale: 'en-us', + objects: { + recipient: User.find(2), + ticket: Ticket.find(1) + }, + ) + +returns + + { + subject: 'some subject', + body: 'some body', + } + +=end + + def self.template(data) + + if data[:templateInline] + return NotificationFactory::Template.new(data[:objects], data[:locale], data[:templateInline]).render + end + + template = NotificationFactory.template_read( + locale: data[:locale] || 'en', + template: data[:template], + format: 'md', + type: 'slack', + ) + + message_subject = NotificationFactory::Template.new(data[:objects], data[:locale], template[:subject]).render + message_body = NotificationFactory::Template.new(data[:objects], data[:locale], template[:body]).render + + if !data[:raw] + application_template = NotificationFactory.application_template_read( + format: 'md', + type: 'slack', + ) + data[:objects][:message] = message_body + data[:objects][:standalone] = data[:standalone] + message_body = NotificationFactory::Template.new(data[:objects], data[:locale], application_template).render + end + { + subject: message_subject.strip!, + body: message_body.strip!, + } + end + +end diff --git a/lib/notification_factory/template.rb b/lib/notification_factory/template.rb index e953d1e27..a0244a6ce 100644 --- a/lib/notification_factory/template.rb +++ b/lib/notification_factory/template.rb @@ -11,6 +11,8 @@ class NotificationFactory::Template ERB.new(@template).result(binding) end + # d - data of object + # d('user.firstname', htmlEscape) def d(key, escape = nil) # do validaton, ignore some methodes @@ -45,19 +47,25 @@ class NotificationFactory::Template h placeholder end + # c - config + # c('fqdn', htmlEscape) def c(key, escape = nil) config = Setting.get(key) return config if escape == false || (escape.nil? && !@escape) h config end + # t - translation + # t('yes', htmlEscape) def t(key, escape = nil) translation = Translation.translate(@locale, key) return translation if escape == false || (escape.nil? && !@escape) h translation end - def a(article) + # a_html - article body in html + # a_html(article) + def a_html(article) content_type = d "#{article}.content_type", false if content_type =~ /html/ return d "#{article}.body", false @@ -65,6 +73,19 @@ class NotificationFactory::Template d("#{article}.body", false).text2html end + # a_text - article body in text + # a_text(article) + def a_text(article) + content_type = d "#{article}.content_type", false + body = d "#{article}.body", false + if content_type =~ /html/ + body = body.html2text + end + (body.strip + "\n").gsub(/^(.*?)$/, '> \\1') + end + + # h - htmlEscape + # h('fqdn', htmlEscape) def h(key) return key if !key CGI.escapeHTML(key.to_s) diff --git a/test/integration/slack_test.rb b/test/integration/slack_test.rb new file mode 100644 index 000000000..33320dfdb --- /dev/null +++ b/test/integration/slack_test.rb @@ -0,0 +1,192 @@ +# encoding: utf-8 +require 'integration_test_helper' +require 'slack' + +class SlackTest < ActiveSupport::TestCase + + # needed to check correct behavior + slack_group = Group.create_if_not_exists( + name: 'Slack', + updated_by_id: 1, + created_by_id: 1 + ) + + # check + test 'base' do + + if !ENV['SLACK_CI_CHANNEL'] + raise "ERROR: Need SLACK_CI_CHANNEL - hint SLACK_CI_CHANNEL='ci-zammad'" + end + if !ENV['SLACK_CI_WEBHOOK'] + raise "ERROR: Need SLACK_CI_WEBHOOK - hint SLACK_CI_WEBHOOK='https://hooks.slack.com/services/...'" + end + if !ENV['SLACK_CI_CHECKER_TOKEN'] + raise "ERROR: Need SLACK_CI_CHECKER_TOKEN - hint SLACK_CI_CHECKER_TOKEN='...'" + end + + channel = ENV['SLACK_CI_CHANNEL'] + webhook = ENV['SLACK_CI_WEBHOOK'] + + # set system mode to done / to activate + Setting.set('system_init_done', true) + Setting.set('slack_integration', true) + + items = [ + { + group_ids: [slack_group.id], + types: %w(create update), + webhook: webhook, + channel: channel, + username: 'zammad bot', + expand: false, + } + ] + Setting.set('slack_config', { items: items }) + + # case 1 + customer = User.find(2) + hash = hash_gen + text = "#{rand_word}... #{hash}" + + default_group = Group.first + ticket1 = Ticket.create( + title: text, + customer_id: customer.id, + group_id: default_group.id, + state: Ticket::State.find_by(name: 'new'), + priority: Ticket::Priority.find_by(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + article1 = Ticket::Article.create( + ticket_id: ticket1.id, + body: text, + type: Ticket::Article::Type.find_by(name: 'note'), + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + internal: false, + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + Delayed::Worker.new.work_off + + # check if message exists + assert_not(slack_check(channel, hash)) + + ticket1.state = Ticket::State.find_by(name: 'open') + ticket1.save + + Observer::Transaction.commit + Delayed::Worker.new.work_off + + # check if message exists + assert_not(slack_check(channel, hash)) + + # case 2 + hash = hash_gen + text = "#{rand_word}... #{hash}" + + ticket2 = Ticket.create( + title: text, + customer_id: customer.id, + group_id: slack_group.id, + state: Ticket::State.find_by(name: 'new'), + priority: Ticket::Priority.find_by(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + article2 = Ticket::Article.create( + ticket_id: ticket2.id, + body: text, + type: Ticket::Article::Type.find_by(name: 'note'), + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + internal: false, + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + Delayed::Worker.new.work_off + + # check if message exists + assert(slack_check(channel, hash)) + + hash = hash_gen + text = "#{rand_word}... #{hash}" + + ticket2.title = text + ticket2.save + + Observer::Transaction.commit + Delayed::Worker.new.work_off + + # check if message exists + assert(slack_check(channel, hash)) + + end + + def hash_gen + (0...10).map { ('a'..'z').to_a[rand(26)] }.join + end + + def rand_word + words = [ + 'dog', + 'cat', + 'house', + 'home', + 'yesterday', + 'tomorrow', + 'new york', + 'berlin', + 'coffee script', + 'java script', + 'bob smith', + 'be open', + 'really nice', + 'stay tuned', + 'be a good boy', + 'invent new things', + ] + words[rand(words.length)] + end + + def slack_check(channel_name, search_for) + + Slack.configure do |config| + config.token = ENV['SLACK_CI_CHECKER_TOKEN'] + end + + Slack.auth_test + + client = Slack::Client.new + channels = client.channels_list['channels'] + channel_id = nil + channels.each {|channel| + next if channel['name'] != channel_name + channel_id = channel['id'] + } + if !channel_id + raise "ERROR: No such channel '#{channel_name}'" + end + + channel_history = client.channels_history(channel: channel_id) + if !channel_history + raise "ERROR: No history for channel #{channel_name}/#{channel_id}" + end + if !channel_history['messages'] + raise "ERROR: No history messages for channel #{channel_name}/#{channel_id}" + end + channel_history['messages'].each {|message| + next if !message['text'] + if message['text'] =~ /#{search_for}/i + p "SUCCESS: message with #{search_for} found!" + return true + end + } + #raise "ERROR: No such message containing #{search_for} in history of channel #{channel_name}/#{channel_id}" + false + end + +end diff --git a/test/unit/ticket_notification_test.rb b/test/unit/ticket_notification_test.rb index b9cca9088..2193d50ab 100644 --- a/test/unit/ticket_notification_test.rb +++ b/test/unit/ticket_notification_test.rb @@ -864,8 +864,8 @@ class TicketNotificationTest < ActiveSupport::TestCase ticket1.priority = Ticket::Priority.lookup(name: '3 high') ticket1.save - list = EventBuffer.list('notification') - list_objects = Observer::Ticket::Notification.get_uniq_changes(list) + list = EventBuffer.list('transaction') + list_objects = Observer::Transaction.get_uniq_changes(list) assert_equal('some notification event test 1', list_objects[ticket1.id][:changes]['title'][0]) assert_equal('some notification event test 1 - #2', list_objects[ticket1.id][:changes]['title'][1]) @@ -878,8 +878,8 @@ class TicketNotificationTest < ActiveSupport::TestCase ticket1.priority = Ticket::Priority.lookup(name: '1 low') ticket1.save - list = EventBuffer.list('notification') - list_objects = Observer::Ticket::Notification.get_uniq_changes(list) + list = EventBuffer.list('transaction') + list_objects = Observer::Transaction.get_uniq_changes(list) assert_equal('some notification event test 1', list_objects[ticket1.id][:changes]['title'][0]) assert_equal('some notification event test 1 - #2 - #3', list_objects[ticket1.id][:changes]['title'][1]) @@ -916,7 +916,7 @@ class TicketNotificationTest < ActiveSupport::TestCase ) assert(ticket1, 'ticket created - ticket notification template') - bg = Observer::Ticket::Notification::BackgroundJob.new( + bg = Transaction::Notification.new( ticket_id: ticket1.id, article_id: article.id, type: 'update', @@ -992,7 +992,7 @@ class TicketNotificationTest < ActiveSupport::TestCase assert_no_match(/pending_till/, result[:body]) assert_no_match(/i18n/, result[:body]) - bg = Observer::Ticket::Notification::BackgroundJob.new( + bg = Transaction::Notification.new( ticket_id: ticket1.id, article_id: article.id, type: 'update',