Zammad Docker and addon updates

This commit is contained in:
Darren Clarke 2023-05-03 08:20:51 +00:00
parent dab5ce0521
commit aa18d3904e
16 changed files with 1972 additions and 2976 deletions

View file

@ -1,46 +1,50 @@
class HardeningHardenSettings < ActiveRecord::Migration[5.2]
def self.restore_setting(name)
s = Setting.find_by(name: name)
if !s.nil?
s.state_current = s.state_initial
s.save!
end
return if s.nil?
s.state_current = s.state_initial
s.save!
end
def self.set_setting(name, value)
s = Setting.find_by(name: name)
if !s.nil?
s.state_current = { "value" => value }
s.save!
end
return if s.nil?
s.state_current = { 'value' => value }
s.save!
end
def self.up
["ui_send_client_stats", "geo_ip_backend", "geo_location_backend", "image_backend", "geo_calendar_backend"].each { |n|
self.set_setting(n, "")
}
%w[ui_send_client_stats geo_ip_backend geo_location_backend image_backend
geo_calendar_backend].each do |n|
set_setting(n, '')
end
# disable customer ticket creation
self.set_setting("customer_ticket_create", false)
set_setting('customer_ticket_create', false)
# disable user account registration
self.set_setting("user_create_account", false)
set_setting('user_create_account', false)
# bump up min password length
self.set_setting("password_min_size", 10)
set_setting('password_min_size', 10)
# delete default zammad user
nicole = User.find_by(email: "nicole.braun@zammad.org")
if !nicole.nil?
Ticket.where(customer: nicole).destroy_all
nicole.destroy
end
nicole = User.find_by(email: 'nicole.braun@zammad.org')
return if nicole.nil?
Ticket.where(customer: nicole).destroy_all
nicole.destroy
end
def self.down
["ui_send_client_stats", "geo_ip_backend", "geo_location_backend", "image_backend", "geo_calendar_backend"].each { |n|
self.restore_setting(n)
}
["customer_ticket_create", "user_create_account", "password_min_size"].each { |n|
self.restore_setting(n)
}
%w[ui_send_client_stats geo_ip_backend geo_location_backend image_backend
geo_calendar_backend].each do |n|
restore_setting(n)
end
%w[customer_ticket_create user_create_account password_min_size].each do |n|
restore_setting(n)
end
end
end

View file

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

View file

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

View file

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