improve email filters by adding a tag option - closes #1991

This commit is contained in:
Muhammad Nuzaihan 2018-06-20 20:13:35 +08:00
parent db82816f87
commit 9f559276e4
No known key found for this signature in database
GPG key ID: 949F5D5715249C07
18 changed files with 1050 additions and 26 deletions

View file

@ -306,7 +306,7 @@ GEM
slop (~> 3.0) slop (~> 3.0)
public_suffix (3.0.1) public_suffix (3.0.1)
puma (3.11.0) puma (3.11.0)
rack (2.0.4) rack (2.0.5)
rack-livereload (0.3.16) rack-livereload (0.3.16)
rack rack
rack-test (1.0.0) rack-test (1.0.0)
@ -405,7 +405,7 @@ GEM
simplecov (>= 0.4.1) simplecov (>= 0.4.1)
slack-notifier (2.3.1) slack-notifier (2.3.1)
slop (3.6.0) slop (3.6.0)
sprockets (3.7.1) sprockets (3.7.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
sprockets-rails (3.2.1) sprockets-rails (3.2.1)

View file

@ -4,6 +4,7 @@ class App.UiElement.postmaster_set
groups = groups =
ticket: ticket:
name: 'Ticket' name: 'Ticket'
model: 'Ticket'
options: [ options: [
{ {
value: 'priority_id' value: 'priority_id'
@ -15,6 +16,11 @@ class App.UiElement.postmaster_set
name: 'State' name: 'State'
relation: 'TicketState' relation: 'TicketState'
} }
{
value: 'tags'
name: 'Tag'
tag: 'tag'
}
{ {
value: 'customer_id' value: 'customer_id'
name: 'Customer' name: 'Customer'
@ -64,6 +70,24 @@ class App.UiElement.postmaster_set
} }
] ]
elements = {}
for groupKey, groupMeta of groups
if !App[groupMeta.model]
elements["#{groupKey}.email"] = { name: 'email', display: 'Email' }
else
for row in App[groupMeta.model].configure_attributes
# ignore passwords and relations
if row.type isnt 'password' && row.name.substr(row.name.length-4,4) isnt '_ids'
# ignore readonly attributes
if !row.readonly
config = _.clone(row)
if config.tag is 'tag'
config.operator = ['add', 'remove']
elements["x-zammad-ticket-#{config.name}"] = config
# add additional ticket attributes # add additional ticket attributes
for row in App.Ticket.configure_attributes for row in App.Ticket.configure_attributes
exists = false exists = false
@ -91,11 +115,11 @@ class App.UiElement.postmaster_set
for item in groups.ticket.options for item in groups.ticket.options
item.value = "x-zammad-ticket-#{item.value}" item.value = "x-zammad-ticket-#{item.value}"
groups [elements, groups]
@render: (attribute, params = {}) -> @render: (attribute, params = {}) ->
groups = @defaults() [elements, groups] = @defaults()
selector = @buildAttributeSelector(groups, attribute) selector = @buildAttributeSelector(groups, attribute)
@ -121,7 +145,9 @@ class App.UiElement.postmaster_set
item.find('.js-attributeSelector select').bind('change', (e) => item.find('.js-attributeSelector select').bind('change', (e) =>
key = $(e.target).find('option:selected').attr('value') key = $(e.target).find('option:selected').attr('value')
elementRow = $(e.target).closest('.js-filterElement') elementRow = $(e.target).closest('.js-filterElement')
groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value')
@rebuildAttributeSelectors(item, elementRow, key, attribute) @rebuildAttributeSelectors(item, elementRow, key, attribute)
@buildOperator(item, elementRow, groupAndAttribute, elements, {}, attribute)
@buildValue(item, elementRow, key, groups, undefined, undefined, attribute) @buildValue(item, elementRow, key, groups, undefined, undefined, attribute)
) )
@ -203,3 +229,28 @@ class App.UiElement.postmaster_set
if key if key
elementRow.find('.js-attributeSelector select').val(key) elementRow.find('.js-attributeSelector select').val(key)
@buildOperator: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
currentOperator = elementRow.find('.js-operator option:selected').attr('value')
if !meta.operator
meta.operator = currentOperator
name = "#{attribute.name}::#{groupAndAttribute}::operator"
selection = $("<select class=\"form-control\" name=\"#{name}\"></select>")
attributeConfig = elements[groupAndAttribute]
if !attributeConfig.operator
elementRow.find('.js-operator').addClass('hide')
else
elementRow.find('.js-operator').removeClass('hide')
if attributeConfig.operator
for operator in attributeConfig.operator
operatorName = App.i18n.translateInline(operator)
selected = ''
if meta.operator is operator
selected = 'selected="selected"'
selection.append("<option value=\"#{operator}\" #{selected}>#{operatorName}</option>")
selection
elementRow.find('.js-operator select').replaceWith(selection)

View file

@ -143,12 +143,17 @@ class Items extends App.ControllerSubContent
e.preventDefault() e.preventDefault()
id = $(e.target).closest('tr').data('id') id = $(e.target).closest('tr').data('id')
item = App.ObjectManagerAttribute.find(id) item = App.ObjectManagerAttribute.find(id)
ui = @
@ajax( @ajax(
id: "object_manager_attributes/#{id}" id: "object_manager_attributes/#{id}"
type: 'DELETE' type: 'DELETE'
url: "#{@apiPath}/object_manager_attributes/#{id}" url: "#{@apiPath}/object_manager_attributes/#{id}"
success: (data) => success: (data) =>
@render() @render()
error: (jqXHR, textStatus, errorThrown) ->
ui.log 'errors'
# this code is unreachable so alert will do fine
alert(jqXHR.responseJSON.error)
) )
discard: (e) -> discard: (e) ->

View file

@ -6,6 +6,12 @@
<%- @Icon('arrow-down', 'dropdown-arrow') %> <%- @Icon('arrow-down', 'dropdown-arrow') %>
</div> </div>
</div> </div>
<div class="controls">
<div class="u-positionOrigin js-operator">
<select></select>
<%- @Icon('arrow-down') %>
</div>
</div>
<div class="controls js-value"></div> <div class="controls js-value"></div>
</div> </div>
<div class="filter-controls"> <div class="filter-controls">

View file

@ -71,8 +71,12 @@
<%- @T('will be deleted') %> <%- @T('will be deleted') %>
<% else if item.to_migrate is true || item.to_config is true: %> <% else if item.to_migrate is true || item.to_config is true: %>
<%- @T('has changed') %> <%- @T('has changed') %>
<% else if item.editable isnt false: %> <% else if item.editable: %>
<% if item.deletable: %>
<a href="#" class="js-delete" title="<%- @Ti('Delete') %>"><%- @Icon('trash') %></a> <a href="#" class="js-delete" title="<%- @Ti('Delete') %>"><%- @Icon('trash') %></a>
<% else: %>
<span class="is-disabled" title="<%= item.not_deletable_reason %>"><%- @Icon('trash') %></span>
<% end %>
<% end %> <% end %>
</td> </td>
</tr> </tr>

View file

@ -9610,3 +9610,8 @@ body.fit {
.flex-spacer { .flex-spacer {
flex: 1; flex: 1;
} }
span.is-disabled {
cursor: not-allowed;
opacity: 0.5;
}

View file

@ -77,6 +77,9 @@ class ObjectManagerAttributesController < ApplicationController
name: object_manager_attribute.name, name: object_manager_attribute.name,
) )
model_destroy_render_item model_destroy_render_item
rescue => e
logger.error e
raise Exceptions::UnprocessableEntity, e
end end
# POST /object_manager_attributes_discard_changes # POST /object_manager_attributes_discard_changes

View file

@ -238,6 +238,14 @@ returns
# create ticket # create ticket
ticket.save! ticket.save!
end
# apply tags to ticket
if mail['x-zammad-ticket-tags'.to_sym].present?
mail['x-zammad-ticket-tags'.to_sym].each do |tag|
ticket.tag_add(tag)
end
end end
# set attributes # set attributes
@ -309,6 +317,8 @@ returns
def self.check_attributes_by_x_headers(header_name, value) def self.check_attributes_by_x_headers(header_name, value)
class_name = nil class_name = nil
attribute = nil attribute = nil
# skip check attributes if it is tags
return true if header_name == 'x-zammad-ticket-tags'
if header_name =~ /^x-zammad-(.+?)-(followup-|)(.*)$/i if header_name =~ /^x-zammad-(.+?)-(followup-|)(.*)$/i
class_name = $1 class_name = $1
attribute = $3 attribute = $3

View file

@ -45,6 +45,29 @@ module Channel::Filter::Database
filter[:perform].each do |key, meta| filter[:perform].each do |key, meta|
next if !Channel::EmailParser.check_attributes_by_x_headers(key, meta['value']) next if !Channel::EmailParser.check_attributes_by_x_headers(key, meta['value'])
Rails.logger.info " perform '#{key.downcase}' = '#{meta.inspect}'" Rails.logger.info " perform '#{key.downcase}' = '#{meta.inspect}'"
if key.downcase == 'x-zammad-ticket-tags' && meta['value'].present? && meta['operator'].present?
mail[ 'x-zammad-ticket-tags'.downcase.to_sym ] ||= []
tags = meta['value'].split(',')
case meta['operator']
when 'add'
tags.each do |tag|
next if tag.blank?
tag.strip!
next if mail[ 'x-zammad-ticket-tags'.downcase.to_sym ].include?(tag)
mail[ 'x-zammad-ticket-tags'.downcase.to_sym ].push tag
end
when 'remove'
tags.each do |tag|
next if tag.blank?
tag.strip!
mail[ 'x-zammad-ticket-tags'.downcase.to_sym ] -= [tag]
end
end
next
end
mail[ key.downcase.to_sym ] = meta['value'] mail[ key.downcase.to_sym ] = meta['value']
end end
end end

View file

@ -29,12 +29,25 @@ list of all attributes
def self.list_full def self.list_full
result = ObjectManager::Attribute.all.order('position ASC, name ASC') result = ObjectManager::Attribute.all.order('position ASC, name ASC')
references = ObjectManager::Attribute.attribute_to_references_hash
attributes = [] attributes = []
assets = {} assets = {}
result.each do |item| result.each do |item|
attribute = item.attributes attribute = item.attributes
attribute[:object] = ObjectLookup.by_id(item.object_lookup_id) attribute[:object] = ObjectLookup.by_id(item.object_lookup_id)
attribute.delete('object_lookup_id') attribute.delete('object_lookup_id')
# an attribute is deletable if it is both editable and not referenced by other Objects (Triggers, Overviews, Schedulers)
deletable = true
not_deletable_reason = ''
if ObjectManager::Attribute.attribute_used_by_references?(attribute[:object], attribute['name'], references)
deletable = false
not_deletable_reason = ObjectManager::Attribute.attribute_used_by_references_humaniced(attribute[:object], attribute['name'], references)
end
attribute[:deletable] = attribute['editable'] && deletable == true
if not_deletable_reason.present?
attribute[:not_deletable_reason] = "This attribute is referenced by #{not_deletable_reason} and thus cannot be deleted!"
end
attributes.push attribute attributes.push attribute
end end
attributes attributes
@ -354,6 +367,10 @@ use "force: true" to delete also not editable fields
# lookups # lookups
if data[:object] if data[:object]
data[:object_lookup_id] = ObjectLookup.by_name(data[:object]) data[:object_lookup_id] = ObjectLookup.by_name(data[:object])
elsif data[:object_lookup_id]
data[:object] = ObjectLookup.by_id(data[:object_lookup_id])
else
raise 'ERROR: need object or object_lookup_id param!'
end end
data[:name].downcase! data[:name].downcase!
@ -371,6 +388,12 @@ use "force: true" to delete also not editable fields
raise "ERROR: #{data[:object]}.#{data[:name]} can't be removed!" raise "ERROR: #{data[:object]}.#{data[:name]} can't be removed!"
end end
# check to make sure that no triggers, overviews, or schedulers references this attribute
if ObjectManager::Attribute.attribute_used_by_references?(data[:object], data[:name])
text = ObjectManager::Attribute.attribute_used_by_references_humaniced(data[:object], data[:name])
raise "ERROR: #{data[:object]}.#{data[:name]} is referenced by #{text} and thus cannot be deleted!"
end
# if record is to create, just destroy it # if record is to create, just destroy it
if record.to_create if record.to_create
record.destroy record.destroy
@ -721,6 +744,109 @@ to send no browser reload event, pass false
true true
end end
=begin
where attributes are used by triggers, overviews or schedulers
result = ObjectManager::Attribute.attribute_to_references_hash
result = {
ticket.category: {
Trigger: ['abc', 'xyz'],
Overview: ['abc1', 'abc2'],
},
ticket.field_b: {
Trigger: ['abc'],
Overview: ['abc1', 'abc2'],
},
},
=end
def self.attribute_to_references_hash
objects = Trigger.select(:name, :condition) + Overview.select(:name, :condition) + Job.select(:name, :condition)
attribute_list = {}
objects.each do |item|
item.condition.each do |condition_key, _condition_attributes|
attribute_list[condition_key] ||= {}
attribute_list[condition_key][item.class.name] ||= []
next if attribute_list[condition_key][item.class.name].include?(item.name)
attribute_list[condition_key][item.class.name].push item.name
end
end
attribute_list
end
=begin
is certain attribute used by triggers, overviews or schedulers
ObjectManager::Attribute.attribute_used_by_references?('Ticket', 'attribute_name')
=end
def self.attribute_used_by_references?(object_name, attribute_name, references = attribute_to_references_hash)
references.each do |reference_key, _relations|
local_object, local_attribute = reference_key.split('.')
next if local_object != object_name.downcase
next if local_attribute != attribute_name
return true
end
false
end
=begin
is certain attribute used by triggers, overviews or schedulers
result = ObjectManager::Attribute.attribute_used_by_references('Ticket', 'attribute_name')
result = {
Trigger: ['abc', 'xyz'],
Overview: ['abc1', 'abc2'],
}
=end
def self.attribute_used_by_references(object_name, attribute_name, references = attribute_to_references_hash)
result = {}
references.each do |reference_key, relations|
local_object, local_attribute = reference_key.split('.')
next if local_object != object_name.downcase
next if local_attribute != attribute_name
relations.each do |relation, relation_names|
result[relation] ||= []
result[relation].push relation_names.sort
end
break
end
result
end
=begin
is certain attribute used by triggers, overviews or schedulers
text = ObjectManager::Attribute.attribute_used_by_references_humaniced('Ticket', 'attribute_name', references)
=end
def self.attribute_used_by_references_humaniced(object_name, attribute_name, references = nil)
result = if references.present?
ObjectManager::Attribute.attribute_used_by_references(object_name, attribute_name, references)
else
ObjectManager::Attribute.attribute_used_by_references(object_name, attribute_name)
end
not_deletable_reason = ''
result.each do |relation, relation_names|
if not_deletable_reason.present?
not_deletable_reason += '; '
end
not_deletable_reason += "#{relation}: #{relation_names.sort.join(',')}"
end
not_deletable_reason
end
def self.reset_database_info(model) def self.reset_database_info(model)
model.connection.schema_cache.clear! model.connection.schema_cache.clear!
model.reset_column_information model.reset_column_information

View file

@ -557,11 +557,11 @@ condition example
# get attributes # get attributes
attributes = attribute.split(/\./) attributes = attribute.split(/\./)
attribute = "#{attributes[0]}s.#{attributes[1]}" attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name(attributes[1])}"
# magic selectors # magic selectors
if attributes[0] == 'ticket' && attributes[1] == 'out_of_office_replacement_id' if attributes[0] == 'ticket' && attributes[1] == 'out_of_office_replacement_id'
attribute = "#{attributes[0]}s.owner_id" attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name('owner_id')}"
end end
if attributes[0] == 'ticket' && attributes[1] == 'tags' if attributes[0] == 'ticket' && attributes[1] == 'tags'

View file

@ -769,6 +769,10 @@ test("form postmaster filter", function() {
}, },
'x-zammad-ticket-priority_id': { 'x-zammad-ticket-priority_id': {
value: '1' value: '1'
},
'x-zammad-ticket-tags': {
operator: 'add',
value: 'test, test1'
} }
}, },
} }
@ -812,6 +816,10 @@ test("form postmaster filter", function() {
}, },
'x-zammad-ticket-priority_id': { 'x-zammad-ticket-priority_id': {
value: '1' value: '1'
},
'x-zammad-ticket-tags': {
operator: 'add',
value: 'test, test1'
} }
}, },
}; };
@ -842,6 +850,10 @@ test("form postmaster filter", function() {
'x-zammad-ticket-group_id': { 'x-zammad-ticket-group_id': {
value: '1' value: '1'
}, },
'x-zammad-ticket-tags': {
operator: 'add',
value: "test, test1"
},
}, },
}; };
deepEqual(params, test_params, 'form param check') deepEqual(params, test_params, 'form param check')
@ -849,6 +861,43 @@ test("form postmaster filter", function() {
}, },
1000 1000
); );
App.Delay.set(function() {
el.find('.postmaster_set .js-filterElement').last().find('.filter-controls .js-add').click()
test("form param check click add after tag option", function() {
params = App.ControllerForm.params(el)
test_params = {
input1: 'some not used default',
input2: 'some name',
match: {
from: {
operator: 'contains',
value: 'some@address'
},
subject: {
operator: 'contains',
value: 'some subject'
}
},
set: {
'x-zammad-ticket-owner_id': {
value: 'owner',
value_completion: ''
},
'x-zammad-ticket-group_id': {
value: '1'
},
'x-zammad-ticket-tags': {
operator: 'add',
value: "test, test1"
},
},
};
deepEqual(params, test_params, 'form param check')
});
},
1000
);
}); });

View file

@ -499,4 +499,99 @@ class AdminObjectManagerTest < TestCase
end end
def test_that_attributes_with_references_should_have_a_disabled_delete_button
@browser = instance = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
# create two new attributes
object_manager_attribute_create(
data: {
name: 'deletable_attribute',
display: 'Deletable Attribute',
data_type: 'Text',
},
)
object_manager_attribute_create(
data: {
name: 'undeletable_attribute',
display: 'Undeletable Attribute',
data_type: 'Text',
},
)
watch_for(
css: '.content.active',
value: 'Database Update required',
)
click(css: '.content.active .tab-pane.active div.js-execute')
watch_for(
css: '.modal',
value: 'restart',
)
watch_for_disappear(
css: '.modal',
timeout: 120,
)
sleep 5
watch_for(
css: '.content.active',
)
match_not(
css: '.content.active',
value: 'Database Update required',
)
# create a new overview that references the undeletable_attribute
overview_create(
browser: instance,
data: {
name: 'test_overview',
roles: ['Agent'],
selector: {
'Undeletable Attribute' => 'DUMMY',
},
'order::direction' => 'down',
'text_input' => true,
}
)
click(
browser: instance,
css: 'a[href="#manage"]',
mute_log: true,
)
click(
browser: instance,
css: '.content.active a[href="#system/object_manager"]',
mute_log: true,
)
30.times do
deletable_attribute = instance.find_elements(xpath: '//td[text()="deletable_attribute"]/following-sibling::*[2]')[0]
break if deletable_attribute
sleep 1
end
sleep 1
deletable_attribute = instance.find_elements(xpath: '//td[text()="deletable_attribute"]/following-sibling::*[2]')[0]
assert_not_nil(deletable_attribute)
deletable_attribute_html = deletable_attribute.attribute('innerHTML')
assert(deletable_attribute_html.include?('title="Delete"'))
assert(deletable_attribute_html.include?('href="#"'))
assert(deletable_attribute_html.exclude?('cannot be deleted'))
undeletable_attribute = instance.find_elements(xpath: '//td[text()="undeletable_attribute"]/following-sibling::*[2]')[0]
assert_not_nil(undeletable_attribute)
undeletable_attribute_html = undeletable_attribute.attribute('innerHTML')
assert(undeletable_attribute_html.include?('Overview'))
assert(undeletable_attribute_html.include?('test_overview'))
assert(undeletable_attribute_html.include?('cannot be deleted'))
assert(undeletable_attribute_html.exclude?('href="#"'))
end
end end

View file

@ -1659,6 +1659,14 @@ wait untill text in selector disabppears
mute_log: true, mute_log: true,
) )
sleep 0.5 sleep 0.5
if data.key?('text_input')
set(
browser: instance,
css: '.modal .ticket_selector .js-value input',
value: value,
mute_log: true,
)
else
select( select(
browser: instance, browser: instance,
css: '.modal .ticket_selector .js-value select', css: '.modal .ticket_selector .js-value select',
@ -1667,6 +1675,7 @@ wait untill text in selector disabppears
mute_log: true, mute_log: true,
) )
end end
end
if data['order::direction'] if data['order::direction']
select( select(

View file

@ -536,7 +536,6 @@ class ObjectManagerAttributesControllerTest < ActionDispatch::IntegrationTest
} }
} }
}, },
'id': 'c-202'
} }
post '/api/v1/object_manager_attributes', params: params.to_json, headers: @headers.merge('Authorization' => credentials) post '/api/v1/object_manager_attributes', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
@ -553,4 +552,467 @@ class ObjectManagerAttributesControllerTest < ActionDispatch::IntegrationTest
assert_equal(result['data_type'], 'boolean') assert_equal(result['data_type'], 'boolean')
end end
test '03 ticket attributes cannot be removed when it is referenced by an overview' do
credentials = ActionController::HttpAuthentication::Basic.encode_credentials('tickets-admin@example.com', 'adminpw')
# 1. create a new ticket attribute and execute migration
migration = ObjectManager::Attribute.migration_execute
params = {
'name': 'test_attribute_referenced_by_an_overview',
'object': 'Ticket',
'display': 'Test Attribute',
'active': true,
'data_type': 'input',
'data_option': {
'default': '',
'type': 'text',
'maxlength': 120,
'null': true,
'options': {},
'relation': ''
},
'screens': {
'create_middle': {
'ticket.customer': {
'shown': true,
'item_class': 'column'
},
'ticket.agent': {
'shown': true,
'item_class': 'column'
}
},
'edit': {
'ticket.customer': {
'shown': true
},
'ticket.agent': {
'shown': true
}
}
},
}
post '/api/v1/object_manager_attributes', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
migration = ObjectManager::Attribute.migration_execute
assert_equal(migration, true)
# 2. create an overview that uses the attribute
params = {
name: 'test_overview',
roles: Role.where(name: 'Agent').pluck(:name),
condition: {
'ticket.state_id': {
'operator': 'is',
'value': Ticket::State.all.pluck(:id),
},
'ticket.test_attribute_referenced_by_an_overview': {
'operator': 'contains',
'value': 'DUMMY'
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w[title customer state created_at],
s: %w[number title customer state created_at],
m: %w[number title customer state created_at],
view_mode_default: 's',
},
user_ids: [ '1' ],
}
if Overview.where('name like ?', '%test%').empty?
post '/api/v1/overviews', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
assert_response(201)
result = JSON.parse(@response.body)
assert_equal(Hash, result.class)
assert_equal('test_overview', result['name'])
end
# 3. attempt to delete the ticket attribute
get '/api/v1/object_manager_attributes', headers: @headers.merge('Authorization' => credentials)
assert_response(200)
result = JSON.parse(@response.body)
target_attribute = result.select { |x| x['name'] == 'test_attribute_referenced_by_an_overview' && x['object'] == 'Ticket' }
assert_equal target_attribute.size, 1
target_id = target_attribute[0]['id']
delete "/api/v1/object_manager_attributes/#{target_id}", headers: @headers.merge('Authorization' => credentials)
assert_response(422)
assert @response.body.include?('Overview')
assert @response.body.include?('test_overview')
assert @response.body.include?('cannot be deleted!')
end
test '04 ticket attributes cannot be removed when it is referenced by a trigger' do
credentials = ActionController::HttpAuthentication::Basic.encode_credentials('tickets-admin@example.com', 'adminpw')
# 1. create a new ticket attribute and execute migration
migration = ObjectManager::Attribute.migration_execute
params = {
'name': 'test_attribute_referenced_by_a_trigger',
'object': 'Ticket',
'display': 'Test Attribute',
'active': true,
'data_type': 'input',
'data_option': {
'default': '',
'type': 'text',
'maxlength': 120,
'null': true,
'options': {},
'relation': ''
},
'screens': {
'create_middle': {
'ticket.customer': {
'shown': true,
'item_class': 'column'
},
'ticket.agent': {
'shown': true,
'item_class': 'column'
}
},
'edit': {
'ticket.customer': {
'shown': true
},
'ticket.agent': {
'shown': true
}
}
},
}
post '/api/v1/object_manager_attributes', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
migration = ObjectManager::Attribute.migration_execute
assert_equal(migration, true)
# 2. create an trigger that uses the attribute
params = {
name: 'test_trigger',
condition: {
'ticket.test_attribute_referenced_by_a_trigger': {
'operator': 'contains',
'value': 'DUMMY'
}
},
'perform': {
'ticket.state_id': {
'value': '2'
}
},
'active': true,
'id': 'c-3'
}
if Trigger.where('name like ?', '%test%').empty?
post '/api/v1/triggers', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
assert_response(201)
result = JSON.parse(@response.body)
assert_equal(Hash, result.class)
assert_equal('test_trigger', result['name'])
end
# 3. attempt to delete the ticket attribute
get '/api/v1/object_manager_attributes', headers: @headers.merge('Authorization' => credentials)
assert_response(200)
result = JSON.parse(@response.body)
target_attribute = result.select { |x| x['name'] == 'test_attribute_referenced_by_a_trigger' && x['object'] == 'Ticket' }
assert_equal target_attribute.size, 1
target_id = target_attribute[0]['id']
delete "/api/v1/object_manager_attributes/#{target_id}", headers: @headers.merge('Authorization' => credentials)
assert_response(422)
assert @response.body.include?('Trigger')
assert @response.body.include?('test_trigger')
assert @response.body.include?('cannot be deleted!')
end
test '05 ticket attributes cannot be removed when it is referenced by a scheduler' do
credentials = ActionController::HttpAuthentication::Basic.encode_credentials('tickets-admin@example.com', 'adminpw')
# 1. create a new ticket attribute and execute migration
migration = ObjectManager::Attribute.migration_execute
params = {
'name': 'test_attribute_referenced_by_a_scheduler',
'object': 'Ticket',
'display': 'Test Attribute',
'active': true,
'data_type': 'input',
'data_option': {
'default': '',
'type': 'text',
'maxlength': 120,
'null': true,
'options': {},
'relation': ''
},
'screens': {
'create_middle': {
'ticket.customer': {
'shown': true,
'item_class': 'column'
},
'ticket.agent': {
'shown': true,
'item_class': 'column'
}
},
'edit': {
'ticket.customer': {
'shown': true
},
'ticket.agent': {
'shown': true
}
}
},
}
post '/api/v1/object_manager_attributes', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
migration = ObjectManager::Attribute.migration_execute
assert_equal(migration, true)
# 2. create a scheduler that uses the attribute
params = {
name: 'test_scheduler',
'timeplan': {
'days': {
'Mon': true,
'Tue': false,
'Wed': false,
'Thu': false,
'Fri': false,
'Sat': false,
'Sun': false
},
'hours': {
'0': true,
'1': false,
'2': false,
'3': false,
'4': false,
'5': false,
'6': false,
'7': false,
'8': false,
'9': false,
'10': false,
'11': false,
'12': false,
'13': false,
'14': false,
'15': false,
'16': false,
'17': false,
'18': false,
'19': false,
'20': false,
'21': false,
'22': false,
'23': false
},
'minutes': {
'0': true,
'10': false,
'20': false,
'30': false,
'40': false,
'50': false
}
},
'condition': {
'ticket.test_attribute_referenced_by_a_scheduler': {
'operator': 'contains',
'value': 'DUMMY'
}
},
'perform': {
'ticket.state_id': {
'value': '2'
}
},
'disable_notification': true,
'note': '',
'active': true,
'id': 'c-0'
}
if Job.where('name like ?', '%test%').empty?
post '/api/v1/jobs', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
assert_response(201)
result = JSON.parse(@response.body)
assert_equal(Hash, result.class)
assert_equal('test_scheduler', result['name'])
end
# 3. attempt to delete the ticket attribute
get '/api/v1/object_manager_attributes', headers: @headers.merge('Authorization' => credentials)
assert_response(200)
result = JSON.parse(@response.body)
target_attribute = result.select { |x| x['name'] == 'test_attribute_referenced_by_a_scheduler' && x['object'] == 'Ticket' }
assert_equal target_attribute.size, 1
target_id = target_attribute[0]['id']
delete "/api/v1/object_manager_attributes/#{target_id}", headers: @headers.merge('Authorization' => credentials)
assert_response(422)
assert @response.body.include?('Job')
assert @response.body.include?('test_scheduler')
assert @response.body.include?('cannot be deleted!')
end
test '06 ticket attributes can be removed when it is referenced by an overview but by user object' do
credentials = ActionController::HttpAuthentication::Basic.encode_credentials('tickets-admin@example.com', 'adminpw')
# 1. create a new ticket attribute and execute migration
migration = ObjectManager::Attribute.migration_execute
params = {
'name': 'test_attribute_referenced_by_an_overview',
'object': 'Ticket',
'display': 'Test Attribute',
'active': true,
'data_type': 'input',
'data_option': {
'default': '',
'type': 'text',
'maxlength': 120,
'null': true,
'options': {},
'relation': ''
},
'screens': {
'create_middle': {
'ticket.customer': {
'shown': true,
'item_class': 'column'
},
'ticket.agent': {
'shown': true,
'item_class': 'column'
}
},
'edit': {
'ticket.customer': {
'shown': true
},
'ticket.agent': {
'shown': true
}
}
},
}
post '/api/v1/object_manager_attributes', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
params = {
'name': 'test_attribute_referenced_by_an_overview',
'object': 'User',
'display': 'Test Attribute',
'active': true,
'data_type': 'input',
'data_option': {
'default': '',
'type': 'text',
'maxlength': 120,
'null': true,
'options': {},
'relation': ''
},
'screens': {
'create_middle': {
'ticket.customer': {
'shown': true,
'item_class': 'column'
},
'ticket.agent': {
'shown': true,
'item_class': 'column'
}
},
'edit': {
'ticket.customer': {
'shown': true
},
'ticket.agent': {
'shown': true
}
}
},
}
post '/api/v1/object_manager_attributes', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
migration = ObjectManager::Attribute.migration_execute
assert_equal(migration, true)
# 2. create an overview that uses the attribute
params = {
name: 'test_overview',
roles: Role.where(name: 'Agent').pluck(:name),
condition: {
'ticket.state_id': {
'operator': 'is',
'value': Ticket::State.all.pluck(:id),
},
'ticket.test_attribute_referenced_by_an_overview': {
'operator': 'contains',
'value': 'DUMMY'
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w[title customer state created_at],
s: %w[number title customer state created_at],
m: %w[number title customer state created_at],
view_mode_default: 's',
},
user_ids: [ '1' ],
}
if Overview.where('name like ?', '%test%').empty?
post '/api/v1/overviews', params: params.to_json, headers: @headers.merge('Authorization' => credentials)
assert_response(201)
result = JSON.parse(@response.body)
assert_equal(Hash, result.class)
assert_equal('test_overview', result['name'])
end
# 3. attempt to delete the ticket attribute
get '/api/v1/object_manager_attributes', headers: @headers.merge('Authorization' => credentials)
assert_response(200)
result = JSON.parse(@response.body)
target_attribute = result.select { |x| x['name'] == 'test_attribute_referenced_by_an_overview' && x['object'] == 'User' }
assert_equal target_attribute.size, 1
target_id = target_attribute[0]['id']
delete "/api/v1/object_manager_attributes/#{target_id}", headers: @headers.merge('Authorization' => credentials)
assert_response(200)
target_attribute = result.select { |x| x['name'] == 'test_attribute_referenced_by_an_overview' && x['object'] == 'Ticket' }
assert_equal target_attribute.size, 1
target_id = target_attribute[0]['id']
delete "/api/v1/object_manager_attributes/#{target_id}", headers: @headers.merge('Authorization' => credentials)
assert_response(422)
assert @response.body.include?('Overview')
assert @response.body.include?('test_overview')
assert @response.body.include?('cannot be deleted!')
end
end end

View file

@ -663,4 +663,78 @@ class ObjectManagerTest < ActiveSupport::TestCase
end end
test 'c object manager attribute - certain names' do
assert_equal(false, ObjectManager::Attribute.pending_migration?)
assert_equal(0, ObjectManager::Attribute.where(to_migrate: true).count)
assert_equal(0, ObjectManager::Attribute.migrations.count)
attribute1 = ObjectManager::Attribute.add(
object: 'Ticket',
name: '1_a_anfrage_status',
display: '1_a_anfrage_status',
data_type: 'input',
data_option: {
maxlength: 200,
type: 'text',
null: true,
},
active: true,
screens: {},
position: 20,
created_by_id: 1,
updated_by_id: 1,
)
assert(attribute1)
assert_equal(true, ObjectManager::Attribute.pending_migration?)
assert_equal(1, ObjectManager::Attribute.where(to_migrate: true).count)
assert_equal(1, ObjectManager::Attribute.migrations.count)
# execute migrations
assert(ObjectManager::Attribute.migration_execute)
assert_equal(false, ObjectManager::Attribute.pending_migration?)
assert_equal(0, ObjectManager::Attribute.where(to_migrate: true).count)
assert_equal(0, ObjectManager::Attribute.migrations.count)
# create example ticket
ticket1 = Ticket.create(
title: 'some attribute test3',
group: Group.lookup(name: 'Users'),
customer_id: 2,
state: Ticket::State.lookup(name: 'new'),
priority: Ticket::Priority.lookup(name: '2 normal'),
'1_a_anfrage_status': 'some attribute text',
updated_by_id: 1,
created_by_id: 1,
)
assert('ticket1 created', ticket1)
assert_equal('some attribute test3', ticket1.title)
assert_equal('Users', ticket1.group.name)
assert_equal('new', ticket1.state.name)
assert_equal('some attribute text', ticket1['1_a_anfrage_status'])
condition = {
'ticket.title' => {
operator: 'is',
value: 'some attribute test3',
},
}
ticket_count, tickets = Ticket.selectors(condition, 10)
assert_equal(ticket_count, 1)
assert_equal(tickets[0].id, ticket1.id)
condition = {
'ticket.1_a_anfrage_status' => {
operator: 'is',
value: 'some attribute text',
},
}
ticket_count, tickets = Ticket.selectors(condition, 10)
assert_equal(ticket_count, 1)
assert_equal(tickets[0].id, ticket1.id)
end
end end

View file

@ -961,4 +961,106 @@ Some Text'
assert_equal(false, article.internal) assert_equal(false, article.internal)
end end
test 'tags in postmaster filter' do
group_default = Group.lookup(name: 'Users')
PostmasterFilter.create!(
name: '01 set tag for email',
match: {
from: {
operator: 'contains',
value: 'nobody@example.com',
},
},
perform: {
'x-zammad-ticket-tags' => {
operator: 'add',
value: 'test1, test2, test3',
},
},
channel: 'email',
active: true,
created_by_id: 1,
updated_by_id: 1,
)
PostmasterFilter.create!(
name: '02 set tag for email',
match: {
from: {
operator: 'contains',
value: 'nobody@example.com',
},
},
perform: {
'x-zammad-ticket-tags' => {
operator: 'remove',
value: 'test2, test3',
},
},
channel: 'email',
active: true,
created_by_id: 1,
updated_by_id: 1,
)
PostmasterFilter.create!(
name: '03 set tag for email',
match: {
from: {
operator: 'contains',
value: 'nobody@example.com',
},
},
perform: {
'x-zammad-ticket-tags' => {
operator: 'add',
value: 'test3',
},
},
channel: 'email',
active: true,
created_by_id: 1,
updated_by_id: 1,
)
PostmasterFilter.create!(
name: '04 set tag for email',
match: {
from: {
operator: 'contains',
value: 'nobody@example.com',
},
},
perform: {
'x-zammad-ticket-tags' => {
operator: 'add',
value: 'abc1 , abc2 ',
},
},
channel: 'email',
active: true,
created_by_id: 1,
updated_by_id: 1,
)
data = 'From: ME Bob <nobody@example.com>
To: customer@example.com
Subject: some subject
Some Text'
parser = Channel::EmailParser.new
ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data)
tags = Tag.tag_list(object: 'Ticket', o_id: ticket.id)
assert_equal('Users', ticket.group.name)
assert_equal('2 normal', ticket.priority.name)
assert_equal('some subject', ticket.title)
assert_equal('nobody@example.com', ticket.customer.email)
assert_equal(4, tags.count)
assert(tags.include?('test1'))
assert(tags.include?('test3'))
assert(tags.include?('abc1'))
assert(tags.include?('abc2'))
end
end end

View file

@ -115,7 +115,7 @@ class TicketEscalationTest < ActiveSupport::TestCase
ticket.save! ticket.save!
assert_not(ticket.has_changes_to_save?) assert_not(ticket.has_changes_to_save?)
assert(ticket.escalation_at) assert(ticket.escalation_at)
assert_equal((ticket_escalation_at - 30.minutes).to_s, ticket.escalation_at.to_s) assert_in_delta((ticket_escalation_at - 30.minutes).to_i, ticket.escalation_at.to_i, 90)
sla.destroy! sla.destroy!
calendar.destroy! calendar.destroy!
@ -192,10 +192,10 @@ Some Text"
ticket_p, article_p, user_p, mail = Channel::EmailParser.new.process({}, email) ticket_p, article_p, user_p, mail = Channel::EmailParser.new.process({}, email)
ticket_p.reload ticket_p.reload
assert(ticket_p.escalation_at) assert(ticket_p.escalation_at)
assert_in_delta(ticket_p.first_response_escalation_at.to_i, (ticket_p.created_at + 1.hour).to_i, 120) assert_in_delta(ticket_p.first_response_escalation_at.to_i, (ticket_p.created_at + 1.hour).to_i, 90)
assert_in_delta(ticket_p.update_escalation_at.to_i, (ticket_p.created_at + 3.hours).to_i, 120) assert_in_delta(ticket_p.update_escalation_at.to_i, (ticket_p.created_at + 3.hours).to_i, 90)
assert_in_delta(ticket_p.close_escalation_at.to_i, (ticket_p.created_at + 4.hours).to_i, 120) assert_in_delta(ticket_p.close_escalation_at.to_i, (ticket_p.created_at + 4.hours).to_i, 90)
assert_in_delta(ticket_p.escalation_at.to_i, (ticket_p.created_at + 1.hour).to_i, 120) assert_in_delta(ticket_p.escalation_at.to_i, (ticket_p.created_at + 1.hour).to_i, 90)
travel 3.hours travel 3.hours
article = nil article = nil
@ -217,10 +217,10 @@ Some Text"
end end
ticket_p.reload ticket_p.reload
assert_in_delta(ticket_p.first_response_escalation_at.to_i, (ticket_p.created_at + 1.hour).to_i, 120) assert_in_delta(ticket_p.first_response_escalation_at.to_i, (ticket_p.created_at + 1.hour).to_i, 90)
assert_in_delta(ticket_p.update_escalation_at.to_i, (ticket_p.last_contact_agent_at + 3.hours).to_i, 120) assert_in_delta(ticket_p.update_escalation_at.to_i, (ticket_p.last_contact_agent_at + 3.hours).to_i, 90)
assert_in_delta(ticket_p.close_escalation_at.to_i, (ticket_p.created_at + 4.hours).to_i, 120) assert_in_delta(ticket_p.close_escalation_at.to_i, (ticket_p.created_at + 4.hours).to_i, 90)
assert_in_delta(ticket_p.escalation_at.to_i, (ticket_p.created_at + 4.hours).to_i, 120) assert_in_delta(ticket_p.escalation_at.to_i, (ticket_p.created_at + 4.hours).to_i, 90)
end end
end end