Added initial version of Out Of Office functionality.

This commit is contained in:
Thorsten Eckel 2017-09-05 11:49:32 +02:00
parent 8958a3ac63
commit 20a8e40484
27 changed files with 882 additions and 56 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,34 @@
<div class="page-header">
<div class="page-header-title"><h1><%- @T('Out of Office') %></h1></div>
</div>
<form class="action">
<div class="action-flow action-flow--row">
<div class="action-row">
<div class="action-flow action-flow--noWrap">
<h2><span class="action-form-status">
<% if @localData.out_of_office is true: %>
<%- @Icon('status', 'ok inline') %>
<% else: %>
<%- @Icon('status', 'error inline') %>
<% end %></span> <input id="out_of_office_reason" name="out_of_office_text" class="form-control form-control--inline" type="text" placeholder="<%- @Ti('e. g.') %> <%- @Ti(@placeholder) %>" value="<% if !_.isEmpty(@localData.out_of_office_text): %><%= @localData.out_of_office_text %><% end %>"></h2>
</div>
</div>
<div class="action-block action-block--flex">
<div class="label"><%- @T('From') %></div>
<div class="form-group js-startDate"></div>
</div>
<div class="action-block action-block--flex">
<div class="label"><%- @T('Till') %></div>
<div class="form-group js-endDate"></div>
</div>
<div class="action-row">
<label for="out_of_office_replacement"><%- @T('Replacement') %></label>
<div class="dropdown js-recipientDropdown"></div>
</div>
<div class="action-controls">
<div class="btn btn--danger js-disabled"><%- @Ti('Disable') %></div>
<div class="btn btn--create js-enable"><%- @Ti('Enable') %></div>
</div>
</div>
</form>

View file

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

View file

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

View file

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

View file

@ -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: "<notification.#{DateTime.current.to_s(:number)}.#{ticket.id}.#{user.id}.#{rand(999_999)}@#{Setting.get('fqdn')}>",
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

View file

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

View file

@ -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 %>
<div class="footer">
<a href="<%= c 'http_type' %>://<%= c 'fqdn' %>/#profile/notifications"><%= t 'Manage your notifications settings' %></a> <% if !c('organization').empty? %>| <%= c 'organization' %><% end %>
<% if @objects[:reason] %>
<% reason = t('You are receiving this because you "%s".') %>
<% reason_item = t (@objects[:reason]) %>
<% reason.gsub!('%s', reason_item) %>
<%= reason %> |
<% end %>
<a href="<%= c 'http_type' %>://<%= c 'fqdn' %>/#profile/notifications"><%= t 'Manage your notifications settings' %></a>
<% if c('organization').present? %>| <%= c 'organization' %><% end %>
</div>
<% end %>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'],
},

View file

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

View file

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

View file

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

View file

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

View file

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