From 20a8e404840d4a743641d23e6a7ae00419668411 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Tue, 5 Sep 2017 11:49:32 +0200 Subject: [PATCH] Added initial version of Out Of Office functionality. --- .../_application_controller.coffee | 5 +- .../controllers/_profile/out_of_office.coffee | 164 ++++++++++++++++++ .../_ui_element/postmaster_set.coffee | 4 +- .../user_autocompletion_search.coffee | 2 +- .../app/controllers/layout_ref.coffee | 2 +- .../app/controllers/ticket_customer.coffee | 2 +- .../app/controllers/widget/avatar.coffee | 6 + .../javascripts/app/models/overview.coffee | 3 +- app/assets/javascripts/app/models/user.coffee | 34 +++- .../app/views/profile/out_of_office.jst.eco | 34 ++++ app/assets/stylesheets/zammad.scss | 14 ++ app/controllers/users_controller.rb | 60 +++++-- app/models/ticket/overviews.rb | 17 +- app/models/transaction/notification.rb | 58 ++++++- app/models/user.rb | 52 +++++- app/views/mailer/application.html.erb | 10 +- config/routes/user.rb | 1 + db/migrate/20120101000001_create_base.rb | 7 + db/migrate/20120101000010_create_ticket.rb | 1 + db/migrate/20170826000001_out_of_office.rb | 53 ++++++ db/seeds/overviews.rb | 28 +++ db/seeds/permissions.rb | 2 +- lib/notification_factory/mailer.rb | 62 ++++--- spec/models/user_spec.rb | 28 +++ test/integration/zendesk_import_test.rb | 4 + test/unit/ticket_notification_test.rb | 154 ++++++++++++++++ test/unit/user_out_of_office_test.rb | 131 ++++++++++++++ 27 files changed, 882 insertions(+), 56 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_profile/out_of_office.coffee create mode 100644 app/assets/javascripts/app/views/profile/out_of_office.jst.eco create mode 100644 db/migrate/20170826000001_out_of_office.rb create mode 100644 test/unit/user_out_of_office_test.rb diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee index a6cbc4204..9cfe5ff03 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.coffee @@ -344,7 +344,10 @@ class App.Controller extends Spine.Controller title: -> userId = $(@).data('id') user = App.User.find(userId) - App.Utils.htmlEscape(user.displayName()) + headline = App.Utils.htmlEscape(user.displayName()) + if user.isOutOfOffice() + headline += " (#{App.Utils.htmlEscape(user.outOfOfficeText())})" + headline content: -> userId = $(@).data('id') user = App.User.fullLocal(userId) diff --git a/app/assets/javascripts/app/controllers/_profile/out_of_office.coffee b/app/assets/javascripts/app/controllers/_profile/out_of_office.coffee new file mode 100644 index 000000000..8f796f715 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_profile/out_of_office.coffee @@ -0,0 +1,164 @@ +class Index extends App.ControllerSubContent + requiredPermission: 'user_preferences.out_of_office+ticket.agent' + header: 'Out of Office' + events: + 'submit form': 'submit' + 'click .js-disabled': 'disable' + 'click .js-enable': 'enable' + + constructor: -> + super + @render() + + render: => + user = @Session.get() + if !@localData + @localData = + out_of_office: user.out_of_office + out_of_office_start_at: user.out_of_office_start_at + out_of_office_end_at: user.out_of_office_end_at + out_of_office_replacement_id: user.out_of_office_replacement_id + out_of_office_replacement_id_completion: user.preferences.out_of_office_replacement_id_completion + out_of_office_text: user.preferences.out_of_office_text + form = $(App.view('profile/out_of_office')( + user: user + localData: @localData + placeholder: App.User.outOfOfficeTextPlaceholder() + )) + + dateStart = new App.ControllerForm( + model: + configure_attributes: + [ + name: 'out_of_office_start_at' + display: '' + tag: 'date' + past: false + future: true + null: false + ] + noFieldset: true + params: @localData + ) + form.find('.js-startDate').html(dateStart.form) + + dateEnd = new App.ControllerForm( + model: + configure_attributes: + [ + name: 'out_of_office_end_at' + display: '' + tag: 'date' + past: false + future: true + null: false + ] + noFieldset: true + params: @localData + ) + form.find('.js-endDate').html(dateEnd.form) + + agentList = new App.ControllerForm( + model: + configure_attributes: + [ + name: 'out_of_office_replacement_id' + display: '' + relation: 'User' + tag: 'user_autocompletion' + autocapitalize: false + multiple: false + limit: 30 + minLengt: 2 + placeholder: 'Enter Person or Organization/Company' + null: false + translate: false + disableCreateObject: true + value: @localData + ] + noFieldset: true + params: @localData + ) + form.find('.js-recipientDropdown').html(agentList.form) + if @localData.out_of_office is true + form.find('.js-disabled').removeClass('is-disabled') + #form.find('.js-enable').addClass('is-disabled') + else + form.find('.js-disabled').addClass('is-disabled') + #form.find('.js-enable').removeClass('is-disabled') + @html(form) + + enable: (e) => + e.preventDefault() + params = @formParam(e.target) + params.out_of_office = true + @store(e, params) + + disable: (e) => + e.preventDefault() + params = @formParam(e.target) + params.out_of_office = false + @store(e, params) + + submit: (e, params) => + e.preventDefault() + params = @formParam(e.target) + @store(e, params) + + store: (e, params) => + @formDisable(e) + for key, value of params + @localData[key] = value + App.Ajax.request( + id: 'user_out_of_office' + type: 'PUT' + url: "#{@apiPath}/users/out_of_office" + data: JSON.stringify(params) + processData: true + success: @success + error: @error + ) + + success: (data) => + if data.message is 'ok' + @render() + @notify( + type: 'success' + msg: App.i18n.translateContent('Successfully!') + timeout: 1000 + ) + else + if data.notice + @notify + type: 'error' + msg: App.i18n.translateContent(data.notice[0], data.notice[1]) + removeAll: true + else + @notify + type: 'error' + msg: 'Please contact your administrator.' + removeAll: true + @formEnable( @$('form') ) + + error: (xhr, status, error) => + @formEnable( @$('form') ) + + # do not close window if request is aborted + return if status is 'abort' + data = JSON.parse(xhr.responseText) + + # show error message + if xhr.status is 401 || error is 'Unauthorized' + message = '» ' + App.i18n.translateInline('Unauthorized') + ' «' + else if xhr.status is 404 || error is 'Not Found' + message = '» ' + App.i18n.translateInline('Not Found') + ' «' + else if data.error + message = App.i18n.translateInline(data.error) + else + message = '» ' + App.i18n.translateInline('Error') + ' «' + @notify + type: 'error' + msg: App.i18n.translateContent(message) + removeAll: true + +App.Config.set('OutOfOffice', { prio: 2800, name: 'Out of Office', parent: '#profile', target: '#profile/out_of_office', permission: ['user_preferences.out_of_office+ticket.agent'], controller: Index }, 'NavBarProfile') diff --git a/app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee b/app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee index 176030f3f..049bc2735 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee @@ -20,7 +20,7 @@ class App.UiElement.postmaster_set name: 'Customer' relation: 'User' tag: 'user_autocompletion' - disableCreateUser: true + disableCreateObject: true } { value: 'group_id' @@ -32,7 +32,7 @@ class App.UiElement.postmaster_set name: 'Owner' relation: 'User' tag: 'user_autocompletion' - disableCreateUser: true + disableCreateObject: true } ] article: diff --git a/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee b/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee index b8d47b42f..f6dfae836 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee @@ -2,5 +2,5 @@ class App.UiElement.user_autocompletion_search @render: (attributeOrig, params = {}) -> attribute = _.clone(attributeOrig) - attribute.disableCreateUser = true + attribute.disableCreateObject = true new App.UserOrganizationAutocompletion(attribute: attribute, params: params).element() diff --git a/app/assets/javascripts/app/controllers/layout_ref.coffee b/app/assets/javascripts/app/controllers/layout_ref.coffee index 1ae661cfb..f4dc268e7 100644 --- a/app/assets/javascripts/app/controllers/layout_ref.coffee +++ b/app/assets/javascripts/app/controllers/layout_ref.coffee @@ -1499,7 +1499,7 @@ class InputsRef extends App.ControllerContent null: false relation: 'User' autocapitalize: false - disableCreateUser: true + disableCreateObject: true multiple: true @$('.userOrganizationAutocompletePlaceholder').replaceWith( userOrganizationAutocomplete.element() ) diff --git a/app/assets/javascripts/app/controllers/ticket_customer.coffee b/app/assets/javascripts/app/controllers/ticket_customer.coffee index e23ea954c..edb5df0c0 100644 --- a/app/assets/javascripts/app/controllers/ticket_customer.coffee +++ b/app/assets/javascripts/app/controllers/ticket_customer.coffee @@ -6,7 +6,7 @@ class App.TicketCustomer extends App.ControllerModal content: -> configure_attributes = [ - { name: 'customer_id', display: 'Customer', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateUser: true }, + { name: 'customer_id', display: 'Customer', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateObject: true }, ] controller = new App.ControllerForm( model: diff --git a/app/assets/javascripts/app/controllers/widget/avatar.coffee b/app/assets/javascripts/app/controllers/widget/avatar.coffee index c2ce3e055..e6bc84d35 100644 --- a/app/assets/javascripts/app/controllers/widget/avatar.coffee +++ b/app/assets/javascripts/app/controllers/widget/avatar.coffee @@ -7,6 +7,12 @@ class App.WidgetAvatar extends App.ObserverController email: true image: true vip: true + out_of_office: true, + out_of_office_start_at: true, + out_of_office_end_at: true, + out_of_office_replacement_id: true, + active: true + globalRerender: false render: (user) => diff --git a/app/assets/javascripts/app/models/overview.coffee b/app/assets/javascripts/app/models/overview.coffee index 144f228f0..dd8c0b2e9 100644 --- a/app/assets/javascripts/app/models/overview.coffee +++ b/app/assets/javascripts/app/models/overview.coffee @@ -8,6 +8,7 @@ class App.Overview extends App.Model { name: 'role_ids', display: 'Available for Role', tag: 'column_select', multiple: true, null: false, relation: 'Role', translate: true }, { name: 'user_ids', display: 'Available for User', tag: 'column_select', multiple: true, null: true, relation: 'User', sortBy: 'firstname' }, { name: 'organization_shared', display: 'Only available for Users with shared Organization', tag: 'select', options: { true: 'yes', false: 'no' }, default: false, null: true }, + { name: 'out_of_office', display: 'Only available for Users which are replacements for other users.', tag: 'select', options: { true: 'yes', false: 'no' }, default: false, null: true }, { name: 'condition', display: 'Conditions for shown Tickets', tag: 'ticket_selector', null: false }, { name: 'prio', display: 'Prio', readonly: 1 }, { @@ -72,4 +73,4 @@ Sie können auch individuelle Übersichten für einzelne Agenten oder agenten Gr ''' uiUrl: -> - '#ticket/view/' + @link + "#ticket/view/#{@link}" diff --git a/app/assets/javascripts/app/models/user.coffee b/app/assets/javascripts/app/models/user.coffee index ae3641619..7d357369c 100644 --- a/app/assets/javascripts/app/models/user.coffee +++ b/app/assets/javascripts/app/models/user.coffee @@ -53,8 +53,14 @@ class App.User extends App.Model cssClass += ' ' if cssClass cssClass += "size-#{ size }" + if @active is false + cssClass += ' avatar--inactive' + + if @isOutOfOffice() + cssClass += ' avatar--vacation' + if placement - placement = " data-placement='#{ placement }'" + placement = " data-placement='#{placement}'" if !avatar if type is 'personal' @@ -104,6 +110,19 @@ class App.User extends App.Model vip: vip url: @imageUrl() + isOutOfOffice: -> + return false if @out_of_office isnt true + start_time = @out_of_office_start_at + return false if !start_time + end_time = @out_of_office_end_at + return false if !end_time + start_time = new Date(Date.parse(start_time)) + end_time = new Date(Date.parse(end_time)) + now = new Date((new Date).toDateString()) + if start_time <= now && end_time >= now + return true + false + imageUrl: -> return if !@image # set image url @@ -237,3 +256,16 @@ class App.User extends App.Model break return access if access false + + @outOfOfficeTextPlaceholder: -> + today = new Date() + outOfOfficeText = 'Christmas holiday' + if today.getMonth() < 3 + outOfOfficeText = 'Easter holiday' + else if today.getMonth() < 9 + outOfOfficeText = 'Summer holiday' + outOfOfficeText + + outOfOfficeText: -> + return @preferences.out_of_office_text if !_.isEmpty(@preferences.out_of_office_text) + App.User.outOfOfficeTextPlaceholder() diff --git a/app/assets/javascripts/app/views/profile/out_of_office.jst.eco b/app/assets/javascripts/app/views/profile/out_of_office.jst.eco new file mode 100644 index 000000000..e2ca357d2 --- /dev/null +++ b/app/assets/javascripts/app/views/profile/out_of_office.jst.eco @@ -0,0 +1,34 @@ + + +
+
+
+
+

+ <% if @localData.out_of_office is true: %> + <%- @Icon('status', 'ok inline') %> + <% else: %> + <%- @Icon('status', 'error inline') %> + <% end %>

+
+
+
+
<%- @T('From') %>
+
+
+
+
<%- @T('Till') %>
+
+
+
+ + +
+
+
<%- @Ti('Disable') %>
+
<%- @Ti('Enable') %>
+
+
+
diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index deaf3abaf..2a55670c1 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -3695,6 +3695,16 @@ footer { opacity: 0.5; } + &--inactive { + filter: grayscale(100%); + opacity: 0.2; + } + + &--vacation { + filter: grayscale(70%); + opacity: 1; + } + &--unique { background-image: image_url("/assets/images/avatar-bg.png"); background-size: 300px 226px; @@ -7816,6 +7826,10 @@ output { h2 { margin-bottom: 0; + + .action-form-status .icon { + margin-top: 0; + } } .action-block, diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a51a6ac0c..1ff08c32c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -538,7 +538,7 @@ Response: } Test: -curl http://localhost/api/v1/users/email_verify.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN"}' +curl http://localhost/api/v1/users/email_verify -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN"}' =end @@ -567,7 +567,7 @@ Response: } Test: -curl http://localhost/api/v1/users/email_verify_send.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"email": "some_email@example.com"}' +curl http://localhost/api/v1/users/email_verify_send -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"email": "some_email@example.com"}' =end @@ -626,7 +626,7 @@ Response: } Test: -curl http://localhost/api/v1/users/password_reset.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"username": "some_username"}' +curl http://localhost/api/v1/users/password_reset -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"username": "some_username"}' =end @@ -678,7 +678,7 @@ Response: } Test: -curl http://localhost/api/v1/users/password_reset_verify.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN", "password" "new_password"}' +curl http://localhost/api/v1/users/password_reset_verify -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN", "password" "new_password"}' =end @@ -734,7 +734,7 @@ Response: } Test: -curl http://localhost/api/v1/users/password_change.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"password_old": "password_old", "password_new": "password_new"}' +curl http://localhost/api/v1/users/password_change -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"password_old": "password_old", "password_new": "password_new"}' =end @@ -781,7 +781,7 @@ curl http://localhost/api/v1/users/password_change.json -v -u #{login}:#{passwor =begin Resource: -PUT /api/v1/users/preferences.json +PUT /api/v1/users/preferences Payload: { @@ -795,7 +795,7 @@ Response: } Test: -curl http://localhost/api/v1/users/preferences.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"language": "de", "notifications": true}' +curl http://localhost/api/v1/users/preferences -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"language": "de", "notifications": true}' =end @@ -808,7 +808,7 @@ curl http://localhost/api/v1/users/preferences.json -v -u #{login}:#{password} - params[:user].each { |key, value| user.preferences[key.to_sym] = value } - user.save + user.save! end end render json: { message: 'ok' }, status: :ok @@ -817,7 +817,47 @@ curl http://localhost/api/v1/users/preferences.json -v -u #{login}:#{password} - =begin Resource: -DELETE /api/v1/users/account.json +PUT /api/v1/users/out_of_office + +Payload: +{ + "out_of_office": true, + "out_of_office_start_at": true, + "out_of_office_end_at": true, + "out_of_office_replacement_id": 123, + "out_of_office_text": 'honeymoon' +} + +Response: +{ + :message => 'ok' +} + +Test: +curl http://localhost/api/v1/users/out_of_office -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"out_of_office": true, "out_of_office_replacement_id": 123}' + +=end + + def out_of_office + raise Exceptions::UnprocessableEntity, 'No current user!' if !current_user + user = User.find(current_user.id) + user.with_lock do + user.assign_attributes( + out_of_office: params[:out_of_office], + out_of_office_start_at: params[:out_of_office_start_at], + out_of_office_end_at: params[:out_of_office_end_at], + out_of_office_replacement_id: params[:out_of_office_replacement_id], + ) + user.preferences[:out_of_office_text] = params[:out_of_office_text] + user.save! + end + render json: { message: 'ok' }, status: :ok + end + +=begin + +Resource: +DELETE /api/v1/users/account Payload: { @@ -831,7 +871,7 @@ Response: } Test: -curl http://localhost/api/v1/users/account.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"provider": "twitter", "uid": 581482342942}' +curl http://localhost/api/v1/users/account -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"provider": "twitter", "uid": 581482342942}' =end diff --git a/app/models/ticket/overviews.rb b/app/models/ticket/overviews.rb index 644039a64..51e894643 100644 --- a/app/models/ticket/overviews.rb +++ b/app/models/ticket/overviews.rb @@ -19,11 +19,11 @@ returns # get customer overviews role_ids = User.joins(:roles).where(users: { id: current_user.id, active: true }, roles: { active: true }).pluck('roles.id') if current_user.permissions?('ticket.customer') - overviews = if current_user.organization_id && current_user.organization.shared - Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: { active: true }).distinct('overview.id').order(:prio) - else - Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: { active: true, organization_shared: false }).distinct('overview.id').order(:prio) - end + overview_filter = { active: true, organization_shared: false } + if current_user.organization_id && current_user.organization.shared + overview_filter.delete(:organization_shared) + end + overviews = Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: overview_filter).distinct('overview.id').order(:prio) overviews_list = [] overviews.each { |overview| user_ids = overview.user_ids @@ -35,7 +35,12 @@ returns # get agent overviews return [] if !current_user.permissions?('ticket.agent') - overviews = Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: { active: true }).distinct('overview.id').order(:prio) + overview_filter = { active: true } + overview_filter_not = { out_of_office: true } + if User.where('out_of_office = ? AND out_of_office_start_at <= ? AND out_of_office_end_at >= ? AND out_of_office_replacement_id = ? AND active = ?', true, Time.zone.today, Time.zone.today, current_user.id, true).count.positive? + overview_filter_not = {} + end + overviews = Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: overview_filter).where.not(overview_filter_not).distinct('overview.id').order(:prio) overviews_list = [] overviews.each { |overview| user_ids = overview.user_ids diff --git a/app/models/transaction/notification.rb b/app/models/transaction/notification.rb index dcc915315..1893a28b6 100644 --- a/app/models/transaction/notification.rb +++ b/app/models/transaction/notification.rb @@ -26,12 +26,11 @@ class Transaction::Notification # return if we run import mode return if Setting.get('import_mode') - return if @item[:object] != 'Ticket' - return if @params[:disable_notification] - ticket = Ticket.find(@item[:object_id]) + ticket = Ticket.find_by(id: @item[:object_id]) + return if !ticket if @item[:article_id] article = Ticket::Article.find(@item[:article_id]) @@ -47,20 +46,41 @@ class Transaction::Notification # find recipients recipients_and_channels = [] + recipients_reason = {} # loop through all users possible_recipients = User.group_access(ticket.group_id, 'full').sort_by(&:login) - if ticket.owner_id == 1 + + # apply owner + if ticket.owner_id != 1 possible_recipients.push ticket.owner + recipients_reason[ticket.owner_id] = 'are assigned' + end + + # apply out of office agents + possible_recipients_additions = Set.new + possible_recipients.each { |user| + recursive_ooo_replacements( + user: user, + replacements: possible_recipients_additions, + reasons: recipients_reason, + ) + } + + if possible_recipients_additions.present? + # join unique entries + possible_recipients = possible_recipients | possible_recipients_additions.to_a end already_checked_recipient_ids = {} possible_recipients.each { |user| 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 + next if already_checked_recipient_ids[user.id] + already_checked_recipient_ids[user.id] = true recipients_and_channels.push result + next if recipients_reason[user.id] + recipients_reason[user.id] = 'are in group' } # send notifications @@ -76,7 +96,7 @@ class Transaction::Notification end # ignore inactive users - next if !user.active + next if !user.active? # ignore if no changes has been done changes = human_changes(user, ticket) @@ -180,6 +200,7 @@ class Transaction::Notification recipient: user, current_user: current_user, changes: changes, + reason: recipients_reason[user.id], }, message_id: "", references: ticket.get_references, @@ -296,4 +317,27 @@ class Transaction::Notification changes end + private + + def recursive_ooo_replacements(user:, replacements:, reasons:, level: 0) + if level == 10 + Rails.logger.warn("Found more than 10 replacement levels for agent #{user}.") + return + end + + replacement = user.out_of_office_agent + return if !replacement + # return for already found, added and checked users + # to prevent re-doing complete lookup paths + return if !replacements.add?(replacement) + reasons[replacement.id] = 'are the out-of-office replacement of the owner' + + recursive_ooo_replacements( + user: replacement, + replacements: replacements, + reasons: reasons, + level: level + 1 + ) + end + end diff --git a/app/models/user.rb b/app/models/user.rb index 03c8280f1..90528a231 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -39,8 +39,8 @@ class User < ApplicationModel include User::SearchIndex before_validation :check_name, :check_email, :check_login, :ensure_uniq_email, :ensure_password, :ensure_roles, :ensure_identifier - before_create :check_preferences_default, :validate_roles, :domain_based_assignment, :set_locale - before_update :check_preferences_default, :validate_roles, :reset_login_failed + before_create :check_preferences_default, :validate_roles, :validate_ooo, :domain_based_assignment, :set_locale + before_update :check_preferences_default, :validate_roles, :validate_ooo, :reset_login_failed after_create :avatar_for_email_check after_update :avatar_for_email_check after_destroy :avatar_destroy, :user_device_destroy @@ -160,6 +160,45 @@ returns =begin +check if user is in role + + user = User.find(123) + result = user.out_of_office? + +returns + + result = true|false + +=end + + def out_of_office? + return false if out_of_office != true + return false if out_of_office_start_at.blank? + return false if out_of_office_end_at.blank? + Time.zone.today.between?(out_of_office_start_at, out_of_office_end_at) + end + +=begin + +check if user is in role + + user = User.find(123) + result = user.out_of_office_agent + +returns + + result = user_model + +=end + + def out_of_office_agent + return if !out_of_office? + return if out_of_office_replacement_id.blank? + User.find_by(id: out_of_office_replacement_id) + end + +=begin + get users activity stream user = User.find(123) @@ -922,6 +961,15 @@ returns true end + def validate_ooo + return true if out_of_office != true + raise Exceptions::UnprocessableEntity, 'out of office start is required' if out_of_office_start_at.blank? + raise Exceptions::UnprocessableEntity, 'out of office end is required' if out_of_office_end_at.blank? + raise Exceptions::UnprocessableEntity, 'out of office end is before start' if out_of_office_start_at > out_of_office_end_at + raise Exceptions::UnprocessableEntity, 'out of office replacement user is required' if out_of_office_replacement_id.blank? + raise Exceptions::UnprocessableEntity, 'out of office no such replacement user' if !User.find_by(id: out_of_office_replacement_id) + true + end =begin checks if the current user is the last one diff --git a/app/views/mailer/application.html.erb b/app/views/mailer/application.html.erb index 913224ad3..858bdd5d3 100644 --- a/app/views/mailer/application.html.erb +++ b/app/views/mailer/application.html.erb @@ -16,6 +16,7 @@ font-size: 16px; } .footer { + font-size: 10px; color: #aaaaaa; border-top-style:solid; border-top-width:1px; @@ -40,6 +41,13 @@ <% if @objects[:standalone] != true %> <% end %> diff --git a/config/routes/user.rb b/config/routes/user.rb index 54f8dba73..0f66db41c 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -8,6 +8,7 @@ Zammad::Application.routes.draw do match api_path + '/users/password_reset_verify', to: 'users#password_reset_verify', via: :post match api_path + '/users/password_change', to: 'users#password_change', via: :post match api_path + '/users/preferences', to: 'users#preferences', via: :put + match api_path + '/users/out_of_office', to: 'users#out_of_office', via: :put match api_path + '/users/account', to: 'users#account_remove', via: :delete match api_path + '/users/avatar', to: 'users#avatar_new', via: :post diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb index 6e4e773c1..71547ebb9 100644 --- a/db/migrate/20120101000001_create_base.rb +++ b/db/migrate/20120101000001_create_base.rb @@ -40,6 +40,10 @@ class CreateBase < ActiveRecord::Migration t.timestamp :last_login, limit: 3, null: true t.string :source, limit: 200, null: true t.integer :login_failed, null: false, default: 0 + t.boolean :out_of_office, null: false, default: false + t.date :out_of_office_start_at, null: true + t.date :out_of_office_end_at, null: true + t.integer :out_of_office_replacement_id, null: true t.string :preferences, limit: 8000, null: true t.integer :updated_by_id, null: false t.integer :created_by_id, null: false @@ -54,10 +58,13 @@ class CreateBase < ActiveRecord::Migration add_index :users, [:phone] add_index :users, [:fax] add_index :users, [:mobile] + add_index :users, [:out_of_office, :out_of_office_start_at, :out_of_office_end_at], name: 'index_out_of_office' + add_index :users, [:out_of_office_replacement_id] add_index :users, [:source] add_index :users, [:created_by_id] add_foreign_key :users, :users, column: :created_by_id add_foreign_key :users, :users, column: :updated_by_id + add_foreign_key :users, :users, column: :out_of_office_replacement_id create_table :signatures do |t| t.string :name, limit: 100, null: false diff --git a/db/migrate/20120101000010_create_ticket.rb b/db/migrate/20120101000010_create_ticket.rb index 98b92472f..ab36bcfce 100644 --- a/db/migrate/20120101000010_create_ticket.rb +++ b/db/migrate/20120101000010_create_ticket.rb @@ -238,6 +238,7 @@ class CreateTicket < ActiveRecord::Migration t.column :order, :string, limit: 2500, null: false t.column :group_by, :string, limit: 250, null: true t.column :organization_shared, :boolean, null: false, default: false + t.column :out_of_office, :boolean, null: false, default: false t.column :view, :string, limit: 1000, null: false t.column :active, :boolean, null: false, default: true t.column :updated_by_id, :integer, null: false diff --git a/db/migrate/20170826000001_out_of_office.rb b/db/migrate/20170826000001_out_of_office.rb new file mode 100644 index 000000000..133771c67 --- /dev/null +++ b/db/migrate/20170826000001_out_of_office.rb @@ -0,0 +1,53 @@ +class OutOfOffice < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + add_column :overviews, :out_of_office, :boolean, null: false, default: false + Overview.reset_column_information + + role_ids = Role.with_permissions(['ticket.agent']).map(&:id) + overview_role = Role.find_by(name: 'Agent') + Overview.create_if_not_exists( + name: 'My replacement Tickets', + link: 'my_replacement_tickets', + prio: 1080, + role_ids: role_ids, + out_of_office: true, + condition: { + 'ticket.state_id' => { + operator: 'is', + value: Ticket::State.by_category(:open).pluck(:id), + }, + #'ticket.out_of_office_replacement_id' => { + # operator: 'is', + # pre_condition: 'current_user.organization_id', + #}, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer group owner escalation_at), + s: %w(title customer group owner escalation_at), + m: %w(number title customer group owner escalation_at), + view_mode_default: 's', + }, + updated_by_id: 1, + created_by_id: 1, + ) + + add_column :users, :out_of_office, :boolean, null: false, default: false + add_column :users, :out_of_office_start_at, :date, null: true + add_column :users, :out_of_office_end_at, :date, null: true + add_column :users, :out_of_office_replacement_id, :integer, null: true + + add_index :users, [:out_of_office, :out_of_office_start_at, :out_of_office_end_at], name: 'index_out_of_office' + add_index :users, [:out_of_office_replacement_id] + add_foreign_key :users, :users, column: :out_of_office_replacement_id + + Cache.clear + end +end diff --git a/db/seeds/overviews.rb b/db/seeds/overviews.rb index 80d66a00b..ce1f052f6 100644 --- a/db/seeds/overviews.rb +++ b/db/seeds/overviews.rb @@ -160,6 +160,34 @@ Overview.create_if_not_exists( }, ) +Overview.create_if_not_exists( + name: 'My replacement Tickets', + link: 'my_replacement_tickets', + prio: 1080, + role_ids: [overview_role.id], + out_of_office: true, + condition: { + 'ticket.state_id' => { + operator: 'is', + value: Ticket::State.by_category(:open).pluck(:id), + }, + #'ticket.out_of_office_replacement_id' => { + # operator: 'is', + # pre_condition: 'current_user.organization_id', + #}, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer group owner escalation_at), + s: %w(title customer group owner escalation_at), + m: %w(number title customer group owner escalation_at), + view_mode_default: 's', + }, +) + overview_role = Role.find_by(name: 'Customer') Overview.create_if_not_exists( name: 'My Tickets', diff --git a/db/seeds/permissions.rb b/db/seeds/permissions.rb index fbd8e60fa..699e28f29 100644 --- a/db/seeds/permissions.rb +++ b/db/seeds/permissions.rb @@ -306,7 +306,7 @@ Permission.create_if_not_exists( ) Permission.create_if_not_exists( name: 'ticket.customer', - note: 'Access to Customer Tickets based on current_user.id and current_user.organization_id', + note: 'Access to Customer Tickets based on current_user and organization', preferences: { not: ['ticket.agent'], }, diff --git a/lib/notification_factory/mailer.rb b/lib/notification_factory/mailer.rb index a55127afe..12f810e12 100644 --- a/lib/notification_factory/mailer.rb +++ b/lib/notification_factory/mailer.rb @@ -35,27 +35,47 @@ returns matrix = user.preferences['notification_config']['matrix'] return if !matrix - # check if group is in selecd groups - if ticket.owner_id != user.id + owned_by_nobody = false + owned_by_me = false + if ticket.owner_id == 1 + owned_by_nobody = true + elsif ticket.owner_id == user.id + owned_by_me = true + else + # check the replacement chain of max 10 + # if the current user is in it + check_for = ticket.owner + 10.times do + replacement = check_for.out_of_office_agent + break if !replacement + + check_for = replacement + next if replacement.id != user.id + + owned_by_me = true + break + end + end + + # check if group is in selected groups + if !owned_by_me selected_group_ids = user.preferences['notification_config']['group_ids'] - if selected_group_ids - if selected_group_ids.class == Array - hit = nil - if selected_group_ids.empty? - hit = true - elsif selected_group_ids[0] == '-' && selected_group_ids.count == 1 - hit = true - else - hit = false - selected_group_ids.each { |selected_group_id| - if selected_group_id.to_s == ticket.group_id.to_s - hit = true - break - end - } - end - return if !hit + if selected_group_ids.is_a?(Array) + hit = nil + if selected_group_ids.empty? + hit = true + elsif selected_group_ids[0] == '-' && selected_group_ids.count == 1 + hit = true + else + hit = false + selected_group_ids.each { |selected_group_id| + if selected_group_id.to_s == ticket.group_id.to_s + hit = true + break + end + } end + return if !hit # no group access end end return if !matrix[type] @@ -64,13 +84,13 @@ returns return if !data['criteria'] channels = data['channel'] return if !channels - if data['criteria']['owned_by_me'] && ticket.owner_id == user.id + if data['criteria']['owned_by_me'] && owned_by_me return { user: user, channels: channels } end - if data['criteria']['owned_by_nobody'] && ticket.owner_id == 1 + if data['criteria']['owned_by_nobody'] && owned_by_nobody return { user: user, channels: channels diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8dc205a0f..e2a8bf18f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -26,6 +26,34 @@ RSpec.describe User do end end + context '#out_of_office_agent' do + + it 'responds to out_of_office_agent' do + user = create(:user) + expect(user).to respond_to(:out_of_office_agent) + end + + context 'replacement' do + + it 'finds assigned' do + user_replacement = create(:user) + + user_ooo = create(:user, + out_of_office: true, + out_of_office_start_at: Time.zone.yesterday, + out_of_office_end_at: Time.zone.tomorrow, + out_of_office_replacement_id: user_replacement.id,) + + expect(user_ooo.out_of_office_agent).to eq user_replacement + end + + it 'finds none for available users' do + user = create(:user) + expect(user.out_of_office_agent).to be nil + end + end + end + context '#max_login_failed?' do it 'responds to max_login_failed?' do diff --git a/test/integration/zendesk_import_test.rb b/test/integration/zendesk_import_test.rb index 751147819..563ed7bff 100644 --- a/test/integration/zendesk_import_test.rb +++ b/test/integration/zendesk_import_test.rb @@ -180,6 +180,10 @@ class ZendeskImportTest < ActiveSupport::TestCase last_login source login_failed + out_of_office + out_of_office_start_at + out_of_office_end_at + out_of_office_replacement_id preferences updated_by_id created_by_id diff --git a/test/unit/ticket_notification_test.rb b/test/unit/ticket_notification_test.rb index 9d6ae786c..0288f41dd 100644 --- a/test/unit/ticket_notification_test.rb +++ b/test/unit/ticket_notification_test.rb @@ -56,6 +56,7 @@ class TicketNotificationTest < ActiveSupport::TestCase lastname: 'Agent1', email: 'ticket-notification-agent1@example.com', password: 'agentpw', + out_of_office: false, active: true, roles: roles, groups: groups, @@ -71,6 +72,7 @@ class TicketNotificationTest < ActiveSupport::TestCase lastname: 'Agent2', email: 'ticket-notification-agent2@example.com', password: 'agentpw', + out_of_office: false, active: true, roles: roles, groups: groups, @@ -80,6 +82,38 @@ class TicketNotificationTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) + @agent3 = User.create_or_update( + login: 'ticket-notification-agent3@example.com', + firstname: 'Notification', + lastname: 'Agent3', + email: 'ticket-notification-agent3@example.com', + password: 'agentpw', + out_of_office: false, + active: true, + roles: roles, + groups: groups, + preferences: { + locale: 'de-de', + }, + updated_by_id: 1, + created_by_id: 1, + ) + @agent4 = User.create_or_update( + login: 'ticket-notification-agent4@example.com', + firstname: 'Notification', + lastname: 'Agent4', + email: 'ticket-notification-agent4@example.com', + password: 'agentpw', + out_of_office: false, + active: true, + roles: roles, + groups: groups, + preferences: { + locale: 'de-de', + }, + updated_by_id: 1, + created_by_id: 1, + ) Group.create_if_not_exists( name: 'WithoutAccess', note: 'Test for notification check.', @@ -944,6 +978,126 @@ class TicketNotificationTest < ActiveSupport::TestCase end + test 'ticket notification - out of office' do + + # create ticket in group + ticket1 = Ticket.create!( + title: 'some notification test out of office', + group: Group.lookup(name: 'TicketNotificationTest'), + customer: @customer, + owner_id: @agent2.id, + #state: Ticket::State.lookup(name: 'new'), + #priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: @customer.id, + created_by_id: @customer.id, + ) + Ticket::Article.create!( + ticket_id: ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: @customer.id, + created_by_id: @customer.id, + ) + assert(ticket1, 'ticket created - ticket notification simple') + + # execute object transaction + Observer::Transaction.commit + Scheduler.worker(true) + + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent3, 'email'), ticket1.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent4, 'email'), ticket1.id) + + @agent2.out_of_office = true + @agent2.preferences[:out_of_office_text] = 'at the doctor' + @agent2.out_of_office_replacement_id = @agent3.id + @agent2.out_of_office_start_at = Time.zone.today - 2.days + @agent2.out_of_office_end_at = Time.zone.today + 2.days + @agent2.save! + + # create ticket in group + ticket2 = Ticket.create!( + title: 'some notification test out of office', + group: Group.lookup(name: 'TicketNotificationTest'), + customer: @customer, + owner_id: @agent2.id, + #state: Ticket::State.lookup(name: 'new'), + #priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: @customer.id, + created_by_id: @customer.id, + ) + Ticket::Article.create!( + ticket_id: ticket2.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: @customer.id, + created_by_id: @customer.id, + ) + assert(ticket2, 'ticket created - ticket notification simple') + + # execute object transaction + Observer::Transaction.commit + Scheduler.worker(true) + + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent3, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent4, 'email'), ticket2.id) + + # update ticket attributes + ticket2.title = "#{ticket2.title} - #2" + ticket2.priority = Ticket::Priority.lookup(name: '3 high') + ticket2.save! + + # execute object transaction + Observer::Transaction.commit + Scheduler.worker(true) + + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, @agent3, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent4, 'email'), ticket2.id) + + @agent3.out_of_office = true + @agent3.preferences[:out_of_office_text] = 'at the doctor' + @agent3.out_of_office_replacement_id = @agent4.id + @agent3.out_of_office_start_at = Time.zone.today - 2.days + @agent3.out_of_office_end_at = Time.zone.today + 2.days + @agent3.save! + + # update ticket attributes + ticket2.title = "#{ticket2.title} - #3" + ticket2.priority = Ticket::Priority.lookup(name: '3 high') + ticket2.save! + + # execute object transaction + Observer::Transaction.commit + Scheduler.worker(true) + + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) + assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket2, @agent3, 'email'), ticket2.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent4, 'email'), ticket2.id) + + end + test 'ticket notification template' do # create ticket in group diff --git a/test/unit/user_out_of_office_test.rb b/test/unit/user_out_of_office_test.rb new file mode 100644 index 000000000..6d8f68968 --- /dev/null +++ b/test/unit/user_out_of_office_test.rb @@ -0,0 +1,131 @@ +require 'test_helper' + +class UserOutOfOfficeTest < ActiveSupport::TestCase + setup do + + UserInfo.current_user_id = 1 + + groups = Group.all + roles = Role.where(name: 'Agent') + @agent1 = User.create_or_update( + login: 'user-out_of_office-agent1@example.com', + firstname: 'UserOutOfOffice', + lastname: 'Agent1', + email: 'user-out_of_office-agent1@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + @agent2 = User.create_or_update( + login: 'user-out_of_office-agent2@example.com', + firstname: 'UserOutOfOffice', + lastname: 'Agent2', + email: 'user-out_of_office-agent2@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + @agent3 = User.create_or_update( + login: 'user-out_of_office-agent3@example.com', + firstname: 'UserOutOfOffice', + lastname: 'Agent3', + email: 'user-out_of_office-agent3@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + end + + test 'check out_of_office?' do + + # check + assert_not(@agent1.out_of_office?) + assert_not(@agent2.out_of_office?) + assert_not(@agent3.out_of_office?) + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + 2.days + @agent1.out_of_office_end_at = Time.zone.now + @agent1.save! + } + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + @agent1.out_of_office_end_at = Time.zone.now - 2.days + @agent1.save! + } + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = nil + @agent1.out_of_office_end_at = Time.zone.now + @agent1.save! + } + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + @agent1.out_of_office_end_at = nil + @agent1.save! + } + + @agent1.out_of_office = false + @agent1.out_of_office_start_at = Time.zone.now + 2.days + @agent1.out_of_office_end_at = Time.zone.now + @agent1.save! + + assert_not(@agent1.out_of_office?) + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + 2.days + @agent1.out_of_office_end_at = Time.zone.now + 4.days + @agent1.save! + } + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office_replacement_id = 999_999_999_999 # not existing + @agent1.save! + } + @agent1.out_of_office_replacement_id = @agent2.id + @agent1.save! + + assert_not(@agent1.out_of_office?) + + travel 2.days + + assert(@agent1.out_of_office?) + + travel 1.day + + assert(@agent1.out_of_office?) + + travel 1.day + + assert(@agent1.out_of_office?) + + travel 1.day + + assert_not(@agent1.out_of_office?) + + assert_not(@agent1.out_of_office_agent) + + assert_not(@agent2.out_of_office_agent) + + @agent2.out_of_office = true + @agent2.out_of_office_start_at = Time.zone.now + @agent2.out_of_office_end_at = Time.zone.now + 4.days + @agent2.out_of_office_replacement_id = @agent3.id + @agent2.save! + + assert(@agent2.out_of_office?) + + assert_equal(@agent2.out_of_office_agent.id, @agent3.id) + + end + +end