Zammad Docker and addon updates
This commit is contained in:
parent
dab5ce0521
commit
aa18d3904e
16 changed files with 1972 additions and 2976 deletions
|
|
@ -0,0 +1,658 @@
|
|||
# coffeelint: disable=camel_case_classes
|
||||
|
||||
###
|
||||
|
||||
UI Element options:
|
||||
|
||||
**attribute.notification**
|
||||
|
||||
- Allows to send notifications (default: false)
|
||||
|
||||
**attribute.ticket_delete**
|
||||
|
||||
- Allows to delete the ticket (default: false)
|
||||
|
||||
**attribute.user_action**
|
||||
|
||||
- Allows pre conditions like current_user.id or user session specific values (default: true)
|
||||
|
||||
**attribute.article_body_cc_only**
|
||||
|
||||
- Renders only article body and cc attributes (default: false)
|
||||
|
||||
**attribute.no_dates**
|
||||
|
||||
- Does not include `date` and `datetime` attributes (default: false)
|
||||
|
||||
**attribute.no_richtext_uploads**
|
||||
|
||||
- Removes support for uploads in richtext attributes (default: false)
|
||||
|
||||
**attribute.sender_type**
|
||||
|
||||
- Includes sender type as a ticket attribute (default: false)
|
||||
|
||||
**attribute.simple_attribute_selector**
|
||||
|
||||
- Renders a simpler attribute without operator support (default: false)
|
||||
|
||||
**attribute.skip_unknown_attributes**
|
||||
|
||||
- Skips rendering of unknown attributes (default: false)
|
||||
|
||||
###
|
||||
|
||||
class App.UiElement.ApplicationAction
|
||||
@defaults: (attribute) ->
|
||||
defaults = ['ticket.state_id']
|
||||
|
||||
groups =
|
||||
ticket:
|
||||
name: __('Ticket')
|
||||
model: 'Ticket'
|
||||
article:
|
||||
name: __('Article')
|
||||
model: if attribute.article_body_cc_only then 'TicketArticle' else 'Article'
|
||||
|
||||
if attribute.notification
|
||||
groups.notification =
|
||||
name: __('Notification')
|
||||
model: 'Notification'
|
||||
|
||||
# merge config
|
||||
elements = {}
|
||||
for groupKey, groupMeta of groups
|
||||
if !groupMeta.model || !App[groupMeta.model]
|
||||
if groupKey is 'notification'
|
||||
elements["#{groupKey}.email"] = { name: 'email', display: __('Email') }
|
||||
elements["#{groupKey}.sms"] = { name: 'sms', display: __('SMS') }
|
||||
elements["#{groupKey}.webhook"] = { name: 'webhook', display: __('Webhook') }
|
||||
else if groupKey is 'article'
|
||||
elements["#{groupKey}.note"] = { name: 'note', display: __('Note') }
|
||||
else
|
||||
|
||||
for row in App[groupMeta.model].configure_attributes
|
||||
|
||||
# ignore all article attributes except body and cc
|
||||
if attribute.article_body_cc_only
|
||||
if groupMeta.model is 'TicketArticle'
|
||||
if row.name isnt 'body' and row.name isnt 'cc'
|
||||
continue
|
||||
|
||||
# ignore all date and datetime attributes
|
||||
if attribute.no_dates
|
||||
if row.tag is 'date' || row.tag is 'datetime'
|
||||
continue
|
||||
|
||||
# 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)
|
||||
|
||||
# disable uploads in richtext attributes
|
||||
if attribute.no_richtext_uploads
|
||||
if config.tag is 'richtext'
|
||||
config.upload = false
|
||||
|
||||
switch config.tag
|
||||
when 'datetime'
|
||||
config.operator = ['static', 'relative']
|
||||
when 'tag'
|
||||
config.operator = ['add', 'remove']
|
||||
|
||||
elements["#{groupKey}.#{config.name}"] = config
|
||||
|
||||
# add ticket deletion action
|
||||
if attribute.ticket_delete
|
||||
elements['ticket.action'] =
|
||||
name: 'action'
|
||||
display: __('Action')
|
||||
tag: 'select'
|
||||
null: false
|
||||
translate: true
|
||||
options:
|
||||
delete: 'Delete'
|
||||
|
||||
# add sender type selection as a ticket attribute
|
||||
if attribute.sender_type
|
||||
elements['ticket.formSenderType'] =
|
||||
name: 'formSenderType'
|
||||
display: __('Sender Type')
|
||||
tag: 'select'
|
||||
null: false
|
||||
translate: true
|
||||
options: [
|
||||
{ value: 'phone-in', name: __('Inbound Call') },
|
||||
{ value: 'phone-out', name: __('Outbound Call') },
|
||||
{ value: 'email-out', name: __('Email') },
|
||||
]
|
||||
|
||||
[defaults, groups, elements]
|
||||
|
||||
@placeholder: (elementFull, attribute, params, groups, elements) ->
|
||||
item = $( App.view('generic/ticket_perform_action/row')( attribute: attribute ) )
|
||||
selector = @buildAttributeSelector(elementFull, groups, elements)
|
||||
item.find('.js-attributeSelector').prepend(selector)
|
||||
item
|
||||
|
||||
@render: (attribute, params = {}) ->
|
||||
|
||||
[defaults, groups, elements] = @defaults(attribute)
|
||||
|
||||
# return item
|
||||
item = $( App.view('generic/ticket_perform_action/index')( attribute: attribute ) )
|
||||
|
||||
# add filter
|
||||
item.on('click', '.js-rowActions .js-add', (e) =>
|
||||
element = $(e.target).closest('.js-filterElement')
|
||||
placeholder = @placeholder(item, attribute, params, groups, elements)
|
||||
if element.get(0)
|
||||
element.after(placeholder)
|
||||
else
|
||||
item.append(placeholder)
|
||||
placeholder.find('.js-attributeSelector select').trigger('change')
|
||||
@updateAttributeSelectors(item)
|
||||
)
|
||||
|
||||
# remove filter
|
||||
item.on('click', '.js-rowActions .js-remove', (e) =>
|
||||
return if $(e.currentTarget).hasClass('is-disabled')
|
||||
$(e.target).closest('.js-filterElement').remove()
|
||||
@updateAttributeSelectors(item)
|
||||
)
|
||||
|
||||
# change attribute selector
|
||||
item.on('change', '.js-attributeSelector select', (e) =>
|
||||
elementRow = $(e.target).closest('.js-filterElement')
|
||||
groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value')
|
||||
@rebuildAttributeSelectors(item, elementRow, groupAndAttribute, elements, {}, attribute)
|
||||
@updateAttributeSelectors(item)
|
||||
)
|
||||
|
||||
# change operator selector
|
||||
item.on('change', '.js-operator select', (e) =>
|
||||
elementRow = $(e.target).closest('.js-filterElement')
|
||||
groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value')
|
||||
@buildOperator(item, elementRow, groupAndAttribute, elements, {}, attribute)
|
||||
)
|
||||
|
||||
# build initial params
|
||||
if _.isEmpty(params[attribute.name])
|
||||
|
||||
for groupAndAttribute in defaults
|
||||
|
||||
# build and append
|
||||
element = @placeholder(item, attribute, params, groups, elements)
|
||||
item.append(element)
|
||||
@rebuildAttributeSelectors(item, element, groupAndAttribute, elements, {}, attribute)
|
||||
|
||||
else
|
||||
|
||||
for groupAndAttribute, meta of params[attribute.name]
|
||||
# Skip unknown attributes.
|
||||
continue if attribute.skip_unknown_attributes and !_.includes(_.keys(elements), groupAndAttribute)
|
||||
|
||||
# build and append
|
||||
element = @placeholder(item, attribute, params, groups, elements)
|
||||
@rebuildAttributeSelectors(item, element, groupAndAttribute, elements, meta, attribute)
|
||||
item.append(element)
|
||||
|
||||
@disableRemoveForOneAttribute(item)
|
||||
item
|
||||
|
||||
@elementKeyGroup: (elementKey) ->
|
||||
elementKey.split(/\./)[0]
|
||||
|
||||
@buildAttributeSelector: (elementFull, groups, elements) ->
|
||||
|
||||
# find first possible attribute
|
||||
selectedValue = ''
|
||||
elementFull.find('.js-attributeSelector select option').each(->
|
||||
if !selectedValue && !$(@).prop('disabled')
|
||||
selectedValue = $(@).val()
|
||||
)
|
||||
|
||||
selection = $('<select class="form-control"></select>')
|
||||
for groupKey, groupMeta of groups
|
||||
displayName = App.i18n.translateInline(groupMeta.name)
|
||||
selection.closest('select').append("<optgroup label=\"#{displayName}\" class=\"js-#{groupKey}\"></optgroup>")
|
||||
optgroup = selection.find("optgroup.js-#{groupKey}")
|
||||
for elementKey, elementGroup of elements
|
||||
elementGroup = @elementKeyGroup(elementKey)
|
||||
if elementGroup is groupKey
|
||||
attributeConfig = elements[elementKey]
|
||||
displayName = App.i18n.translateInline(attributeConfig.display)
|
||||
|
||||
selected = ''
|
||||
if elementKey is selectedValue
|
||||
selected = 'selected="selected"'
|
||||
optgroup.append("<option value=\"#{elementKey}\" #{selected}>#{displayName}</option>")
|
||||
selection
|
||||
|
||||
# disable - if we only have one attribute
|
||||
@disableRemoveForOneAttribute: (elementFull) ->
|
||||
if elementFull.find('.js-attributeSelector select').length > 1
|
||||
elementFull.find('.js-remove').removeClass('is-disabled')
|
||||
else
|
||||
elementFull.find('.js-remove').addClass('is-disabled')
|
||||
|
||||
@updateAttributeSelectors: (elementFull) ->
|
||||
|
||||
# enable all
|
||||
elementFull.find('.js-attributeSelector select option').prop('disabled', false)
|
||||
|
||||
# disable all used attributes
|
||||
elementFull.find('.js-attributeSelector select').each(->
|
||||
keyLocal = $(@).val()
|
||||
elementFull.find('.js-attributeSelector select option[value="' + keyLocal + '"]').attr('disabled', true)
|
||||
)
|
||||
|
||||
# disable - if we only have one attribute
|
||||
@disableRemoveForOneAttribute(elementFull)
|
||||
|
||||
@rebuildAttributeSelectors: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
|
||||
# set attribute
|
||||
if groupAndAttribute
|
||||
elementRow.find('.js-attributeSelector select').val(groupAndAttribute)
|
||||
|
||||
notificationTypeMatch = groupAndAttribute.match(/^notification.([\w]+)$/)
|
||||
articleTypeMatch = groupAndAttribute.match(/^article.([\w]+)$/)
|
||||
|
||||
if _.isArray(notificationTypeMatch) && notificationType = notificationTypeMatch[1]
|
||||
elementRow.find('.js-setAttribute').html('').addClass('hide')
|
||||
elementRow.find('.js-setArticle').html('').addClass('hide')
|
||||
@buildNotificationArea(notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
else if !attribute.article_body_cc_only && _.isArray(articleTypeMatch) && articleType = articleTypeMatch[1]
|
||||
elementRow.find('.js-setAttribute').html('').addClass('hide')
|
||||
elementRow.find('.js-setNotification').html('').addClass('hide')
|
||||
@buildArticleArea(articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
else
|
||||
elementRow.find('.js-setNotification').html('').addClass('hide')
|
||||
elementRow.find('.js-setArticle').html('').addClass('hide')
|
||||
if !elementRow.find('.js-setAttribute div').get(0)
|
||||
attributeSelectorElement = $( App.view('generic/ticket_perform_action/attribute_selector')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
meta: meta || {}
|
||||
))
|
||||
elementRow.find('.js-setAttribute').html(attributeSelectorElement).removeClass('hide')
|
||||
|
||||
if attribute.simple_attribute_selector
|
||||
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
else
|
||||
@buildOperator(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
|
||||
@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 || !attributeConfig.operator
|
||||
elementRow.find('.js-operator').parent().addClass('hide')
|
||||
else
|
||||
elementRow.find('.js-operator').parent().removeClass('hide')
|
||||
if attributeConfig && 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)
|
||||
|
||||
@buildPreCondition(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
|
||||
@buildPreCondition: (elementFull, elementRow, groupAndAttribute, elements, meta, attributeConfig) ->
|
||||
currentOperator = elementRow.find('.js-operator option:selected').attr('value')
|
||||
currentPreCondition = elementRow.find('.js-preCondition option:selected').attr('value')
|
||||
|
||||
if !meta.pre_condition
|
||||
meta.pre_condition = currentPreCondition
|
||||
|
||||
toggleValue = =>
|
||||
preCondition = elementRow.find('.js-preCondition option:selected').attr('value')
|
||||
if preCondition isnt 'specific'
|
||||
elementRow.find('.js-value select').html('')
|
||||
elementRow.find('.js-value').addClass('hide')
|
||||
else
|
||||
elementRow.find('.js-value').removeClass('hide')
|
||||
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
|
||||
# force to use auto complition on user lookup
|
||||
attribute = clone(attributeConfig, true)
|
||||
|
||||
name = "#{attribute.name}::#{groupAndAttribute}::value"
|
||||
attributeSelected = elements[groupAndAttribute]
|
||||
|
||||
preCondition = false
|
||||
if attributeSelected?.relation is 'User'
|
||||
preCondition = 'user'
|
||||
attribute.tag = 'user_autocompletion'
|
||||
if attributeSelected?.relation is 'Organization'
|
||||
preCondition = 'org'
|
||||
attribute.tag = 'autocompletion_ajax'
|
||||
if !preCondition || attribute.user_action is false
|
||||
elementRow.find('.js-preCondition select').html('')
|
||||
elementRow.find('.js-preCondition').closest('.controls').addClass('hide')
|
||||
toggleValue()
|
||||
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
return
|
||||
|
||||
elementRow.find('.js-preCondition').closest('.controls').removeClass('hide')
|
||||
name = "#{attribute.name}::#{groupAndAttribute}::pre_condition"
|
||||
|
||||
selection = $("<select class=\"form-control\" name=\"#{name}\" ></select>")
|
||||
options = {}
|
||||
if preCondition is 'user'
|
||||
options =
|
||||
'current_user.id': App.i18n.translateInline('current user')
|
||||
'specific': App.i18n.translateInline('specific user')
|
||||
|
||||
if attributeSelected.null is true
|
||||
options['not_set'] = App.i18n.translateInline('unassign user')
|
||||
|
||||
else if preCondition is 'org'
|
||||
options =
|
||||
'current_user.organization_id': App.i18n.translateInline('current user organization')
|
||||
'specific': App.i18n.translateInline('specific organization')
|
||||
|
||||
for key, value of options
|
||||
selected = ''
|
||||
if key is meta.pre_condition
|
||||
selected = 'selected="selected"'
|
||||
selection.append("<option value=\"#{key}\" #{selected}>#{App.i18n.translateInline(value)}</option>")
|
||||
elementRow.find('.js-preCondition').closest('.controls').removeClass('hide')
|
||||
elementRow.find('.js-preCondition select').replaceWith(selection)
|
||||
|
||||
elementRow.find('.js-preCondition select').on('change', (e) ->
|
||||
toggleValue()
|
||||
)
|
||||
|
||||
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
toggleValue()
|
||||
|
||||
@buildValue: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
name = "#{attribute.name}::#{groupAndAttribute}::value"
|
||||
|
||||
# build new item
|
||||
attributeConfig = elements[groupAndAttribute]
|
||||
config = clone(attributeConfig, true)
|
||||
|
||||
if config?.relation is 'User'
|
||||
config.tag = 'user_autocompletion'
|
||||
config.disableCreateObject = true
|
||||
if config?.relation is 'Organization'
|
||||
config.tag = 'autocompletion_ajax'
|
||||
|
||||
# render ui element
|
||||
item = ''
|
||||
if config && App.UiElement[config.tag]
|
||||
config['name'] = name
|
||||
if attribute.value && attribute.value[groupAndAttribute]
|
||||
config['value'] = _.clone(attribute.value[groupAndAttribute]['value'])
|
||||
config.multiple = false
|
||||
config.default = undefined
|
||||
config.nulloption = config.null
|
||||
if config.tag is 'multiselect' || config.tag is 'multi_tree_select'
|
||||
config.multiple = true
|
||||
if config.tag is 'checkbox'
|
||||
config.tag = 'select'
|
||||
if config.tag is 'datetime'
|
||||
config.validationContainer = 'self'
|
||||
item = App.UiElement[config.tag].render(config, {})
|
||||
|
||||
relative_operators = [
|
||||
__('before (relative)'),
|
||||
__('within next (relative)'),
|
||||
__('within last (relative)'),
|
||||
__('after (relative)'),
|
||||
__('till (relative)'),
|
||||
__('from (relative)'),
|
||||
__('relative'),
|
||||
]
|
||||
|
||||
upcoming_operator = meta?.operator
|
||||
|
||||
if !_.include(config?.operator, upcoming_operator)
|
||||
if Array.isArray(config?.operator)
|
||||
upcoming_operator = config.operator[0]
|
||||
else
|
||||
upcoming_operator = null
|
||||
|
||||
if _.include(relative_operators, upcoming_operator)
|
||||
config['name'] = "#{attribute.name}::#{groupAndAttribute}"
|
||||
if attribute.value && attribute.value[groupAndAttribute]
|
||||
config['value'] = _.clone(attribute.value[groupAndAttribute])
|
||||
item = App.UiElement['time_range'].render(config, {})
|
||||
|
||||
elementRow.find('.js-setAttribute > .flex > .js-value').removeClass('hide').html(item)
|
||||
|
||||
@buildNotificationArea: (notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
|
||||
return if elementRow.find(".js-setNotification .js-body-#{notificationType}").get(0)
|
||||
|
||||
elementRow.find('.js-setNotification').empty()
|
||||
|
||||
options =
|
||||
'article_last_sender': __('Sender of last article')
|
||||
'ticket_owner': __('Owner')
|
||||
'ticket_customer': __('Customer')
|
||||
'ticket_agents': __('All agents')
|
||||
|
||||
name = "#{attribute.name}::notification.#{notificationType}"
|
||||
|
||||
messageLength = switch notificationType
|
||||
when 'sms' then 160
|
||||
else 200000
|
||||
|
||||
# meta.recipient was a string in the past (single-select) so we convert it to array if needed
|
||||
if !_.isArray(meta.recipient)
|
||||
meta.recipient = [meta.recipient]
|
||||
|
||||
columnSelectOptions = []
|
||||
for key, value of options
|
||||
selected = undefined
|
||||
for recipient in meta.recipient
|
||||
if key is recipient
|
||||
selected = true
|
||||
columnSelectOptions.push({ value: key, name: App.i18n.translatePlain(value), selected: selected })
|
||||
|
||||
columnSelectRecipientUserOptions = []
|
||||
for user in App.User.all()
|
||||
key = "userid_#{user.id}"
|
||||
selected = undefined
|
||||
for recipient in meta.recipient
|
||||
if key is recipient
|
||||
selected = true
|
||||
columnSelectRecipientUserOptions.push({ value: key, name: "#{user.firstname} #{user.lastname}", selected: selected })
|
||||
|
||||
columnSelectRecipient = new App.ColumnSelect
|
||||
attribute:
|
||||
name: "#{name}::recipient"
|
||||
options: [
|
||||
{
|
||||
label: __('Variables'),
|
||||
group: columnSelectOptions
|
||||
},
|
||||
{
|
||||
label: __('User'),
|
||||
group: columnSelectRecipientUserOptions
|
||||
},
|
||||
]
|
||||
|
||||
selectionRecipient = columnSelectRecipient.element()
|
||||
|
||||
if notificationType is 'webhook'
|
||||
notificationElement = $( App.view('generic/ticket_perform_action/webhook')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
notificationType: notificationType
|
||||
meta: meta || {}
|
||||
))
|
||||
|
||||
notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
|
||||
|
||||
|
||||
if App.Webhook.search(filter: { active: true }).length isnt 0 || !_.isEmpty(meta.webhook_id)
|
||||
webhookSelection = App.UiElement.select.render(
|
||||
name: "#{name}::webhook_id"
|
||||
multiple: false
|
||||
null: false
|
||||
relation: 'Webhook'
|
||||
value: meta.webhook_id
|
||||
translate: false
|
||||
nulloption: true
|
||||
)
|
||||
else
|
||||
webhookSelection = App.view('generic/ticket_perform_action/webhook_not_available')( attribute: attribute )
|
||||
|
||||
notificationElement.find('.js-webhooks').html(webhookSelection)
|
||||
|
||||
else
|
||||
notificationElement = $( App.view('generic/ticket_perform_action/notification')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
notificationType: notificationType
|
||||
meta: meta || {}
|
||||
))
|
||||
|
||||
notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
|
||||
|
||||
visibilitySelection = App.UiElement.select.render(
|
||||
name: "#{name}::internal"
|
||||
multiple: false
|
||||
null: false
|
||||
options: { true: __('internal'), false: __('public') }
|
||||
value: meta.internal || 'false'
|
||||
translate: true
|
||||
)
|
||||
|
||||
includeAttachmentsCheckbox = App.UiElement.select.render(
|
||||
name: "#{name}::include_attachments"
|
||||
multiple: false
|
||||
null: false
|
||||
options: { true: __('Yes'), false: __('No') }
|
||||
value: meta.include_attachments || 'false'
|
||||
translate: true
|
||||
)
|
||||
|
||||
notificationElement.find('.js-internal').html(visibilitySelection)
|
||||
notificationElement.find('.js-include_attachments').html(includeAttachmentsCheckbox)
|
||||
|
||||
notificationElement.find('.js-body div[contenteditable="true"]').ce(
|
||||
mode: 'richtext'
|
||||
placeholder: __('message')
|
||||
maxlength: messageLength
|
||||
)
|
||||
new App.WidgetPlaceholder(
|
||||
el: notificationElement.find('.js-body div[contenteditable="true"]').parent()
|
||||
objects: [
|
||||
{
|
||||
prefix: 'ticket'
|
||||
object: 'Ticket'
|
||||
display: __('Ticket')
|
||||
},
|
||||
{
|
||||
prefix: 'article'
|
||||
object: 'TicketArticle'
|
||||
display: __('Article')
|
||||
},
|
||||
{
|
||||
prefix: 'user'
|
||||
object: 'User'
|
||||
display: __('Current User')
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
elementRow.find('.js-setNotification').html(notificationElement).removeClass('hide')
|
||||
|
||||
if App.Config.get('smime_integration') == true || App.Config.get('pgp_integration') == true
|
||||
selection = App.UiElement.select.render(
|
||||
name: "#{name}::sign"
|
||||
multiple: false
|
||||
options: {
|
||||
'no': __('Do not sign email')
|
||||
'discard': __('Sign email (if not possible, discard notification)')
|
||||
'always': __('Sign email (if not possible, send notification anyway)')
|
||||
}
|
||||
value: meta.sign
|
||||
translate: true
|
||||
)
|
||||
|
||||
elementRow.find('.js-sign').html(selection)
|
||||
|
||||
selection = App.UiElement.select.render(
|
||||
name: "#{name}::encryption"
|
||||
multiple: false
|
||||
options: {
|
||||
'no': __('Do not encrypt email')
|
||||
'discard': __('Encrypt email (if not possible, discard notification)')
|
||||
'always': __('Encrypt email (if not possible, send notification anyway)')
|
||||
}
|
||||
value: meta.encryption
|
||||
translate: true
|
||||
)
|
||||
|
||||
elementRow.find('.js-encryption').html(selection)
|
||||
|
||||
@buildArticleArea: (articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
|
||||
return if elementRow.find(".js-setArticle .js-body-#{articleType}").get(0)
|
||||
|
||||
elementRow.find('.js-setArticle').empty()
|
||||
|
||||
name = "#{attribute.name}::article.#{articleType}"
|
||||
selection = App.UiElement.select.render(
|
||||
name: "#{name}::internal"
|
||||
multiple: false
|
||||
null: false
|
||||
label: __('Visibility')
|
||||
options: { true: 'internal', false: 'public' }
|
||||
value: meta.internal
|
||||
translate: true
|
||||
)
|
||||
articleElement = $( App.view('generic/ticket_perform_action/article')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
articleType: articleType
|
||||
meta: meta || {}
|
||||
))
|
||||
articleElement.find('.js-internal').html(selection)
|
||||
articleElement.find('.js-body div[contenteditable="true"]').ce(
|
||||
mode: 'richtext'
|
||||
placeholder: __('message')
|
||||
maxlength: 200000
|
||||
)
|
||||
new App.WidgetPlaceholder(
|
||||
el: articleElement.find('.js-body div[contenteditable="true"]').parent()
|
||||
objects: [
|
||||
{
|
||||
prefix: 'ticket'
|
||||
object: 'Ticket'
|
||||
display: __('Ticket')
|
||||
},
|
||||
{
|
||||
prefix: 'article'
|
||||
object: 'TicketArticle'
|
||||
display: __('Article')
|
||||
},
|
||||
{
|
||||
prefix: 'user'
|
||||
object: 'User'
|
||||
display: __('Current User')
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
elementRow.find('.js-setArticle').html(articleElement).removeClass('hide')
|
||||
|
|
@ -1,19 +1,17 @@
|
|||
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
||||
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Ticket < ApplicationModel
|
||||
include CanBeImported
|
||||
include HasActivityStreamLog
|
||||
include ChecksClientNotification
|
||||
include ChecksLatestChangeObserved
|
||||
include CanCsvImport
|
||||
include ChecksHtmlSanitized
|
||||
include HasHistory
|
||||
include HasTags
|
||||
include HasSearchIndexBackend
|
||||
include HasOnlineNotifications
|
||||
include HasKarmaActivityLog
|
||||
include HasLinks
|
||||
include HasObjectManagerAttributesValidation
|
||||
include HasObjectManagerAttributes
|
||||
include HasTaskbars
|
||||
include Ticket::CallsStatsTicketReopenLog
|
||||
include Ticket::EnqueuesUserTicketCounterJob
|
||||
|
|
@ -21,6 +19,8 @@ class Ticket < ApplicationModel
|
|||
include Ticket::SetsCloseTime
|
||||
include Ticket::SetsOnlineNotificationSeen
|
||||
include Ticket::TouchesAssociations
|
||||
include Ticket::TriggersSubscriptions
|
||||
include Ticket::ChecksReopenAfterCertainTime
|
||||
|
||||
include ::Ticket::Escalation
|
||||
include ::Ticket::Subject
|
||||
|
|
@ -38,10 +38,15 @@ class Ticket < ApplicationModel
|
|||
|
||||
include HasTransactionDispatcher
|
||||
|
||||
# workflow checks should run after before_create and before_update callbacks
|
||||
include ChecksCoreWorkflow
|
||||
|
||||
validates :group_id, presence: true
|
||||
|
||||
activity_stream_permission 'ticket.agent'
|
||||
|
||||
core_workflow_screens 'create_middle', 'edit', 'overview_bulk'
|
||||
|
||||
activity_stream_attributes_ignored :organization_id, # organization_id will change automatically on user update
|
||||
:create_article_type_id,
|
||||
:create_article_sender_id,
|
||||
|
|
@ -57,19 +62,26 @@ class Ticket < ApplicationModel
|
|||
:update_escalation_at,
|
||||
:update_in_min,
|
||||
:update_diff_in_min,
|
||||
:last_close_at,
|
||||
:last_contact_at,
|
||||
:last_contact_agent_at,
|
||||
:last_contact_customer_at,
|
||||
:last_owner_update_at,
|
||||
:preferences
|
||||
|
||||
search_index_attributes_relevant :organization_id,
|
||||
:group_id,
|
||||
:state_id,
|
||||
:priority_id
|
||||
|
||||
history_attributes_ignored :create_article_type_id,
|
||||
:create_article_sender_id,
|
||||
:article_count,
|
||||
:preferences
|
||||
|
||||
history_relation_object 'Ticket::Article', 'Mention'
|
||||
history_relation_object 'Ticket::Article', 'Mention', 'Ticket::SharedDraftZoom'
|
||||
|
||||
validates :note, length: { maximum: 250 }
|
||||
sanitized_html :note
|
||||
|
||||
belongs_to :group, optional: true
|
||||
|
|
@ -78,6 +90,7 @@ class Ticket < ApplicationModel
|
|||
has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket
|
||||
has_many :flags, class_name: 'Ticket::Flag', dependent: :destroy
|
||||
has_many :mentions, as: :mentionable, dependent: :destroy
|
||||
has_one :shared_draft, class_name: 'Ticket::SharedDraftZoom', inverse_of: :ticket, dependent: :destroy
|
||||
belongs_to :state, class_name: 'Ticket::State', optional: true
|
||||
belongs_to :priority, class_name: 'Ticket::Priority', optional: true
|
||||
belongs_to :owner, class_name: 'User', optional: true
|
||||
|
|
@ -93,43 +106,6 @@ class Ticket < ApplicationModel
|
|||
|
||||
=begin
|
||||
|
||||
get user access conditions
|
||||
|
||||
conditions = Ticket.access_condition( User.find(1) , 'full')
|
||||
|
||||
returns
|
||||
|
||||
result = [user1, user2, ...]
|
||||
|
||||
=end
|
||||
|
||||
def self.access_condition(user, access)
|
||||
sql = []
|
||||
bind = []
|
||||
|
||||
if user.permissions?('ticket.agent')
|
||||
sql.push('group_id IN (?)')
|
||||
bind.push(user.group_ids_access(access))
|
||||
end
|
||||
|
||||
if user.permissions?('ticket.customer')
|
||||
if !user.organization || ( !user.organization.shared || user.organization.shared == false )
|
||||
sql.push('tickets.customer_id = ?')
|
||||
bind.push(user.id)
|
||||
else
|
||||
sql.push('(tickets.customer_id = ? OR tickets.organization_id = ?)')
|
||||
bind.push(user.id)
|
||||
bind.push(user.organization.id)
|
||||
end
|
||||
end
|
||||
|
||||
return if sql.blank?
|
||||
|
||||
[ sql.join(' OR ') ].concat(bind)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
processes tickets which have reached their pending time and sets next state_id
|
||||
|
||||
processed_tickets = Ticket.process_pending
|
||||
|
|
@ -204,6 +180,31 @@ returns
|
|||
result
|
||||
end
|
||||
|
||||
def auto_assign(user)
|
||||
return if !persisted?
|
||||
return if Setting.get('ticket_auto_assignment').blank?
|
||||
return if owner_id != 1
|
||||
return if !TicketPolicy.new(user, self).full?
|
||||
|
||||
user_ids_ignore = Array(Setting.get('ticket_auto_assignment_user_ids_ignore')).map(&:to_i)
|
||||
return if user_ids_ignore.include?(user.id)
|
||||
|
||||
ticket_auto_assignment_selector = Setting.get('ticket_auto_assignment_selector')
|
||||
return if ticket_auto_assignment_selector.blank?
|
||||
|
||||
condition = ticket_auto_assignment_selector[:condition].merge(
|
||||
'ticket.id' => {
|
||||
'operator' => 'is',
|
||||
'value' => id,
|
||||
}
|
||||
)
|
||||
|
||||
ticket_count, = Ticket.selectors(condition, limit: 1, current_user: user, access: 'full')
|
||||
return if ticket_count.to_i.zero?
|
||||
|
||||
update!(owner: user)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
processes escalated tickets
|
||||
|
|
@ -220,7 +221,7 @@ returns
|
|||
result = []
|
||||
|
||||
# fetch all escalated and soon to be escalating tickets
|
||||
where('escalation_at <= ?', Time.zone.now + 15.minutes).find_each(batch_size: 500) do |ticket|
|
||||
where('escalation_at <= ?', 15.minutes.from_now).find_each(batch_size: 500) do |ticket|
|
||||
|
||||
article_id = nil
|
||||
article = Ticket::Article.last_customer_agent_article(ticket.id)
|
||||
|
|
@ -241,7 +242,7 @@ returns
|
|||
next
|
||||
end
|
||||
|
||||
# check if warning need to be sent
|
||||
# check if warning needs to be sent
|
||||
TransactionJob.perform_now(
|
||||
object: 'Ticket',
|
||||
type: 'escalation_warning',
|
||||
|
|
@ -321,10 +322,10 @@ returns
|
|||
# prevent cross merging tickets
|
||||
target_ticket = Ticket.find_by(id: data[:ticket_id])
|
||||
raise 'no target ticket given' if !target_ticket
|
||||
raise Exceptions::UnprocessableEntity, 'ticket already merged, no merge into merged ticket possible' if target_ticket.state.state_type.name == 'merged'
|
||||
raise Exceptions::UnprocessableEntity, __('It is not possible to merge into an already merged ticket.') if target_ticket.state.state_type.name == 'merged'
|
||||
|
||||
# check different ticket ids
|
||||
raise Exceptions::UnprocessableEntity, 'Can\'t merge ticket with it self!' if id == target_ticket.id
|
||||
raise Exceptions::UnprocessableEntity, __('A ticket cannot be merged into itself.') if id == target_ticket.id
|
||||
|
||||
# update articles
|
||||
Transaction.execute context: 'merge' do
|
||||
|
|
@ -413,6 +414,26 @@ returns
|
|||
|
||||
# touch new ticket (to broadcast change)
|
||||
target_ticket.touch # rubocop:disable Rails/SkipsModelValidations
|
||||
|
||||
EventBuffer.add('transaction', {
|
||||
object: target_ticket.class.name,
|
||||
type: 'update.received_merge',
|
||||
data: target_ticket,
|
||||
changes: {},
|
||||
id: target_ticket.id,
|
||||
user_id: UserInfo.current_user_id,
|
||||
created_at: Time.zone.now,
|
||||
})
|
||||
|
||||
EventBuffer.add('transaction', {
|
||||
object: self.class.name,
|
||||
type: 'update.merged_into',
|
||||
data: self,
|
||||
changes: {},
|
||||
id: id,
|
||||
user_id: UserInfo.current_user_id,
|
||||
created_at: Time.zone.now,
|
||||
})
|
||||
end
|
||||
true
|
||||
end
|
||||
|
|
@ -489,7 +510,7 @@ get count of tickets and tickets which match on selector
|
|||
access = options[:access] || 'full'
|
||||
raise 'no selectors given' if !selectors
|
||||
|
||||
query, bind_params, tables = selector2sql(selectors, current_user: current_user, execution_time: options[:execution_time])
|
||||
query, bind_params, tables = selector2sql(selectors, options)
|
||||
return [] if !query
|
||||
|
||||
ActiveRecord::Base.transaction(requires_new: true) do
|
||||
|
|
@ -497,20 +518,20 @@ get count of tickets and tickets which match on selector
|
|||
if !current_user || access == 'ignore'
|
||||
ticket_count = Ticket.distinct.where(query, *bind_params).joins(tables).count
|
||||
tickets = Ticket.distinct.where(query, *bind_params).joins(tables).limit(limit)
|
||||
return [ticket_count, tickets]
|
||||
next [ticket_count, tickets]
|
||||
end
|
||||
|
||||
access_condition = Ticket.access_condition(current_user, access)
|
||||
ticket_count = Ticket.distinct.where(access_condition).where(query, *bind_params).joins(tables).count
|
||||
tickets = Ticket.distinct.where(access_condition).where(query, *bind_params).joins(tables).limit(limit)
|
||||
tickets = "TicketPolicy::#{access.camelize}Scope".constantize
|
||||
.new(current_user).resolve
|
||||
.distinct
|
||||
.where(query, *bind_params)
|
||||
.joins(tables)
|
||||
|
||||
return [ticket_count, tickets]
|
||||
next [tickets.count, tickets.limit(limit)]
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
Rails.logger.error e
|
||||
raise ActiveRecord::Rollback
|
||||
|
||||
end
|
||||
[]
|
||||
end
|
||||
|
||||
=begin
|
||||
|
|
@ -561,419 +582,7 @@ condition example
|
|||
=end
|
||||
|
||||
def self.selector2sql(selectors, options = {})
|
||||
current_user = options[:current_user]
|
||||
current_user_id = UserInfo.current_user_id
|
||||
if current_user
|
||||
current_user_id = current_user.id
|
||||
end
|
||||
return if !selectors
|
||||
|
||||
# remember query and bind params
|
||||
query = ''
|
||||
bind_params = []
|
||||
like = Rails.application.config.db_like
|
||||
|
||||
if selectors.respond_to?(:permit!)
|
||||
selectors = selectors.permit!.to_h
|
||||
end
|
||||
|
||||
# get tables to join
|
||||
tables = ''
|
||||
selectors.each do |attribute, selector_raw|
|
||||
attributes = attribute.split('.')
|
||||
selector = selector_raw.stringify_keys
|
||||
next if !attributes[1]
|
||||
next if attributes[0] == 'execution_time'
|
||||
next if tables.include?(attributes[0])
|
||||
next if attributes[0] == 'ticket' && attributes[1] != 'mention_user_ids'
|
||||
next if attributes[0] == 'ticket' && attributes[1] == 'mention_user_ids' && selector['pre_condition'] == 'not_set'
|
||||
|
||||
if query != ''
|
||||
query += ' AND '
|
||||
end
|
||||
case attributes[0]
|
||||
when 'customer'
|
||||
tables += ', users customers'
|
||||
query += 'tickets.customer_id = customers.id'
|
||||
when 'organization'
|
||||
tables += ', organizations'
|
||||
query += 'tickets.organization_id = organizations.id'
|
||||
when 'owner'
|
||||
tables += ', users owners'
|
||||
query += 'tickets.owner_id = owners.id'
|
||||
when 'article'
|
||||
tables += ', ticket_articles articles'
|
||||
query += 'tickets.id = articles.ticket_id'
|
||||
when 'ticket_state'
|
||||
tables += ', ticket_states'
|
||||
query += 'tickets.state_id = ticket_states.id'
|
||||
when 'ticket'
|
||||
if attributes[1] == 'mention_user_ids'
|
||||
tables += ', mentions'
|
||||
query += "tickets.id = mentions.mentionable_id AND mentions.mentionable_type = 'Ticket'"
|
||||
end
|
||||
else
|
||||
raise "invalid selector #{attribute.inspect}->#{attributes.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
# add conditions
|
||||
no_result = false
|
||||
selectors.each do |attribute, selector_raw|
|
||||
|
||||
# validation
|
||||
raise "Invalid selector #{selector_raw.inspect}" if !selector_raw
|
||||
raise "Invalid selector #{selector_raw.inspect}" if !selector_raw.respond_to?(:key?)
|
||||
|
||||
selector = selector_raw.stringify_keys
|
||||
raise "Invalid selector, operator missing #{selector.inspect}" if !selector['operator']
|
||||
raise "Invalid selector, operator #{selector['operator']} is invalid #{selector.inspect}" if !selector['operator'].match?(%r{^(is|is\snot|contains|contains\s(not|all|one|all\snot|one\snot)|(after|before)\s\(absolute\)|(within\snext|within\slast|after|before|till|from)\s\(relative\))|(is\sin\sworking\stime|is\snot\sin\sworking\stime)$})
|
||||
|
||||
# validate value / allow blank but only if pre_condition exists and is not specific
|
||||
if !selector.key?('value') ||
|
||||
(selector['value'].instance_of?(Array) && selector['value'].respond_to?(:blank?) && selector['value'].blank?) ||
|
||||
(selector['operator'].start_with?('contains') && selector['value'].respond_to?(:blank?) && selector['value'].blank?)
|
||||
return nil if selector['pre_condition'].nil?
|
||||
return nil if selector['pre_condition'].respond_to?(:blank?) && selector['pre_condition'].blank?
|
||||
return nil if selector['pre_condition'] == 'specific'
|
||||
end
|
||||
|
||||
# validate pre_condition values
|
||||
return nil if selector['pre_condition'] && selector['pre_condition'] !~ %r{^(not_set|current_user\.|specific)}
|
||||
|
||||
# get attributes
|
||||
attributes = attribute.split('.')
|
||||
attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name(attributes[1])}"
|
||||
|
||||
# magic selectors
|
||||
if attributes[0] == 'ticket' && attributes[1] == 'out_of_office_replacement_id'
|
||||
attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name('owner_id')}"
|
||||
end
|
||||
|
||||
if attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||||
selector['value'] = selector['value'].split(',').collect(&:strip)
|
||||
end
|
||||
|
||||
if selector['operator'].include?('in working time')
|
||||
next if attributes[1] != 'calendar_id'
|
||||
raise 'Please enable execution_time feature to use it (currently only allowed for triggers and schedulers)' if !options[:execution_time]
|
||||
|
||||
biz = Calendar.lookup(id: selector['value'])&.biz
|
||||
next if biz.blank?
|
||||
|
||||
if ( selector['operator'] == 'is in working time' && !biz.in_hours?(Time.zone.now) ) || ( selector['operator'] == 'is not in working time' && biz.in_hours?(Time.zone.now) )
|
||||
no_result = true
|
||||
break
|
||||
end
|
||||
|
||||
# skip to next condition
|
||||
next
|
||||
end
|
||||
|
||||
if query != ''
|
||||
query += ' AND '
|
||||
end
|
||||
|
||||
# because of no grouping support we select not_set by sub select for mentions
|
||||
if attributes[0] == 'ticket' && attributes[1] == 'mention_user_ids'
|
||||
if selector['pre_condition'] == 'not_set'
|
||||
query += if selector['operator'] == 'is'
|
||||
"(SELECT 1 FROM mentions mentions_sub WHERE mentions_sub.mentionable_type = 'Ticket' AND mentions_sub.mentionable_id = tickets.id) IS NULL"
|
||||
else
|
||||
"1 = (SELECT 1 FROM mentions mentions_sub WHERE mentions_sub.mentionable_type = 'Ticket' AND mentions_sub.mentionable_id = tickets.id)"
|
||||
end
|
||||
else
|
||||
query += if selector['operator'] == 'is'
|
||||
'mentions.user_id IN (?)'
|
||||
else
|
||||
'mentions.user_id NOT IN (?)'
|
||||
end
|
||||
if selector['pre_condition'] == 'current_user.id'
|
||||
bind_params.push current_user_id
|
||||
else
|
||||
bind_params.push selector['value']
|
||||
end
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
if selector['operator'] == 'is'
|
||||
if selector['pre_condition'] == 'not_set'
|
||||
if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id})
|
||||
query += "(#{attribute} IS NULL OR #{attribute} IN (?))"
|
||||
bind_params.push 1
|
||||
else
|
||||
query += "#{attribute} IS NULL"
|
||||
end
|
||||
elsif selector['pre_condition'] == 'current_user.id'
|
||||
raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id
|
||||
|
||||
query += "#{attribute} IN (?)"
|
||||
if attributes[1] == 'out_of_office_replacement_id'
|
||||
bind_params.push User.find(current_user_id).out_of_office_agent_of.pluck(:id)
|
||||
else
|
||||
bind_params.push current_user_id
|
||||
end
|
||||
elsif selector['pre_condition'] == 'current_user.organization_id'
|
||||
raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id
|
||||
|
||||
query += "#{attribute} IN (?)"
|
||||
user = User.find_by(id: current_user_id)
|
||||
bind_params.push user.organization_id
|
||||
else
|
||||
# rubocop:disable Style/IfInsideElse
|
||||
if selector['value'].nil?
|
||||
query += "#{attribute} IS NULL"
|
||||
else
|
||||
if attributes[1] == 'out_of_office_replacement_id'
|
||||
query += "#{attribute} IN (?)"
|
||||
bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id)
|
||||
else
|
||||
if selector['value'].class != Array
|
||||
selector['value'] = [selector['value']]
|
||||
end
|
||||
query += if selector['value'].include?('')
|
||||
"(#{attribute} IN (?) OR #{attribute} IS NULL)"
|
||||
else
|
||||
"#{attribute} IN (?)"
|
||||
end
|
||||
bind_params.push selector['value']
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/IfInsideElse
|
||||
end
|
||||
elsif selector['operator'] == 'is not'
|
||||
if selector['pre_condition'] == 'not_set'
|
||||
if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id})
|
||||
query += "(#{attribute} IS NOT NULL AND #{attribute} NOT IN (?))"
|
||||
bind_params.push 1
|
||||
else
|
||||
query += "#{attribute} IS NOT NULL"
|
||||
end
|
||||
elsif selector['pre_condition'] == 'current_user.id'
|
||||
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||||
if attributes[1] == 'out_of_office_replacement_id'
|
||||
bind_params.push User.find(current_user_id).out_of_office_agent_of.pluck(:id)
|
||||
else
|
||||
bind_params.push current_user_id
|
||||
end
|
||||
elsif selector['pre_condition'] == 'current_user.organization_id'
|
||||
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||||
user = User.find_by(id: current_user_id)
|
||||
bind_params.push user.organization_id
|
||||
else
|
||||
# rubocop:disable Style/IfInsideElse
|
||||
if selector['value'].nil?
|
||||
query += "#{attribute} IS NOT NULL"
|
||||
else
|
||||
if attributes[1] == 'out_of_office_replacement_id'
|
||||
bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id)
|
||||
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||||
else
|
||||
if selector['value'].class != Array
|
||||
selector['value'] = [selector['value']]
|
||||
end
|
||||
query += if selector['value'].include?('')
|
||||
"(#{attribute} IS NOT NULL AND #{attribute} NOT IN (?))"
|
||||
else
|
||||
"(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||||
end
|
||||
bind_params.push selector['value']
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/IfInsideElse
|
||||
end
|
||||
elsif selector['operator'] == 'contains'
|
||||
query += "#{attribute} #{like} (?)"
|
||||
value = "%#{selector['value']}%"
|
||||
bind_params.push value
|
||||
elsif selector['operator'] == 'contains not'
|
||||
query += "#{attribute} NOT #{like} (?)"
|
||||
value = "%#{selector['value']}%"
|
||||
bind_params.push value
|
||||
elsif selector['operator'] == 'contains all' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||||
query += "? = (
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
tag_objects,
|
||||
tag_items,
|
||||
tags
|
||||
WHERE
|
||||
tickets.id = tags.o_id AND
|
||||
tag_objects.id = tags.tag_object_id AND
|
||||
tag_objects.name = 'Ticket' AND
|
||||
tag_items.id = tags.tag_item_id AND
|
||||
tag_items.name IN (?)
|
||||
)"
|
||||
bind_params.push selector['value'].count
|
||||
bind_params.push selector['value']
|
||||
elsif selector['operator'] == 'contains one' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||||
tables += ', tag_objects, tag_items, tags'
|
||||
query += "
|
||||
tickets.id = tags.o_id AND
|
||||
tag_objects.id = tags.tag_object_id AND
|
||||
tag_objects.name = 'Ticket' AND
|
||||
tag_items.id = tags.tag_item_id AND
|
||||
tag_items.name IN (?)"
|
||||
|
||||
bind_params.push selector['value']
|
||||
elsif selector['operator'] == 'contains all not' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||||
query += "0 = (
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
tag_objects,
|
||||
tag_items,
|
||||
tags
|
||||
WHERE
|
||||
tickets.id = tags.o_id AND
|
||||
tag_objects.id = tags.tag_object_id AND
|
||||
tag_objects.name = 'Ticket' AND
|
||||
tag_items.id = tags.tag_item_id AND
|
||||
tag_items.name IN (?)
|
||||
)"
|
||||
bind_params.push selector['value']
|
||||
elsif selector['operator'] == 'contains one not' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||||
query += "(
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
tag_objects,
|
||||
tag_items,
|
||||
tags
|
||||
WHERE
|
||||
tickets.id = tags.o_id AND
|
||||
tag_objects.id = tags.tag_object_id AND
|
||||
tag_objects.name = 'Ticket' AND
|
||||
tag_items.id = tags.tag_item_id AND
|
||||
tag_items.name IN (?)
|
||||
) BETWEEN 0 AND 0"
|
||||
bind_params.push selector['value']
|
||||
elsif selector['operator'] == 'before (absolute)'
|
||||
query += "#{attribute} <= ?"
|
||||
bind_params.push selector['value']
|
||||
elsif selector['operator'] == 'after (absolute)'
|
||||
query += "#{attribute} >= ?"
|
||||
bind_params.push selector['value']
|
||||
elsif selector['operator'] == 'within last (relative)'
|
||||
query += "#{attribute} BETWEEN ? AND ?"
|
||||
time = nil
|
||||
case selector['range']
|
||||
when 'minute'
|
||||
time = selector['value'].to_i.minutes.ago
|
||||
when 'hour'
|
||||
time = selector['value'].to_i.hours.ago
|
||||
when 'day'
|
||||
time = selector['value'].to_i.days.ago
|
||||
when 'month'
|
||||
time = selector['value'].to_i.months.ago
|
||||
when 'year'
|
||||
time = selector['value'].to_i.years.ago
|
||||
else
|
||||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||||
end
|
||||
bind_params.push time
|
||||
bind_params.push Time.zone.now
|
||||
elsif selector['operator'] == 'within next (relative)'
|
||||
query += "#{attribute} BETWEEN ? AND ?"
|
||||
time = nil
|
||||
case selector['range']
|
||||
when 'minute'
|
||||
time = selector['value'].to_i.minutes.from_now
|
||||
when 'hour'
|
||||
time = selector['value'].to_i.hours.from_now
|
||||
when 'day'
|
||||
time = selector['value'].to_i.days.from_now
|
||||
when 'month'
|
||||
time = selector['value'].to_i.months.from_now
|
||||
when 'year'
|
||||
time = selector['value'].to_i.years.from_now
|
||||
else
|
||||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||||
end
|
||||
bind_params.push Time.zone.now
|
||||
bind_params.push time
|
||||
elsif selector['operator'] == 'before (relative)'
|
||||
query += "#{attribute} <= ?"
|
||||
time = nil
|
||||
case selector['range']
|
||||
when 'minute'
|
||||
time = selector['value'].to_i.minutes.ago
|
||||
when 'hour'
|
||||
time = selector['value'].to_i.hours.ago
|
||||
when 'day'
|
||||
time = selector['value'].to_i.days.ago
|
||||
when 'month'
|
||||
time = selector['value'].to_i.months.ago
|
||||
when 'year'
|
||||
time = selector['value'].to_i.years.ago
|
||||
else
|
||||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||||
end
|
||||
bind_params.push time
|
||||
elsif selector['operator'] == 'after (relative)'
|
||||
query += "#{attribute} >= ?"
|
||||
time = nil
|
||||
case selector['range']
|
||||
when 'minute'
|
||||
time = selector['value'].to_i.minutes.from_now
|
||||
when 'hour'
|
||||
time = selector['value'].to_i.hours.from_now
|
||||
when 'day'
|
||||
time = selector['value'].to_i.days.from_now
|
||||
when 'month'
|
||||
time = selector['value'].to_i.months.from_now
|
||||
when 'year'
|
||||
time = selector['value'].to_i.years.from_now
|
||||
else
|
||||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||||
end
|
||||
bind_params.push time
|
||||
elsif selector['operator'] == 'till (relative)'
|
||||
query += "#{attribute} <= ?"
|
||||
time = nil
|
||||
case selector['range']
|
||||
when 'minute'
|
||||
time = selector['value'].to_i.minutes.from_now
|
||||
when 'hour'
|
||||
time = selector['value'].to_i.hours.from_now
|
||||
when 'day'
|
||||
time = selector['value'].to_i.days.from_now
|
||||
when 'month'
|
||||
time = selector['value'].to_i.months.from_now
|
||||
when 'year'
|
||||
time = selector['value'].to_i.years.from_now
|
||||
else
|
||||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||||
end
|
||||
bind_params.push time
|
||||
elsif selector['operator'] == 'from (relative)'
|
||||
query += "#{attribute} >= ?"
|
||||
time = nil
|
||||
case selector['range']
|
||||
when 'minute'
|
||||
time = selector['value'].to_i.minutes.ago
|
||||
when 'hour'
|
||||
time = selector['value'].to_i.hours.ago
|
||||
when 'day'
|
||||
time = selector['value'].to_i.days.ago
|
||||
when 'month'
|
||||
time = selector['value'].to_i.months.ago
|
||||
when 'year'
|
||||
time = selector['value'].to_i.years.ago
|
||||
else
|
||||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||||
end
|
||||
bind_params.push time
|
||||
else
|
||||
raise "Invalid operator '#{selector['operator']}' for '#{selector['value'].inspect}'"
|
||||
end
|
||||
end
|
||||
|
||||
return if no_result
|
||||
|
||||
[query, bind_params, tables]
|
||||
Ticket::Selector::Sql.new(selector: selectors, options: options).get
|
||||
end
|
||||
|
||||
=begin
|
||||
|
|
@ -1011,9 +620,10 @@ perform changes on ticket
|
|||
end
|
||||
end
|
||||
|
||||
objects = build_notification_template_objects(article)
|
||||
perform_notification = {}
|
||||
perform_article = {}
|
||||
changed = false
|
||||
perform_article = {}
|
||||
changed = false
|
||||
perform.each do |key, value|
|
||||
(object_name, attribute) = key.split('.', 2)
|
||||
raise "Unable to update object #{object_name}.#{attribute}, only can update tickets, send notifications and create articles!" if object_name != 'ticket' && object_name != 'article' && object_name != 'notification'
|
||||
|
|
@ -1034,23 +644,7 @@ perform changes on ticket
|
|||
when 'static'
|
||||
value['value']
|
||||
when 'relative'
|
||||
pendtil = Time.zone.now
|
||||
val = value['value'].to_i
|
||||
|
||||
case value['range']
|
||||
when 'day'
|
||||
pendtil += val.days
|
||||
when 'minute'
|
||||
pendtil += val.minutes
|
||||
when 'hour'
|
||||
pendtil += val.hours
|
||||
when 'month'
|
||||
pendtil += val.months
|
||||
when 'year'
|
||||
pendtil += val.years
|
||||
end
|
||||
|
||||
pendtil
|
||||
TimeRangeHelper.relative(range: value['range'], value: value['value'])
|
||||
end
|
||||
|
||||
if new_value
|
||||
|
|
@ -1095,7 +689,7 @@ perform changes on ticket
|
|||
if value['pre_condition'].start_with?('not_set')
|
||||
value['value'] = 1
|
||||
elsif value['pre_condition'].start_with?('current_user.')
|
||||
raise 'Unable to use current_user, got no current_user_id for ticket.perform_changes' if !current_user_id
|
||||
raise __("The required parameter 'current_user_id' is missing.") if !current_user_id
|
||||
|
||||
value['value'] = current_user_id
|
||||
end
|
||||
|
|
@ -1106,6 +700,14 @@ perform changes on ticket
|
|||
|
||||
changed = true
|
||||
|
||||
if value['value'].is_a?(String)
|
||||
value['value'] = NotificationFactory::Mailer.template(
|
||||
templateInline: value['value'],
|
||||
objects: objects,
|
||||
quote: true,
|
||||
)
|
||||
end
|
||||
|
||||
self[attribute] = value['value']
|
||||
logger.debug { "set #{object_name}.#{attribute} = #{value['value'].inspect} for ticket_id #{id}" }
|
||||
end
|
||||
|
|
@ -1114,10 +716,8 @@ perform changes on ticket
|
|||
save!
|
||||
end
|
||||
|
||||
objects = build_notification_template_objects(article)
|
||||
|
||||
perform_article.each do |key, value|
|
||||
raise 'Unable to create article, we only support article.note' if key != 'article.note'
|
||||
raise __("Article could not be created. An unsupported key other than 'article.note' was provided.") if key != 'article.note'
|
||||
|
||||
add_trigger_note(id, value, objects, perform_origin)
|
||||
end
|
||||
|
|
@ -1209,7 +809,7 @@ perform active triggers on ticket
|
|||
else
|
||||
::Trigger.where(active: true).order(:name)
|
||||
end
|
||||
return [true, 'No triggers active'] if triggers.blank?
|
||||
return [true, __('No triggers active')] if triggers.blank?
|
||||
|
||||
# check if notification should be send because of customer emails
|
||||
send_notification = true
|
||||
|
|
@ -1226,95 +826,6 @@ perform active triggers on ticket
|
|||
triggers.each do |trigger|
|
||||
logger.debug { "Probe trigger (#{trigger.name}/#{trigger.id}) for this object (Ticket:#{ticket.id}/Loop:#{local_options[:loop_count]})" }
|
||||
|
||||
condition = trigger.condition
|
||||
|
||||
# check if one article attribute is used
|
||||
one_has_changed_done = false
|
||||
article_selector = false
|
||||
trigger.condition.each_key do |key|
|
||||
(object_name, attribute) = key.split('.', 2)
|
||||
next if object_name != 'article'
|
||||
next if attribute == 'id'
|
||||
|
||||
article_selector = true
|
||||
end
|
||||
if article && article_selector
|
||||
one_has_changed_done = true
|
||||
end
|
||||
if article && type == 'update'
|
||||
one_has_changed_done = true
|
||||
end
|
||||
|
||||
# check ticket "has changed" options
|
||||
has_changed_done = true
|
||||
condition.each do |key, value|
|
||||
next if value.blank?
|
||||
next if value['operator'].blank?
|
||||
next if !value['operator']['has changed']
|
||||
|
||||
# remove condition item, because it has changed
|
||||
(object_name, attribute) = key.split('.', 2)
|
||||
next if object_name != 'ticket'
|
||||
next if item[:changes].blank?
|
||||
next if !item[:changes].key?(attribute)
|
||||
|
||||
condition.delete(key)
|
||||
one_has_changed_done = true
|
||||
end
|
||||
|
||||
# check if we have not matching "has changed" attributes
|
||||
condition.each_value do |value|
|
||||
next if value.blank?
|
||||
next if value['operator'].blank?
|
||||
next if !value['operator']['has changed']
|
||||
|
||||
has_changed_done = false
|
||||
break
|
||||
end
|
||||
|
||||
# check ticket action
|
||||
if condition['ticket.action']
|
||||
next if condition['ticket.action']['operator'] == 'is' && condition['ticket.action']['value'] != type
|
||||
next if condition['ticket.action']['operator'] != 'is' && condition['ticket.action']['value'] == type
|
||||
|
||||
condition.delete('ticket.action')
|
||||
end
|
||||
next if !has_changed_done
|
||||
|
||||
# check in min one attribute of condition has changed on update
|
||||
one_has_changed_condition = false
|
||||
if type == 'update'
|
||||
|
||||
# verify if ticket condition exists
|
||||
condition.each_key do |key|
|
||||
(object_name, attribute) = key.split('.', 2)
|
||||
next if object_name != 'ticket'
|
||||
|
||||
one_has_changed_condition = true
|
||||
next if item[:changes].blank?
|
||||
next if !item[:changes].key?(attribute)
|
||||
|
||||
one_has_changed_done = true
|
||||
break
|
||||
end
|
||||
next if one_has_changed_condition && !one_has_changed_done
|
||||
end
|
||||
|
||||
# check if ticket selector is matching
|
||||
condition['ticket.id'] = {
|
||||
operator: 'is',
|
||||
value: ticket.id,
|
||||
}
|
||||
next if article_selector && !article
|
||||
|
||||
# check if article selector is matching
|
||||
if article_selector
|
||||
condition['article.id'] = {
|
||||
operator: 'is',
|
||||
value: article.id,
|
||||
}
|
||||
end
|
||||
|
||||
user_id = ticket.updated_by_id
|
||||
if article
|
||||
user_id = article.updated_by_id
|
||||
|
|
@ -1323,7 +834,7 @@ perform active triggers on ticket
|
|||
user = User.lookup(id: user_id)
|
||||
|
||||
# verify is condition is matching
|
||||
ticket_count, tickets = Ticket.selectors(condition, limit: 1, execution_time: true, current_user: user, access: 'ignore')
|
||||
ticket_count, tickets = Ticket.selectors(trigger.condition, limit: 1, execution_time: true, current_user: user, access: 'ignore', ticket_action: type, ticket_id: ticket.id, article_id: article&.id, changes: item[:changes], changes_required: true)
|
||||
|
||||
next if ticket_count.blank?
|
||||
next if ticket_count.zero?
|
||||
|
|
@ -1455,7 +966,7 @@ result
|
|||
|
||||
customer = User.find_by(id: customer_id)
|
||||
return true if !customer
|
||||
return true if organization_id == customer.organization_id
|
||||
return true if organization_id.present? && customer.organization_id?(organization_id)
|
||||
|
||||
self.organization_id = customer.organization_id
|
||||
true
|
||||
|
|
@ -1523,9 +1034,30 @@ result
|
|||
# if another email notification trigger preceded this one
|
||||
# (see https://github.com/zammad/zammad/issues/1543)
|
||||
def build_notification_template_objects(article)
|
||||
last_article = nil
|
||||
last_internal_article = nil
|
||||
last_external_article = nil
|
||||
all_articles = articles
|
||||
|
||||
if article.nil?
|
||||
last_article = all_articles.last
|
||||
last_internal_article = all_articles.reverse.find(&:internal?)
|
||||
last_external_article = all_articles.reverse.find { |a| !a.internal? }
|
||||
else
|
||||
last_article = article
|
||||
last_internal_article = article.internal? ? article : all_articles.reverse.find(&:internal?)
|
||||
last_external_article = article.internal? ? all_articles.reverse.find { |a| !a.internal? } : article
|
||||
end
|
||||
|
||||
{
|
||||
ticket: self,
|
||||
article: article || articles.last
|
||||
ticket: self,
|
||||
article: last_article,
|
||||
last_article: last_article,
|
||||
last_internal_article: last_internal_article,
|
||||
last_external_article: last_external_article,
|
||||
created_article: article,
|
||||
created_internal_article: article&.internal? ? article : nil,
|
||||
created_external_article: article&.internal? ? nil : article,
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -1587,7 +1119,7 @@ result
|
|||
Mail::AddressList.new(recipient_email).addresses.each do |address|
|
||||
recipient_email = address.address
|
||||
email_address_validation = EmailAddressValidation.new(recipient_email)
|
||||
break if recipient_email.present? && email_address_validation.valid_format?
|
||||
break if recipient_email.present? && email_address_validation.valid?
|
||||
end
|
||||
rescue
|
||||
if recipient_email.present?
|
||||
|
|
@ -1600,7 +1132,7 @@ result
|
|||
end
|
||||
|
||||
email_address_validation = EmailAddressValidation.new(recipient_email)
|
||||
next if !email_address_validation.valid_format?
|
||||
next if !email_address_validation.valid?
|
||||
|
||||
# do not send notification if system address
|
||||
next if EmailAddress.exists?(email: recipient_email.downcase)
|
||||
|
|
@ -1700,7 +1232,7 @@ result
|
|||
sign = value['sign'].present? && value['sign'] != 'no'
|
||||
encryption = value['encryption'].present? && value['encryption'] != 'no'
|
||||
security = {
|
||||
type: security_type,
|
||||
type: security_type,
|
||||
sign: {
|
||||
success: false,
|
||||
},
|
||||
|
|
@ -1717,10 +1249,10 @@ result
|
|||
else
|
||||
cert = SMIMECertificate.for_sender_email_address(from)
|
||||
end
|
||||
|
||||
begin
|
||||
list = Mail::AddressList.new(email_address.email)
|
||||
from = list.addresses.first.to_s
|
||||
|
||||
if cert && !cert.expired?
|
||||
sign_found = true
|
||||
security[:sign][:success] = true
|
||||
|
|
@ -1795,7 +1327,7 @@ result
|
|||
)
|
||||
|
||||
attachments_inline.each do |attachment|
|
||||
Store.add(
|
||||
Store.create!(
|
||||
object: 'Ticket::Article',
|
||||
o_id: message.id,
|
||||
data: attachment[:data],
|
||||
|
|
@ -1805,6 +1337,11 @@ result
|
|||
end
|
||||
|
||||
original_article = objects[:article]
|
||||
|
||||
if ActiveModel::Type::Boolean.new.cast(value['include_attachments']) == true && original_article&.attachments.present?
|
||||
original_article.clone_attachments('Ticket::Article', message.id, only_attached_attachments: true)
|
||||
end
|
||||
|
||||
if original_article&.should_clone_inline_attachments? # rubocop:disable Style/GuardClause
|
||||
original_article.clone_attachments('Ticket::Article', message.id, only_inline_attachments: true)
|
||||
original_article.should_clone_inline_attachments = false # cancel the temporary flag after cloning
|
||||
|
|
@ -1903,7 +1440,15 @@ result
|
|||
return 0 if !user.preferences[:mail_delivery_failed]
|
||||
return 0 if user.preferences[:mail_delivery_failed_data].blank?
|
||||
|
||||
# blocked for 60 full days
|
||||
(user.preferences[:mail_delivery_failed_data].to_date - Time.zone.now.to_date).to_i + 61
|
||||
# blocked for 60 full days; see #4459
|
||||
remaining_days = (user.preferences[:mail_delivery_failed_data].to_date - Time.zone.now.to_date).to_i + 61
|
||||
return remaining_days if remaining_days.positive?
|
||||
|
||||
# cleanup user preferences
|
||||
user.preferences[:mail_delivery_failed] = false
|
||||
user.preferences[:mail_delivery_failed_data] = nil
|
||||
user.save!
|
||||
0
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
class PGPSupport < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
# return if it's a new setup
|
||||
# return unless Setting.exists?(name: 'system_init_done')
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: 'PGP integration',
|
||||
name: 'pgp_integration',
|
||||
area: 'Integration::Switch',
|
||||
description: 'Defines if PGP encryption is enabled or not.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'pgp_integration',
|
||||
tag: 'boolean',
|
||||
options: {
|
||||
true => 'yes',
|
||||
false => 'no'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
state: false,
|
||||
preferences: {
|
||||
prio: 1,
|
||||
authentication: true,
|
||||
permission: ['admin.integration']
|
||||
},
|
||||
frontend: true
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: 'PGP config',
|
||||
name: 'pgp_config',
|
||||
area: 'Integration::PGP',
|
||||
description: 'Defines the PGP config.',
|
||||
options: {},
|
||||
state: {},
|
||||
preferences: {
|
||||
prio: 2,
|
||||
permission: ['admin.integration']
|
||||
},
|
||||
frontend: true
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Defines postmaster filter.',
|
||||
name: '0016_postmaster_filter_smime',
|
||||
area: 'Postmaster::PreFilter',
|
||||
description: 'Defines postmaster filter to handle secure mailing.',
|
||||
options: {},
|
||||
state: 'Channel::Filter::SecureMailing',
|
||||
frontend: false
|
||||
)
|
||||
|
||||
create_table :pgp_keypairs do |t|
|
||||
t.string :fingerprint, limit: 250, null: false
|
||||
t.binary :public_key, limit: 10.megabytes, null: false
|
||||
t.binary :private_key, limit: 10.megabytes, null: true
|
||||
t.string :private_key_secret, limit: 500, null: true
|
||||
t.timestamps limit: 3, null: false
|
||||
end
|
||||
add_index :pgp_keypairs, [:fingerprint], unique: true
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue