Fixes issue #865 - Freshdesk import

This commit is contained in:
Thorsten Eckel 2021-05-25 12:30:12 +00:00
parent a1d17ac45b
commit 8790e389be
96 changed files with 7963 additions and 9 deletions

View file

@ -38,6 +38,7 @@ Metrics/AbcSize:
- 'app/controllers/getting_started_controller.rb'
- 'app/controllers/import_otrs_controller.rb'
- 'app/controllers/import_zendesk_controller.rb'
- 'app/controllers/import_freshdesk_controller.rb'
- 'app/controllers/integration/check_mk_controller.rb'
- 'app/controllers/integration/cti_controller.rb'
- 'app/controllers/integration/idoit_controller.rb'
@ -466,6 +467,7 @@ Metrics/CyclomaticComplexity:
- 'app/controllers/getting_started_controller.rb'
- 'app/controllers/import_otrs_controller.rb'
- 'app/controllers/import_zendesk_controller.rb'
- 'app/controllers/import_freshdesk_controller.rb'
- 'app/controllers/integration/check_mk_controller.rb'
- 'app/controllers/integration/smime_controller.rb'
- 'app/controllers/knowledge_base/public/categories_controller.rb'

View file

@ -0,0 +1,194 @@
class ImportFreshdesk extends App.ControllerWizardFullScreen
className: 'getstarted fit'
elements:
'.input-feedback': 'urlStatus'
'[data-target=freshdesk-credentials]': 'nextEnterCredentials'
'[data-target=freshdesk-start-migration]': 'nextStartMigration'
'#freshdesk-subdomain': 'freshdeskSubdomain'
'#freshdesk-subdomain-addon': 'freshdeskSubdomainAddon'
'.freshdesk-subdomain-error': 'linkErrorMessage'
'.freshdesk-api-token-error': 'apiTokenErrorMessage'
'#freshdesk-email': 'freshdeskEmail'
'#freshdesk-api-token': 'freshdeskApiToken'
'.js-ticket-count-info': 'ticketCountInfo'
updateMigrationDisplayLoop: 0
events:
'click .js-freshdesk-credentials': 'showCredentials'
'click .js-migration-start': 'startMigration'
'keyup #freshdesk-subdomain': 'updateUrl'
'keyup #freshdesk-api-token': 'updateApiToken'
constructor: ->
super
# set title
@title 'Import'
@freshdeskDomain = '.freshdesk.com'
# redirect to login if master user already exists
if @Config.get('system_init_done')
@navigate '#login'
return
@fetch()
fetch: ->
# get data
@ajax(
id: 'getting_started'
type: 'GET'
url: "#{@apiPath}/getting_started"
processData: true
success: (data, status, xhr) =>
# check if import is active
if data.import_mode == true && data.import_backend != 'freshdesk'
@navigate "#import/#{data.import_backend}", { emptyEl: true }
return
# render page
@render()
if data.import_mode == true
@showImportState()
@updateMigration()
)
render: ->
@replaceWith App.view('import/freshdesk')(
freshdeskDomain: @freshdeskDomain
)
updateUrl: (e) =>
@urlStatus.attr('data-state', 'loading')
@freshdeskSubdomainAddon.attr('style', 'padding-right: 42px')
@linkErrorMessage.text('')
# get data
callback = =>
@ajax(
id: 'import_freshdesk_url'
type: 'POST'
url: "#{@apiPath}/import/freshdesk/url_check"
data: JSON.stringify(url: "https://#{@freshdeskSubdomain.val()}#{@freshdeskDomain}")
processData: true
success: (data, status, xhr) =>
# validate form
if data.result is 'ok'
@urlStatus.attr('data-state', 'success')
@linkErrorMessage.text('')
@nextEnterCredentials.removeClass('hide')
else
@urlStatus.attr('data-state', 'error')
@linkErrorMessage.text( data.message_human || data.message)
@nextEnterCredentials.addClass('hide')
)
@delay( callback, 700, 'import_freshdesk_url' )
updateApiToken: (e) =>
@urlStatus.attr('data-state', 'loading')
@apiTokenErrorMessage.text('')
# get data
callback = =>
@ajax(
id: 'import_freshdesk_api_token'
type: 'POST'
url: "#{@apiPath}/import/freshdesk/credentials_check"
data: JSON.stringify(token: @freshdeskApiToken.val())
processData: true
success: (data, status, xhr) =>
# validate form
if data.result is 'ok'
@urlStatus.attr('data-state', 'success')
@apiTokenErrorMessage.text('')
@nextStartMigration.removeClass('hide')
else
@urlStatus.attr('data-state', 'error')
@apiTokenErrorMessage.text(data.message_human || data.message)
@nextStartMigration.addClass('hide')
)
@delay(callback, 700, 'import_freshdesk_api_token')
showCredentials: (e) =>
e.preventDefault()
@urlStatus.attr('data-state', '')
@$('[data-slide=freshdesk-subdomain]').toggleClass('hide')
@$('[data-slide=freshdesk-credentials]').toggleClass('hide')
showImportState: =>
@$('[data-slide=freshdesk-subdomain]').addClass('hide')
@$('[data-slide=freshdesk-credentials]').addClass('hide')
@$('[data-slide=freshdesk-import]').removeClass('hide')
startMigration: (e) =>
e.preventDefault()
@showImportState()
@ajax(
id: 'import_start'
type: 'POST'
url: "#{@apiPath}/import/freshdesk/import_start"
processData: true
success: (data, status, xhr) =>
# validate form
if data.result is 'ok'
@delay(@updateMigration, 3000)
)
updateMigration: =>
@updateMigrationDisplayLoop += 1
@showImportState()
@ajax(
id: 'import_status'
type: 'GET'
url: "#{@apiPath}/import/freshdesk/import_status"
processData: true
success: (data, status, xhr) =>
if _.isEmpty(data.result) && @updateMigrationDisplayLoop > 16
@$('.js-error').removeClass('hide')
@$('.js-error').html(App.i18n.translateContent('Background process did not start or has not finished! Please contact your support.'))
return
if !_.isEmpty(data.result['error'])
@$('.js-error').removeClass('hide')
@$('.js-error').html(App.i18n.translateContent(data.result['error']))
else
@$('.js-error').addClass('hide')
if !_.isEmpty(data.finished_at) && _.isEmpty(data.result['error'])
window.location.reload()
return
if !_.isEmpty(data.result)
for model, stats of data.result
if stats.sum > stats.total
stats.sum = stats.total
element = @$('.js-' + model.toLowerCase() )
element.find('.js-done').text(stats.sum)
element.find('.js-total').text(stats.total)
element.find('progress').attr('max', stats.total )
element.find('progress').attr('value', stats.sum )
if stats.total <= stats.sum
element.addClass('is-done')
else
element.removeClass('is-done')
@delay(@updateMigration, 5000)
)
App.Config.set('import/freshdesk', ImportFreshdesk, 'Routes')
App.Config.set('freshdesk', {
title: 'Freshdesk'
name: 'Freshdesk'
class: 'js-freshdesk'
url: '#import/freshdesk'
}, 'ImportPlugins')

View file

@ -73,14 +73,13 @@ class ImportZendesk extends App.ControllerWizardFullScreen
success: (data, status, xhr) =>
# validate form
console.log(data)
if data.result is 'ok'
@urlStatus.attr('data-state', 'success')
@linkErrorMessage.text('')
@nextEnterCredentials.removeClass('hide')
else
@urlStatus.attr('data-state', 'error')
@linkErrorMessage.text( data.message_human || data.message)
@linkErrorMessage.text( data.message_human || data.message)
@nextEnterCredentials.addClass('hide')
)
@ -101,14 +100,13 @@ class ImportZendesk extends App.ControllerWizardFullScreen
success: (data, status, xhr) =>
# validate form
console.log(data)
if data.result is 'ok'
@urlStatus.attr('data-state', 'success')
@apiTokenErrorMessage.text('')
@nextStartMigration.removeClass('hide')
else
@urlStatus.attr('data-state', 'error')
@apiTokenErrorMessage.text(data.message_human || data.message)
@apiTokenErrorMessage.text(data.message_human || data.message)
@nextStartMigration.addClass('hide')
)
@ -139,7 +137,6 @@ class ImportZendesk extends App.ControllerWizardFullScreen
success: (data, status, xhr) =>
# validate form
console.log(data)
if data.result is 'ok'
@delay(@updateMigration, 3000)
)

View file

@ -0,0 +1,109 @@
<div class="main flex vertical centered darkBackground">
<%- @Icon('full-logo', 'wizard-logo') %>
<div class="import wizard">
<div class="wizard-slide vertical" data-slide="freshdesk-subdomain">
<h2><%- @T('%s URL', 'Freshdesk') %></h2>
<div class="wizard-body flex vertical justified">
<p>
<%- @T('Enter the Subdomain of your %s system', 'Freshdesk') %>:
</p>
<div class="form-group">
<label for="freshdesk-subdomain"><%- @T('%s Subdomain', 'Freshdesk') %></label>
<div class="u-positionOrigin">
<div class="input-group">
<input type="text" id="freshdesk-subdomain" class="form-control" placeholder="example" name="freshdesk-subdomain" aria-describedby="freshdesk-subdomain-addon">
<span class="input-group-addon" id="freshdesk-subdomain-addon"><%- @freshdeskDomain %></span>
</div>
<div class="input-feedback input-feedback--no-background centered">
<div class="small loading icon"></div>
<%- @Icon('diagonal-cross', 'icon-error') %>
<%- @Icon('checkmark') %>
</div>
</div>
<div class="error freshdesk-subdomain-error"></div>
</div>
</div>
<div class="wizard-controls horizontal center">
<a class="btn btn--text btn--secondary" href="#import"><%- @T('Go Back') %></a>
<div class="btn btn--primary align-right hide js-freshdesk-credentials" data-target="freshdesk-credentials"><%- @T('Enter credentials') %></div>
</div>
</div>
<div class="wizard-slide vertical hide" data-slide="freshdesk-credentials">
<h2><%- @T('%s credentials', 'Freshdesk') %></h2>
<div class="wizard-body flex vertical justified">
<p>
<a class="js-freshdeskUrlApiToken" href="https://support.freshdesk.com/support/solutions/articles/215517-how-to-find-your-api-key" target="_blank"><%- @T('Enter your %s API token gained from your account profile settings.', 'Freshdesk') %></a>
</p>
<p>
<%- @T('Attention: These will be your login password after the import is completed.') %>
</p>
<div class="form-group">
<label for="freshdesk-api-token"><%- @T('API token') %></label>
<div class="u-positionOrigin">
<input type="text" id="freshdesk-api-token" class="form-control" placeholder="XYZ3133723421111" name="freshdesk-api-token">
<div class="input-feedback centered">
<div class="small loading icon"></div>
<%- @Icon('diagonal-cross', 'icon-error') %>
<%- @Icon('checkmark') %>
</div>
</div>
<div class="error freshdesk-api-token-error"></div>
</div>
</div>
<div class="wizard-controls horizontal center">
<a class="btn btn--text btn--secondary" href="#import"><%- @T('Go Back') %></a>
<div class="btn btn--primary align-right hide js-migration-start" data-target="freshdesk-start-migration"><%- @T('Migrate %s Data', 'Freshdesk') %></div>
</div>
</div>
<div class="wizard-slide vertical hide" data-slide="freshdesk-import">
<h2><%- @T('%s Migration', 'Freshdesk') %></h2>
<div class="alert alert--danger hide js-error" role="alert"></div>
<div class="wizard-body flex vertical justified">
<table class="progressTable">
<tr class="js-groups">
<td><span class="js-done">-</span>/<span class="js-total">-</span>
<td><span><%- @T('Groups') %></span>
<td class="progressTable-progressCell">
<div class="horizontal center">
<div class="flex"><progress max="42" value="42"></progress></div>
<%- @Icon('checkmark') %>
</div>
</tr>
<tr class="js-organizations">
<td><span class="js-done">-</span>/<span class="js-total">-</span>
<td><span><%- @T('Organizations') %></span>
<td class="progressTable-progressCell">
<div class="horizontal center">
<div class="flex"><progress max="42" value="42"></progress></div>
<%- @Icon('checkmark') %>
</div>
</tr>
<tr class="js-users">
<td><span class="js-done">-</span>/<span class="js-total">-</span>
<td><span><%- @T('Users') %></span>
<td class="progressTable-progressCell">
<div class="horizontal center">
<div class="flex"><progress max="42" value="42"></progress></div>
<%- @Icon('checkmark') %>
</div>
</tr>
<tr class="js-tickets">
<td><span class="js-done">-</span>/<span class="js-total">-</span>
<td><span><%- @T('Tickets') %></span>
<td class="progressTable-progressCell">
<div class="horizontal center">
<div class="flex"><progress max="42" value="42"></progress></div>
<%- @Icon('checkmark') %>
</div>
</tr>
</table>
</div>
<div class="wizard-controls horizontal center">
<a href="#" class="btn btn--primary align-right hide js-finished"><%- @T('done') %></a>
</div>
</div>
</div>
</div>

View file

@ -42,7 +42,7 @@
</div>
<label for="zendesk-api-token"><%- @T('API token') %></label>
<div class="u-positionOrigin">
<input type="email" id="zendesk-api-token" class="form-control" placeholder="XYZ3133723421111" name="zendesk-api-token">
<input type="text" id="zendesk-api-token" class="form-control" placeholder="XYZ3133723421111" name="zendesk-api-token">
<div class="input-feedback centered">
<div class="small loading icon"></div>
<%- @Icon('diagonal-cross', 'icon-error') %>
@ -108,4 +108,4 @@
</div>
</div>
</div>
</div>

View file

@ -9196,6 +9196,14 @@ label + .wizard-buttonList {
width: 52px;
border-radius: 0 5px 5px 0;
background: linear-gradient(to right, rgba(255,255,255,0), white 33%);
&--no-background {
background: none;
}
}
.input-feedback--no-background {
background: none;
}
.input-feedback .icon {

View file

@ -0,0 +1,143 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class ImportFreshdeskController < ApplicationController
def url_check
return if setup_done_response
# validate
if params[:url].blank? || params[:url] !~ %r{^(http|https)://.+?$}
render json: {
result: 'invalid',
message: 'Invalid URL!',
}
return
end
response = UserAgent.request(params[:url])
if !response.success?
render json: {
result: 'invalid',
message_human: url_check_human_error_message(response.error.to_s),
message: response.error.to_s,
}
return
end
# Check if maybe a redirect is implemented.
if !response.body.match?(%r{#{params[:url]}})
render json: {
result: 'invalid',
message_human: 'Hostname not found!',
}
return
end
endpoint = "#{params[:url]}/api/v2"
endpoint.gsub!(%r{([^:])//+}, '\\1/')
Setting.set('import_freshdesk_endpoint', endpoint)
render json: {
result: 'ok',
url: params[:url],
}
end
def credentials_check
return if setup_done_response
if !params[:token]
render json: {
result: 'invalid',
message_human: 'Incomplete credentials',
}
return
end
Setting.set('import_freshdesk_endpoint_key', params[:token])
result = Sequencer.process('Import::Freshdesk::ConnectionTest')
if !result[:connected]
Setting.set('import_freshdesk_endpoint_key', nil)
render json: {
result: 'invalid',
message_human: 'Invalid credentials!',
}
return
end
render json: {
result: 'ok',
}
end
def import_start
return if setup_done_response
Setting.set('import_mode', true)
Setting.set('import_backend', 'freshdesk')
job = ImportJob.create(name: 'Import::Freshdesk')
AsyncImportJob.perform_later(job)
render json: {
result: 'ok',
}
end
def import_status
job = ImportJob.find_by(name: 'Import::Freshdesk')
if job.finished_at.present?
Setting.reload
end
model_show_render_item(job)
end
private
def setup_done
count = User.all.count()
done = true
if count <= 2
done = false
end
done
end
def setup_done_response
if !setup_done
return false
end
render json: {
setup_done: true,
}
true
end
def url_check_human_error_message(error)
translation_map = {
'No such file' => 'Hostname not found!',
'getaddrinfo: nodename nor servname provided, or not known' => 'Hostname not found!',
'No route to host' => 'No route to host!',
'Connection refused' => 'Connection refused!',
}
message_human = ''
translation_map.each do |key, message|
if error.match?(%r{#{Regexp.escape(key)}}i)
message_human = message
end
end
message_human
end
end

View file

@ -50,6 +50,7 @@ class ImportZendeskController < ApplicationController
endpoint = "#{params[:url]}/api/v2"
endpoint.gsub!(%r{([^:])//+}, '\\1/')
Setting.set('import_zendesk_endpoint', endpoint)
render json: {

View file

@ -0,0 +1,10 @@
Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path
# import freshdesk
match api_path + '/import/freshdesk/url_check', to: 'import_freshdesk#url_check', via: :post
match api_path + '/import/freshdesk/credentials_check', to: 'import_freshdesk#credentials_check', via: :post
match api_path + '/import/freshdesk/import_start', to: 'import_freshdesk#import_start', via: :post
match api_path + '/import/freshdesk/import_status', to: 'import_freshdesk#import_status', via: :get
end

View file

@ -3142,6 +3142,44 @@ Setting.create_if_not_exists(
state: '',
frontend: false
)
Setting.create_if_not_exists(
title: 'Import Endpoint',
name: 'import_freshdesk_endpoint',
area: 'Import::Freshdesk',
description: 'Defines Freshdesk endpoint to import users, ticket, states and articles.',
options: {
form: [
{
display: '',
null: false,
name: 'import_freshdesk_endpoint',
tag: 'input',
},
],
},
state: 'https://yours.freshdesk.com/api/v2',
frontend: false
)
Setting.create_if_not_exists(
title: 'Import Key for requesting the Freshdesk API',
name: 'import_freshdesk_endpoint_key',
area: 'Import::Freshdesk',
description: 'Defines Freshdesk endpoint authentication key.',
options: {
form: [
{
display: '',
null: false,
name: 'import_freshdesk_endpoint_key',
tag: 'input',
},
],
},
state: '',
frontend: false
)
Setting.create_if_not_exists(
title: 'Import Backends',
name: 'import_backends',

View file

@ -57,7 +57,7 @@ module Import
#
# return [nil]
def start
raise "Missing implementation if the 'start' method."
raise "Missing implementation of the 'start' method."
end
end
end

15
lib/import/freshdesk.rb Normal file
View file

@ -0,0 +1,15 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module Import
class Freshdesk < Import::Base
include Import::Mixin::Sequence
def start
process
end
def sequence_name
'Import::Freshdesk::Full'
end
end
end

View file

@ -0,0 +1,26 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Agent < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::User',
'Import::Freshdesk::Agent::Mapping',
'Import::Common::Model::Attributes::AddByIds',
'Import::Common::Model::FindBy::Name',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
'Import::Freshdesk::MapId',
'Import::Common::Model::Statistics::Diff::ModelKey',
'Import::Common::ImportJob::Statistics::Update',
'Import::Common::ImportJob::Statistics::Store',
]
end
end
end
end
end
end

View file

@ -0,0 +1,27 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Company < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Organization',
'Import::Freshdesk::Company::Mapping',
'Import::Freshdesk::Mapping::CustomFields',
'Import::Common::Model::Attributes::AddByIds',
'Import::Common::Model::FindBy::Name',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
'Import::Freshdesk::MapId',
'Import::Common::Model::Statistics::Diff::ModelKey',
'Import::Common::ImportJob::Statistics::Update',
'Import::Common::ImportJob::Statistics::Store',
]
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class CompanyField < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Organization',
'Import::Freshdesk::ObjectAttribute::Skip',
'Import::Freshdesk::ObjectAttribute::SanitizedName',
'Import::Freshdesk::ObjectAttribute::Config',
'Import::Freshdesk::ObjectAttribute::Add',
'Import::Freshdesk::ObjectAttribute::MigrationExecute',
'Import::Freshdesk::ObjectAttribute::FieldMap',
]
end
end
end
end
end
end

View file

@ -0,0 +1,20 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class ConnectionTest < Sequencer::Sequence::Base
def self.expecting
[:connected]
end
def self.sequence
[
'Freshdesk::Connected',
]
end
end
end
end
end
end

View file

@ -0,0 +1,27 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Contact < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::User',
'Import::Freshdesk::Contact::Mapping',
'Import::Freshdesk::Mapping::CustomFields',
'Import::Common::Model::Attributes::AddByIds',
'Import::Common::Model::FindBy::Name',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
'Import::Freshdesk::MapId',
'Import::Common::Model::Statistics::Diff::ModelKey',
'Import::Common::ImportJob::Statistics::Update',
'Import::Common::ImportJob::Statistics::Store',
]
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class ContactField < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::User',
'Import::Freshdesk::ObjectAttribute::Skip',
'Import::Freshdesk::ObjectAttribute::SanitizedName',
'Import::Freshdesk::ObjectAttribute::Config',
'Import::Freshdesk::ObjectAttribute::Add',
'Import::Freshdesk::ObjectAttribute::MigrationExecute',
'Import::Freshdesk::ObjectAttribute::FieldMap',
]
end
end
end
end
end
end

View file

@ -0,0 +1,23 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Conversation < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Ticket::Article',
'Import::Freshdesk::Conversation::Mapping',
'Import::Freshdesk::Conversation::InlineImages',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
'Import::Freshdesk::MapId',
'Import::Freshdesk::Conversation::Attachments',
]
end
end
end
end
end
end

View file

@ -0,0 +1,19 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Conversations < Sequencer::Sequence::Base
def self.sequence
[
'Sequencer::Unit::Import::Freshdesk::Request',
'Import::Freshdesk::Resources',
'Import::Freshdesk::ModelClass',
'Import::Freshdesk::Perform',
]
end
end
end
end
end
end

View file

@ -0,0 +1,25 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Description < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Ticket::Article',
'Import::Freshdesk::Description::Mapping',
# Handling of inline images and attachments is the same for first article (description)
# and subsequent articles (conversation).
'Import::Freshdesk::Conversation::InlineImages',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
'Import::Freshdesk::MapId',
'Import::Freshdesk::Conversation::Attachments',
]
end
end
end
end
end
end

View file

@ -0,0 +1,33 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Full < Sequencer::Sequence::Base
def self.sequence
[
'Import::Common::ImportMode::Check',
'Import::Common::SystemInitDone::Check',
'Import::Common::ImportJob::DryRun',
'Import::Freshdesk::IdMap',
'Import::Freshdesk::Groups',
'Import::Freshdesk::FieldMap',
'Import::Freshdesk::CompanyFields',
'Import::Freshdesk::Companies',
'Import::Freshdesk::Agents',
'Import::Freshdesk::Agents::GroupsPermissions',
'Import::Freshdesk::ContactFields',
'Import::Freshdesk::Contacts::Default',
'Import::Freshdesk::Contacts::Blocked',
'Import::Freshdesk::Contacts::Deleted',
'Import::Freshdesk::TicketFields',
'Import::Freshdesk::Tickets',
'Import::Common::SystemInitDone::Set',
'Import::Common::ImportMode::Unset',
]
end
end
end
end
end
end

View file

@ -0,0 +1,18 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class GenericField < Sequencer::Sequence::Base
def self.sequence
[
'Import::Freshdesk::Request',
'Import::Freshdesk::Resources',
'Import::Freshdesk::Perform',
]
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class GenericObject < Sequencer::Sequence::Base
def self.sequence
[
'Import::Freshdesk::Request',
'Import::Freshdesk::Resources',
'Import::Freshdesk::ModelClass',
'Import::Freshdesk::ObjectCount',
'Import::Common::ImportJob::Statistics::Update',
'Import::Common::ImportJob::Statistics::Store',
'Import::Freshdesk::Perform',
]
end
end
end
end
end
end

View file

@ -0,0 +1,26 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Group < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Group',
'Import::Freshdesk::Group::Mapping',
'Import::Common::Model::Attributes::AddByIds',
'Import::Common::Model::FindBy::Name',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
'Import::Freshdesk::MapId',
'Import::Common::Model::Statistics::Diff::ModelKey',
'Import::Common::ImportJob::Statistics::Update',
'Import::Common::ImportJob::Statistics::Store',
]
end
end
end
end
end
end

View file

@ -0,0 +1,33 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class Ticket < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Ticket',
# Fetch additional data such as attachments which is not included
# in the ticket list endpoint.
'Import::Freshdesk::Ticket::Fetch',
'Import::Freshdesk::Ticket::Mapping',
'Import::Freshdesk::Mapping::CustomFields',
'Import::Common::Model::Attributes::AddByIds',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
'Import::Freshdesk::MapId',
'Import::Freshdesk::Ticket::Tags',
'Import::Freshdesk::Ticket::TimeEntries',
'Import::Freshdesk::Ticket::Description',
'Import::Freshdesk::Ticket::Conversations',
'Import::Common::Model::Statistics::Diff::ModelKey',
'Import::Common::ImportJob::Statistics::Update',
'Import::Common::ImportJob::Statistics::Store',
]
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class TicketField < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Ticket',
'Import::Freshdesk::ObjectAttribute::Skip',
'Import::Freshdesk::ObjectAttribute::SanitizedName',
'Import::Freshdesk::ObjectAttribute::Config',
'Import::Freshdesk::ObjectAttribute::Add',
'Import::Freshdesk::ObjectAttribute::MigrationExecute',
'Import::Freshdesk::ObjectAttribute::FieldMap',
]
end
end
end
end
end
end

View file

@ -0,0 +1,19 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class TimeEntries < Sequencer::Sequence::Base
def self.sequence
[
'Sequencer::Unit::Import::Freshdesk::Request',
'Import::Freshdesk::Resources',
'Import::Freshdesk::ModelClass',
'Import::Freshdesk::Perform',
]
end
end
end
end
end
end

View file

@ -0,0 +1,20 @@
class Sequencer
class Sequence
module Import
module Freshdesk
class TimeEntry < Sequencer::Sequence::Base
def self.sequence
[
'Common::ModelClass::Ticket::TimeAccounting',
'Import::Freshdesk::TimeEntry::Mapping',
'Import::Common::Model::Update',
'Import::Common::Model::Create',
'Import::Common::Model::Save',
]
end
end
end
end
end
end

View file

@ -0,0 +1,30 @@
class Sequencer
class Unit
module Common
module Model
class Tags < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
skip_action :skipped, :failed
uses :dry_run, :instance
def process
return if dry_run
return if tags.blank?
Array(tags).each do |tag|
instance.tag_add(tag, 1)
end
end
private
def tags
raise NotImplementedError
end
end
end
end
end
end

View file

@ -0,0 +1,12 @@
class Sequencer
class Unit
module Common
module ModelClass
class Ticket < Sequencer::Unit::Common::ModelClass::Base
class TimeAccounting < Sequencer::Unit::Common::ModelClass::Base
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Unit
module Freshdesk
class Connected < Sequencer::Unit::Common::Provider::Named
extend ::Sequencer::Unit::Import::Freshdesk::Requester
private
def connected
response = self.class.perform_request(
api_path: 'agents/me',
)
response.is_a?(Net::HTTPOK)
rescue => e
logger.error e
nil
end
end
end
end
end

View file

@ -0,0 +1,76 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Agent
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
extend ::Sequencer::Unit::Import::Freshdesk::Requester
uses :resource, :id_map
def process
contact = resource['contact']
provide_mapped do
{
login: contact['email'],
firstname: contact['name'],
email: contact['email'],
phone: contact['phone'],
active: contact['active'],
group_ids: group_ids,
password: password,
image_source: contact['last_login_at'],
role_ids: ::Role.where(name: role_names).pluck(:id),
}
end
end
def self.admin_id
@admin_id ||= begin
token_user = self.token_user
token_user.try(:[], 'id')
end
end
def self.token_user
response = request(
api_path: 'agents/me',
)
JSON.parse(response.body)
rescue => e
logger.error e
nil
end
private
def group_ids
Array(resource['group_ids']).map do |group_id|
id_map['Group'][group_id]
end
end
def role_names
return %w[Agent Admin] if token_user?
'Agent'
end
def password
return Setting.get('import_freshdesk_endpoint_key') if token_user?
nil
end
def token_user?
self.class.admin_id == resource['id']
end
end
end
end
end
end
end

View file

@ -0,0 +1,10 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Agents < Sequencer::Unit::Import::Freshdesk::SubSequence::Object
end
end
end
end
end

View file

@ -0,0 +1,29 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Agents < Sequencer::Unit::Import::Freshdesk::SubSequence::Object
class GroupsPermissions < Sequencer::Unit::Base
def process
::Role.find_by(name: 'Agent').users.each do |user|
user.group_ids_access_map = group_ids_access_map
user.save!
end
end
private
def group_ids_access_map
@group_ids_access_map ||= begin
::Group.all.pluck(:id).each_with_object({}) do |group_id, result|
result[group_id] = 'full'.freeze
end
end
end
end
end
end
end
end
end

View file

@ -0,0 +1,10 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Companies < Sequencer::Unit::Import::Freshdesk::SubSequence::Object
end
end
end
end
end

View file

@ -0,0 +1,24 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Company
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource
def process
provide_mapped do
{
name: resource['name'],
note: resource['description'],
}
end
end
end
end
end
end
end
end

View file

@ -0,0 +1,10 @@
class Sequencer
class Unit
module Import
module Freshdesk
class CompanyFields < Sequencer::Unit::Import::Freshdesk::SubSequence::Field
end
end
end
end
end

View file

@ -0,0 +1,37 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Contact
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource, :id_map
def process
provide_mapped do
{
firstname: resource['name'],
active: resource['active'],
organization_id: organization_id,
email: resource['email'],
mobile: resource['mobile'],
phone: resource['phone'],
image_source: resource['avatar'],
group_ids: [],
role_ids: ::Role.where(name: 'Customer').pluck(:id),
}
end
end
private
def organization_id
id_map['Organization'][resource['company_id']]
end
end
end
end
end
end
end

View file

@ -0,0 +1,10 @@
class Sequencer
class Unit
module Import
module Freshdesk
class ContactFields < Sequencer::Unit::Import::Freshdesk::SubSequence::Field
end
end
end
end
end

View file

@ -0,0 +1,19 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Contacts
class Blocked < Sequencer::Unit::Import::Freshdesk::Contacts::Default
def request_params
super.merge(
state: 'blocked',
)
end
end
end
end
end
end
end

View file

@ -0,0 +1,16 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Contacts
class Default < Sequencer::Unit::Import::Freshdesk::SubSequence::Object
def object
'Contact'
end
end
end
end
end
end
end

View file

@ -0,0 +1,19 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Contacts
class Deleted < Sequencer::Unit::Import::Freshdesk::Contacts::Default
def request_params
super.merge(
state: 'deleted',
)
end
end
end
end
end
end
end

View file

@ -0,0 +1,78 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Conversation
class Attachments < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
optional :action
skip_action :skipped, :failed
uses :resource, :instance, :model_class, :dry_run
def self.mutex
@mutex ||= Mutex.new
end
def process
return if resource['attachments'].blank?
download_threads.each(&:join)
end
private
def download_threads
resource['attachments'].map do |attachment|
Thread.new do
sync(attachment)
end
end
end
def sync(attachment)
logger.debug { "Downloading attachment #{attachment}" }
response = ::UserAgent.get(
attachment['attachment_url'],
{},
{
open_timeout: 20,
read_timeout: 240,
},
)
if !response.success?
logger.error response.error
return
end
return if dry_run
store_attachment(attachment, response)
end
def store_attachment(attachment, response)
self.class.mutex.synchronize do
::Store.add(
object: model_class.name,
o_id: instance.id,
data: response.body,
filename: attachment['name'],
preferences: {
'Content-Type' => attachment['content_type']
},
created_by_id: 1
)
end
end
end
end
end
end
end
end

View file

@ -0,0 +1,74 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Conversation
class InlineImages < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :mapped
def process
return if !contains_inline_image?(mapped[:body])
provide_mapped do
{
body: replaced_inline_images,
}
end
end
def self.inline_data(freshdesk_url)
@cache ||= {}
return @cache[freshdesk_url] if @cache[freshdesk_url]
image_data = download(freshdesk_url)
return if image_data.blank?
@cache[freshdesk_url] = "data:image/png;base64,#{Base64.strict_encode64(image_data)}"
@cache[freshdesk_url]
end
def self.download(freshdesk_url)
logger.debug { "Downloading inline image from #{freshdesk_url}" }
response = UserAgent.get(
freshdesk_url,
{},
{
open_timeout: 20,
read_timeout: 240,
},
)
return response.body if response.success?
logger.error response.error
nil
end
private
def contains_inline_image?(string)
return false if string.blank?
string.include?('freshdesk.com/inline/attachment')
end
def replaced_inline_images
body_html = Nokogiri::HTML(mapped[:body])
body_html.css('img').each do |node|
next if !contains_inline_image?(node['src'])
node.attributes['src'].value = self.class.inline_data(node['src'])
end
body_html.to_html
end
end
end
end
end
end
end

View file

@ -0,0 +1,66 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Conversation
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource, :id_map
SOURCE_MAP = {
0 => ::Ticket::Article::Type.select(:id).find_by(name: 'email').id, # Reply
1 => ::Ticket::Article::Type.select(:id).find_by(name: 'email').id, # Email
2 => ::Ticket::Article::Type.select(:id).find_by(name: 'web').id, # Note
3 => ::Ticket::Article::Type.select(:id).find_by(name: 'phone').id, # Phone
4 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # UNKNOWN!
5 => ::Ticket::Article::Type.select(:id).find_by(name: 'twitter status').id, # Created from tweets
6 => ::Ticket::Article::Type.select(:id).find_by(name: 'web').id, # Created from survey feedback
7 => ::Ticket::Article::Type.select(:id).find_by(name: 'facebook feed post').id, # Created from Facebook post
8 => ::Ticket::Article::Type.select(:id).find_by(name: 'email').id, # Created from Forwarded Email
9 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # Created from Phone
10 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # Created from Mobihelp
11 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # E-Commerce
}.freeze
INCOMING_MAP = {
true => ::Ticket::Article::Sender.select(:id).find_by(name: 'Customer').id,
false => ::Ticket::Article::Sender.select(:id).find_by(name: 'Agent').id,
}.freeze
def process # rubocop:disable Metrics/AbcSize
provide_mapped do
{
from: resource['from_email'],
to: resource['to_emails']&.join(', '),
cc: resource['cc_emails']&.join(', '),
ticket_id: ticket_id,
body: resource['body'],
content_type: 'text/html',
internal: resource['private'].present?,
message_id: resource['id'],
updated_by_id: user_id,
created_by_id: user_id,
sender_id: INCOMING_MAP[ resource['incoming'] ],
type_id: SOURCE_MAP[ resource['source'] ],
created_at: resource['created_at'],
updated_at: resource['updated_at'],
}
end
end
private
def ticket_id
id_map['Ticket'][resource['ticket_id']]
end
def user_id
id_map['User'][resource['user_id']]
end
end
end
end
end
end
end

View file

@ -0,0 +1,67 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Description
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource, :id_map
SOURCE_MAP = {
0 => ::Ticket::Article::Type.select(:id).find_by(name: 'email').id, # Reply
1 => ::Ticket::Article::Type.select(:id).find_by(name: 'email').id, # Email
2 => ::Ticket::Article::Type.select(:id).find_by(name: 'web').id, # Note
3 => ::Ticket::Article::Type.select(:id).find_by(name: 'phone').id, # Phone
4 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # UNKNOWN!
5 => ::Ticket::Article::Type.select(:id).find_by(name: 'twitter status').id, # Created from tweets
6 => ::Ticket::Article::Type.select(:id).find_by(name: 'web').id, # Created from survey feedback
7 => ::Ticket::Article::Type.select(:id).find_by(name: 'facebook feed post').id, # Created from Facebook post
8 => ::Ticket::Article::Type.select(:id).find_by(name: 'email').id, # Created from Forwarded Email
9 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # Created from Phone
10 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # Created from Mobihelp
11 => ::Ticket::Article::Type.select(:id).find_by(name: 'note').id, # E-Commerce
}.freeze
def process # rubocop:disable Metrics/AbcSize
provide_mapped do
{
from: from,
to: resource['to_emails']&.join(', '),
cc: resource['cc_emails']&.join(', '),
ticket_id: ticket_id,
body: resource['description'],
content_type: 'text/html',
internal: false,
message_id: "ticketid#{resource['id']}@freshdesk.com",
sender_id: ::Ticket::Article::Sender.select(:id).find_by(name: 'Customer').id,
type_id: SOURCE_MAP[ resource['source'] ],
updated_by_id: requester_id,
created_by_id: requester_id,
created_at: resource['created_at'],
updated_at: resource['updated_at'],
}
end
end
private
def from
return nil if resource['to_emails'].blank?
::User.find(requester_id).email
end
def requester_id
id_map['User'][resource['requester_id']]
end
def ticket_id
id_map['Ticket'][resource['id']]
end
end
end
end
end
end
end

View file

@ -0,0 +1,14 @@
class Sequencer
class Unit
module Import
module Freshdesk
class FieldMap < Sequencer::Unit::Common::Provider::Named
def field_map
{}
end
end
end
end
end
end

View file

@ -0,0 +1,24 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Group
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource
def process
provide_mapped do
{
name: resource['name'],
note: resource['description'],
}
end
end
end
end
end
end
end
end

View file

@ -0,0 +1,10 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Groups < Sequencer::Unit::Import::Freshdesk::SubSequence::Object
end
end
end
end
end

View file

@ -0,0 +1,14 @@
class Sequencer
class Unit
module Import
module Freshdesk
class IdMap < Sequencer::Unit::Common::Provider::Named
def id_map
{}
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Unit
module Import
module Freshdesk
class MapId < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
optional :action
skip_action :skipped, :failed
uses :id_map, :model_class, :resource, :instance
def process
id_map[model_class.name] ||= {}
id_map[model_class.name][resource['id']] = instance.id
end
end
end
end
end
end

View file

@ -0,0 +1,34 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Mapping
class CustomFields < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource, :field_map, :model_class
def process
provide_mapped do
custom_fields
end
end
private
def custom_fields
resource['custom_fields'].each_with_object({}) do |(freshdesk_name, value), result|
local_name = custom_fields_map[freshdesk_name]
result[ local_name.to_sym ] = value
end
end
def custom_fields_map
@custom_fields_map ||= field_map[model_class.name]
end
end
end
end
end
end
end

View file

@ -0,0 +1,27 @@
class Sequencer
class Unit
module Import
module Freshdesk
class ModelClass < Sequencer::Unit::Common::Provider::Named
uses :object
MAP = {
'Company' => ::Organization,
'Agent' => ::User,
'Contact' => ::User,
'Group' => ::Group,
'Ticket' => ::Ticket,
'Conversation' => ::Ticket::Article,
}.freeze
private
def model_class
MAP[object]
end
end
end
end
end
end

View file

@ -0,0 +1,21 @@
class Sequencer
class Unit
module Import
module Freshdesk
module ObjectAttribute
class Add < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
skip_any_action
uses :config
def process
ObjectManager::Attribute.add(config)
end
end
end
end
end
end
end

View file

@ -0,0 +1,127 @@
class Sequencer
class Unit
module Import
module Freshdesk
module ObjectAttribute
class Config < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
skip_any_action
uses :resource, :sanitized_name, :model_class
provides :config
def process
state.provide(:config) do
{
object: model_class.to_s,
name: sanitized_name,
display: resource['label'],
data_type: data_type,
data_option: data_option,
editable: true,
active: true,
screens: screens,
position: resource['position'],
created_by_id: 1,
updated_by_id: 1,
}
end
end
private
DATA_TYPE_MAP = {
'custom_date' => 'date',
'custom_checkbox' => 'boolean',
'custom_dropdown' => 'select',
'custom_text' => 'input',
'custom_number' => 'integer',
'custom_paragraph' => 'input',
'custom_decimal' => 'input', # Don't use 'integer' as it would cut off the fractional part.
}.freeze
def data_type
@data_type ||= DATA_TYPE_MAP[resource['type']]
end
def data_option
{
null: true,
note: '',
}.merge(data_type_options)
end
def data_type_options
case data_type
when 'date'
{
future: true,
past: true,
diff: 0,
}
when 'boolean'
{
default: false,
options: {
true => 'yes',
false => 'no',
},
}
when 'select'
{
default: '',
options: options,
}
when 'input'
{
type: 'text',
maxlength: 255,
}
when 'integer'
{
min: 0,
max: 999_999_999,
}
else
{}
end
end
def screens
{
view: {
'-all-' => {
shown: true,
null: true,
},
Customer: {
shown: false,
null: true,
},
},
edit: {
'-all-' => {
shown: true,
null: true,
},
Customer: {
shown: false,
null: true,
},
}
}
end
def options
resource['choices'].each_with_object({}) do |choice, result|
result[choice] = choice
end
end
end
end
end
end
end
end

View file

@ -0,0 +1,24 @@
class Sequencer
class Unit
module Import
module Freshdesk
module ObjectAttribute
class FieldMap < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
skip_any_action
optional :action
uses :field_map, :model_class, :resource, :sanitized_name
def process
field_map[model_class.name] ||= {}
field_map[model_class.name][ resource['name'] ] = sanitized_name
end
end
end
end
end
end
end

View file

@ -0,0 +1,19 @@
class Sequencer
class Unit
module Import
module Freshdesk
module ObjectAttribute
class MigrationExecute < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
skip_any_action
def process
ObjectManager::Attribute.migration_execute(false)
end
end
end
end
end
end
end

View file

@ -0,0 +1,24 @@
class Sequencer
class Unit
module Import
module Freshdesk
module ObjectAttribute
class SanitizedName < Sequencer::Unit::Import::Common::ObjectAttribute::SanitizedName
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
skip_any_action
uses :resource
private
def unsanitized_name
# active_customer
resource['name']
end
end
end
end
end
end
end

View file

@ -0,0 +1,21 @@
class Sequencer
class Unit
module Import
module Freshdesk
module ObjectAttribute
class Skip < Sequencer::Unit::Base
uses :resource
provides :action
def process
return if !resource['default']
state.provide(:action, :skipped)
end
end
end
end
end
end
end

View file

@ -0,0 +1,27 @@
class Sequencer
class Unit
module Import
module Freshdesk
class ObjectCount < Sequencer::Unit::Common::Provider::Attribute
include ::Sequencer::Unit::Import::Common::Model::Statistics::Mixin::EmptyDiff
uses :model_class, :resources
private
def statistics_diff
{
model_key => empty_diff.merge!(
total: resources.count
)
}
end
def model_key
model_class.name.pluralize.to_sym
end
end
end
end
end
end

View file

@ -0,0 +1,25 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Perform < Sequencer::Unit::Base
uses :resources, :object, :import_job, :dry_run, :field_map, :id_map
def process
resources.each do |resource|
::Sequencer.process("Import::Freshdesk::#{object}",
parameters: {
import_job: import_job,
dry_run: dry_run,
resource: resource,
field_map: field_map,
id_map: id_map,
})
end
end
end
end
end
end
end

View file

@ -0,0 +1,33 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Request < Sequencer::Unit::Common::Provider::Attribute
extend ::Sequencer::Unit::Import::Freshdesk::Requester
uses :object, :request_params
provides :response
private
def response
builder = backend.new(
object: object,
request_params: request_params
)
self.class.request(
api_path: builder.api_path,
params: builder.params,
)
end
def backend
"::Sequencer::Unit::Import::Freshdesk::Request::#{object}".safe_constantize || ::Sequencer::Unit::Import::Freshdesk::Request::Generic
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Request < Sequencer::Unit::Common::Provider::Attribute
class Conversation < Sequencer::Unit::Import::Freshdesk::Request::Generic
attr_reader :ticket
def initialize(*)
super
@ticket = request_params.delete(:ticket)
end
def api_path
"tickets/#{ticket['id']}/conversations"
end
end
end
end
end
end
end

View file

@ -0,0 +1,28 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Request < Sequencer::Unit::Common::Provider::Attribute
class Generic
attr_reader :object, :request_params
def initialize(object:, request_params:)
@object = object
@request_params = request_params
end
def api_path
object.pluralize.underscore
end
def params
request_params.merge(
per_page: 100,
)
end
end
end
end
end
end
end

View file

@ -0,0 +1,19 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Request < Sequencer::Unit::Common::Provider::Attribute
class Ticket < Sequencer::Unit::Import::Freshdesk::Request::Generic
def params
super.merge(
updated_since: '1970-01-01',
order_type: :asc,
)
end
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Request < Sequencer::Unit::Common::Provider::Attribute
class TimeEntry < Sequencer::Unit::Import::Freshdesk::Request::Generic
attr_reader :ticket
def initialize(*)
super
@ticket = request_params.delete(:ticket)
end
def api_path
"tickets/#{ticket['id']}/time_entries"
end
end
end
end
end
end
end

View file

@ -0,0 +1,56 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Requester
def request(api_path:, params: nil)
10.times do |iteration|
response = perform_request(
api_path: api_path,
params: params,
)
return response if response.is_a? Net::HTTPOK
handle_error response, iteration
rescue => e
handle_exception e, iteration
end
end
def handle_error(response, iteration)
sleep_for = 10
case response
when Net::HTTPTooManyRequests
sleep_for = response.header['retry-after'].to_i + 10
logger.info "Rate limit: #{response.header.to_hash} (429 Too Many Requests). Sleeping #{sleep_for} seconds and retry (##{iteration + 1}/10)."
else
logger.info "Unknown response: #{response.inspect}. Sleeping 10 seconds and retry (##{iteration + 1}/10)."
end
sleep sleep_for
end
def handle_exception(e, iteration)
logger.error e
logger.info "Sleeping 10 seconds after #{e.name} and retry (##{iteration + 1}/10)."
sleep 10
end
def perform_request(api_path:, params: nil)
uri = URI("#{Setting.get('import_freshdesk_endpoint')}/#{api_path}")
uri.query = URI.encode_www_form(params) if params.present?
headers = { 'Content-Type' => 'application/json' }
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 600) do |http|
# for those special moments...
# http.set_debug_output($stdout)
request = Net::HTTP::Get.new(uri, headers)
request.basic_auth(Setting.get('import_freshdesk_endpoint_key'), 'X')
return http.request(request)
end
end
end
end
end
end
end

View file

@ -0,0 +1,18 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Resources < Sequencer::Unit::Common::Provider::Named
uses :response
private
def resources
JSON.parse(response.body)
end
end
end
end
end
end

View file

@ -0,0 +1,16 @@
class Sequencer
class Unit
module Import
module Freshdesk
module SubSequence
class Field < Sequencer::Unit::Import::Freshdesk::SubSequence::Generic
def sequence_name
'Sequencer::Sequence::Import::Freshdesk::GenericField'.freeze
end
end
end
end
end
end
end

View file

@ -0,0 +1,48 @@
class Sequencer
class Unit
module Import
module Freshdesk
module SubSequence
class Generic < Sequencer::Unit::Base
uses :dry_run, :import_job, :field_map, :id_map
attr_accessor :iteration
def process
loop.each_with_index do |_, iteration|
@iteration = iteration
result = ::Sequencer.process(sequence_name,
parameters: {
request_params: request_params,
import_job: import_job,
dry_run: dry_run,
object: object,
field_map: field_map,
id_map: id_map,
},
expecting: [:response])
break if result[:response].header['link'].blank?
end
end
def request_params
{
page: iteration + 1,
}
end
def object
self.class.name.demodulize.singularize
end
def sequence_name
raise NotImplementedError
end
end
end
end
end
end
end

View file

@ -0,0 +1,16 @@
class Sequencer
class Unit
module Import
module Freshdesk
module SubSequence
class Object < Sequencer::Unit::Import::Freshdesk::SubSequence::Generic
def sequence_name
'Sequencer::Sequence::Import::Freshdesk::GenericObject'.freeze
end
end
end
end
end
end
end

View file

@ -0,0 +1,33 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Ticket
class Conversations < Sequencer::Unit::Import::Freshdesk::SubSequence::Generic
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
optional :action
skip_action :skipped, :failed
uses :resource
def object
'Conversation'
end
def sequence_name
'Sequencer::Sequence::Import::Freshdesk::Conversations'.freeze
end
def request_params
super.merge(
ticket: resource,
)
end
end
end
end
end
end
end

View file

@ -0,0 +1,28 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Ticket
class Description < Sequencer::Unit::Import::Freshdesk::SubSequence::Generic
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
skip_action :skipped, :failed
uses :dry_run, :import_job, :resource, :field_map, :id_map
def process
::Sequencer.process('Import::Freshdesk::Description',
parameters: {
import_job: import_job,
dry_run: dry_run,
field_map: field_map,
id_map: id_map,
resource: resource,
})
end
end
end
end
end
end
end

View file

@ -0,0 +1,35 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Ticket
class Fetch < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Freshdesk::Requester
uses :resource
# Fetch additional data such as attachments which is not included
# in the ticket list endpoint.
def process
resource.merge!(fetch_ticket)
end
private
def fetch_ticket
response = request(
api_path: "tickets/#{resource['id']}",
)
JSON.parse(response.body)
rescue => e
logger.error "Error when fetching ticket data for ticket #{resource['id']}"
logger.error e
{}
end
end
end
end
end
end
end

View file

@ -0,0 +1,59 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Ticket
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource, :id_map
PRIORITY_MAP = {
1 => ::Ticket::Priority.find_by(name: '1 low').id, # low
2 => ::Ticket::Priority.find_by(name: '2 normal').id, # medium
3 => ::Ticket::Priority.find_by(name: '3 high').id, # high
4 => ::Ticket::Priority.find_by(name: '3 high').id, # urgent
}.freeze
STATE_MAP = {
2 => ::Ticket::State.find_by(name: 'open').id, # open
3 => ::Ticket::State.find_by(name: 'open').id, # pending
4 => ::Ticket::State.find_by(name: 'closed').id, # resolved
5 => ::Ticket::State.find_by(name: 'closed').id, # closed
}.freeze
def process # rubocop:disable Metrics/AbcSize
provide_mapped do
{
title: resource['subject'],
number: resource['id'],
group_id: group_id,
priority_id: PRIORITY_MAP[resource['priority']],
state_id: STATE_MAP[resource['status']],
owner_id: owner_id,
customer_id: customer_id,
created_at: resource['created_at'],
updated_at: resource['updated_at'],
}
end
end
private
def group_id
id_map.dig('Group', resource['group_id']) || ::Group.find_by(name: 'Support')&.id || 1
end
def customer_id
id_map['User'][resource['requester_id']]
end
def owner_id
id_map['User'][resource['responder_id']]
end
end
end
end
end
end
end

View file

@ -0,0 +1,20 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Ticket
class Tags < Sequencer::Unit::Common::Model::Tags
uses :resource
private
def tags
resource['tags']
end
end
end
end
end
end
end

View file

@ -0,0 +1,33 @@
class Sequencer
class Unit
module Import
module Freshdesk
module Ticket
class TimeEntries < Sequencer::Unit::Import::Freshdesk::SubSequence::Generic
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
optional :action
skip_action :skipped, :failed
uses :resource
def object
'TimeEntry'
end
def sequence_name
'Sequencer::Sequence::Import::Freshdesk::TimeEntries'.freeze
end
def request_params
super.merge(
ticket: resource,
)
end
end
end
end
end
end
end

View file

@ -0,0 +1,10 @@
class Sequencer
class Unit
module Import
module Freshdesk
class TicketFields < Sequencer::Unit::Import::Freshdesk::SubSequence::Field
end
end
end
end
end

View file

@ -0,0 +1,10 @@
class Sequencer
class Unit
module Import
module Freshdesk
class Tickets < Sequencer::Unit::Import::Freshdesk::SubSequence::Object
end
end
end
end
end

View file

@ -0,0 +1,60 @@
class Sequencer
class Unit
module Import
module Freshdesk
module TimeEntry
class Mapping < Sequencer::Unit::Base
include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
uses :resource, :id_map
provides :action
def process
provide_mapped do
{
time_unit: time_unit,
ticket_id: ticket_id,
created_by_id: agent_id,
created_at: resource['created_at'],
updated_at: resource['updated_at'],
}
end
rescue TypeError => e
# TODO
# TimeTracking is not available in the plans: Sprout, Blossom
# In this case `resource`s value is `["code", "require_feature"]`
# We should somehow detect that/the plan (no API available) to avoid
# running into this error and therefore performing unnecessary requests
# See:
# - Ticket# 1077135
# - https://support.freshdesk.com/support/solutions/articles/37583-keeping-track-of-time-spent
#
# Idea: Maybe it's possible to use the "X-Ratelimit-Total"-Value from the header, because with this it
# should be possible to detect the plan.
logger.debug { e }
# state.provide(:action, :failed)
state.provide(:action, :skipped)
end
private
def time_unit
hours, minutes = resource['time_spent'].match(%r{(\d{2}):(\d{2})}).captures
(hours.to_i * 60) + minutes.to_i
end
def ticket_id
id_map['Ticket'][resource['ticket_id']]
end
def agent_id
id_map['User'][resource['agent_id']]
end
end
end
end
end
end
end

View file

@ -63,6 +63,19 @@ FactoryBot.define do
end
end
factory :object_manager_attribute_boolean, parent: :object_manager_attribute do
data_type { 'boolean' }
data_option do
{
default: false,
options: {
true => 'yes',
false => 'no',
}
}
end
end
factory :object_manager_attribute_date, parent: :object_manager_attribute do
name { 'date_attribute' }
data_type { 'date' }

View file

@ -0,0 +1,99 @@
require 'rails_helper'
#
# The purpose of this integration test is to verify that the API generally works.
# Individual import steps are tested in spec/lib/sequencer.
#
RSpec.describe 'Freshdesk import', type: :integration, use_vcr: true, db_strategy: :reset do # rubocop:disable RSpec/DescribeClass
before do
if !ENV['IMPORT_FRESHDESK_ENDPOINT']
raise "ERROR: Need IMPORT_FRESHDESK_ENDPOINT - hint IMPORT_FRESHDESK_ENDPOINT='https://example.freshdesk.com/api/v2'"
end
if !ENV['IMPORT_FRESHDESK_ENDPOINT_KEY']
raise "ERROR: Need IMPORT_FRESHDESK_ENDPOINT_KEY - hint IMPORT_FRESHDESK_ENDPOINT_KEY='01234567899876543210'"
end
Setting.set('import_freshdesk_endpoint', ENV['IMPORT_FRESHDESK_ENDPOINT'])
Setting.set('import_freshdesk_endpoint_key', ENV['IMPORT_FRESHDESK_ENDPOINT_KEY'])
Setting.set('import_mode', true)
Setting.set('system_init_done', false)
VCR.configure do |c|
%w[
IMPORT_FRESHDESK_ENDPOINT
IMPORT_FRESHDESK_ENDPOINT_KEY
IMPORT_FRESHDESK_ENDPOINT_SUBDOMAIN
].each do |env_key|
c.filter_sensitive_data("<#{env_key}>") { ENV[env_key] }
end
# The API key is used only inside the base64 encoded Basic Auth string, so mask that as well.
%w[
IMPORT_FRESHDESK_ENDPOINT_BASIC_AUTH
].each do |env_key|
c.filter_sensitive_data("<#{env_key}>") { Base64.encode64( "#{ENV['IMPORT_FRESHDESK_ENDPOINT_KEY']}:X" ).chomp }
end
end
VCR.use_cassette 'freshdesk_import' do
ImportJob.create(name: 'Import::Freshdesk').start
end
end
context 'when performing the full Freshdesk import' do
let(:job) { ImportJob.last }
let(:expected_stats) do
{
'Groups' => {
'skipped' => 0,
'created' => 9,
'updated' => 0,
'unchanged' => 0,
'failed' => 0,
'deactivated' => 0,
'sum' => 9,
'total' => 9,
},
'Users' => {
'skipped' => 0,
'created' => 19,
'updated' => 0,
'unchanged' => 0,
'failed' => 0,
'deactivated' => 0,
'sum' => 19,
'total' => 19,
},
'Organizations' => {
'skipped' => 0,
'created' => 0,
'updated' => 1,
'unchanged' => 0,
'failed' => 0,
'deactivated' => 0,
'sum' => 1,
'total' => 1,
},
'Tickets' => {
'skipped' => 0,
'created' => 13,
'updated' => 0,
'unchanged' => 0,
'failed' => 0,
'deactivated' => 0,
'sum' => 13,
'total' => 13,
},
}
end
it 'imports the correct number of expected objects' do
expect(job.result).to eq expected_stats
end
end
end

View file

@ -0,0 +1,88 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::Agent, sequencer: :sequence do
context 'when importing agents from Freshdesk' do
let(:groups) do
create_list(:group, 3)
end
let(:resource) do
{
'available' => false,
'occasional' => false,
'id' => 1001,
'ticket_scope' => 1,
'created_at' => '2021-04-09T13:23:58Z',
'updated_at' => '2021-05-10T09:14:20Z',
'last_active_at' => '2021-05-10T09:14:20Z',
'available_since' => nil,
'type' => 'support_agent',
'contact' => {
'active' => true,
'email' => 'freshdesk@example.com',
'job_title' => nil,
'language' => 'en',
'last_login_at' => '2021-05-10T07:52:58Z',
'mobile' => nil,
'name' => 'John Doe',
'phone' => nil,
'time_zone' => 'Eastern Time (US & Canada)',
'created_at' => '2021-04-09T13:23:58Z',
'updated_at' => '2021-04-09T13:31:00Z'
},
'signature' => nil,
'group_ids' => [1001, 1002, 1003]
}
end
let(:id_map) do
{
'Group' => {
1001 => groups[0].id,
1002 => groups[1].id,
1003 => groups[2].id,
}
}
end
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: {},
id_map: id_map,
}
end
it 'imports user correctly' do # rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength
expect { process(process_payload) }.to change(User, :count).by(1)
expect(User.last).to have_attributes(
firstname: 'John',
lastname: 'Doe',
login: 'freshdesk@example.com',
email: 'freshdesk@example.com',
active: true,
)
end
it 'sets user roles correctly for admin user' do
allow( Sequencer::Unit::Import::Freshdesk::Agent::Mapping).to receive(:admin_id).and_return(1001)
process(process_payload)
expect(User.last.roles.sort.map(&:name)).to eq %w[Admin Agent]
end
it 'sets user roles correctly for non-admin user' do
process(process_payload)
expect(User.last.roles.sort.map(&:name)).to eq ['Agent']
end
it 'sets user groups correctly ' do
process(process_payload)
expect(User.last.groups_access('full').sort).to eq groups
end
end
end

View file

@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::CompanyField, sequencer: :sequence do
context 'when trying to import company fields from Freshdesk', db_strategy: :reset do
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: {},
id_map: {},
}
end
# Other field types are checked in ticket_field_spec.rb.
context 'when fields are valid' do
let(:resource) do
{
'id' => 80_000_387_409,
'name' => 'custom_dropdown',
'label' => 'custom_dropdown',
'position' => 9,
'required_for_agents' => false,
'type' => 'custom_dropdown',
'default' => false,
'created_at' => '2021-04-12T20:24:41Z',
'updated_at' => '2021-04-12T20:24:41Z',
'choices' => [
'First Choice',
'Second Choice',
],
}
end
it 'adds custom fields' do
expect { process(process_payload) }.to change(Organization, :column_names).by(['custom_dropdown'])
end
end
context 'when fields are invalid' do
let(:resource) do
{
'id' => 80_000_382_712,
'name' => 'name',
'label' => 'Company Name',
'position' => 1,
'required_for_agents' => true,
'type' => 'default_name',
'default' => true,
'created_at' => '2021-04-09T13:23:59Z',
'updated_at' => '2021-04-09T13:23:59Z'
}
end
it 'ignores other fields' do
expect { process(process_payload) }.not_to change(Organization, :column_names)
end
end
end
end

View file

@ -0,0 +1,68 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::Company, sequencer: :sequence, db_strategy: :reset do
context 'when importing companies from Freshdesk' do
let(:resource) do
{ 'id' => 80_000_602_705,
'name' => 'Test Foundation',
'description' => nil,
'note' => nil,
'domains' => ['acmecorp.com'],
'created_at' => '2021-04-09T13:24:00Z',
'updated_at' => '2021-04-12T20:25:36Z',
'custom_fields' => {
'cf_test_checkbox' => true,
'cf_custom_integer' => 999,
'cf_custom_dropdown' => 'key_2',
'cf_custom_decimal' => '1.1',
},
'health_score' => nil,
'account_tier' => 'Basic',
'renewal_date' => nil,
'industry' => nil }
end
let(:field_map) do
{
'Organization' => {
'cf_test_checkbox' => 'cf_test_checkbox',
'cf_custom_integer' => 'cf_custom_integer',
'cf_custom_dropdown' => 'cf_custom_dropdown',
'cf_custom_decimal' => 'cf_custom_decimal'
}
}
end
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: field_map,
id_map: {},
}
end
before do
create :object_manager_attribute_select, object_name: 'Organization', name: 'cf_custom_dropdown'
create :object_manager_attribute_integer, object_name: 'Organization', name: 'cf_custom_integer'
create :object_manager_attribute_boolean, object_name: 'Organization', name: 'cf_test_checkbox'
create :object_manager_attribute_text, object_name: 'Organization', name: 'cf_custom_decimal'
ObjectManager::Attribute.migration_execute
end
it 'adds organizations' do # rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength
expect { process(process_payload) }.to change(Organization, :count).by(1)
expect(Organization.last).to have_attributes(
name: 'Test Foundation',
note: nil,
cf_custom_dropdown: 'key_2',
cf_custom_integer: 999,
cf_test_checkbox: true,
cf_custom_decimal: '1.1',
)
end
end
end

View file

@ -0,0 +1,70 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::ContactField, sequencer: :sequence do
context 'when tryping to import contact fields from Freshdesk', db_strategy: :reset do
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: {},
id_map: {},
}
end
# Other field types are checked in ticket_field_spec.rb.
context 'when fields are valid' do
let(:resource) do
{
'editable_in_signup' => false,
'id' => 80_000_776_200,
'name' => 'custom_dropdown',
'label' => 'custom_dropdown',
'position' => 16,
'required_for_agents' => false,
'type' => 'custom_dropdown',
'default' => false,
'customers_can_edit' => true,
'label_for_customers' => 'custom_dropdown',
'required_for_customers' => false,
'displayed_for_customers' => true,
'created_at' => '2021-04-12T20:19:46Z',
'updated_at' => '2021-04-12T20:19:46Z',
'choices' => [ 'First Choice', 'Second Choice']
}
end
it 'adds custom fields' do
expect { process(process_payload) }.to change(User, :column_names).by(['custom_dropdown'])
end
end
context 'when fields are invalid' do
let(:resource) do
{
'editable_in_signup' => false,
'id' => 80_000_766_844,
'name' => 'twitter_followers_count',
'label' => 'Twitter Follower Count',
'position' => 15,
'required_for_agents' => false,
'type' => 'default_twitter_followers_count',
'default' => true,
'customers_can_edit' => false,
'label_for_customers' => 'Twitter Follower Count',
'required_for_customers' => false,
'displayed_for_customers' => false,
'created_at' => '2021-04-09T13:24:02Z',
'updated_at' => '2021-04-09T13:24:02Z'
}
end
it 'ignores other fields' do
expect { process(process_payload) }.not_to change(User, :column_names)
end
end
end
end

View file

@ -0,0 +1,86 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::Contact, sequencer: :sequence, db_strategy: :reset do
context 'when importing customers from Freshdesk' do
let(:resource) do
{
'active' => false,
'address' => nil,
'company_id' => 1001,
'description' => nil,
'email' => 'sam.ozzy@freshdesk.com',
'id' => 80_014_400_819,
'job_title' => nil,
'language' => 'en',
'mobile' => nil,
'name' => 'Sam Osborne',
'phone' => nil,
'time_zone' => 'Eastern Time (US & Canada)',
'twitter_id' => nil,
'custom_fields' => {
'cf_test_checkbox' => true,
'cf_custom_integer' => 999,
'cf_custom_dropdown' => 'key_2',
'cf_custom_decimal' => '1.1',
},
'facebook_id' => nil,
'created_at' => '2021-04-09T13:29:43Z',
'updated_at' => '2021-04-09T13:29:43Z',
'csat_rating' => 103,
'preferred_source' => 'email',
}
end
let(:field_map) do
{
'User' => {
'cf_test_checkbox' => 'cf_test_checkbox',
'cf_custom_integer' => 'cf_custom_integer',
'cf_custom_dropdown' => 'cf_custom_dropdown',
'cf_custom_decimal' => 'cf_custom_decimal'
}
}
end
let(:id_map) do
{
'Organization' => { 1001 => 1 }
}
end
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: field_map,
id_map: id_map,
}
end
before do
create :object_manager_attribute_select, object_name: 'User', name: 'cf_custom_dropdown'
create :object_manager_attribute_integer, object_name: 'User', name: 'cf_custom_integer'
create :object_manager_attribute_boolean, object_name: 'User', name: 'cf_test_checkbox'
create :object_manager_attribute_text, object_name: 'User', name: 'cf_custom_decimal'
ObjectManager::Attribute.migration_execute
end
it 'imports customers correctly' do # rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength
expect { process(process_payload) }.to change(User, :count).by(1)
expect(User.last).to have_attributes(
firstname: 'Sam',
lastname: 'Osborne',
login: 'sam.ozzy@freshdesk.com',
email: 'sam.ozzy@freshdesk.com',
active: false,
cf_custom_dropdown: 'key_2',
cf_custom_integer: 999,
cf_test_checkbox: true,
cf_custom_decimal: '1.1',
)
end
end
end

View file

@ -0,0 +1,102 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::Conversation, sequencer: :sequence do
context 'when importing conversations from Freshdesk' do
let(:resource) do
{
'body' => "<div style=\"font-family:-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; font-size:14px\">\n<div dir=\"ltr\">Let's see if inline images work in a subsequent article:</div>\n<div dir=\"ltr\"><img src=\"https://eucattachment.freshdesk.com/inline/attachment?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ODAwMTIyMjY4NTMsImRvbWFpbiI6InphbW1hZC5mcmVzaGRlc2suY29tIiwiYWNjb3VudF9pZCI6MTg5MDU2MH0.705lNehzm--aO36CGFg0SW73j0NG3UWcRcN1_DXgtwc\" style=\"width: auto\" class=\"fr-fil fr-dib\" data-id=\"80012226853\"></div>\n</div>", 'body_text' => "Let's see if inline images work in a subsequent article:",
'id' => 80_027_218_656,
'incoming' => false,
'private' => true,
'user_id' => 80_014_400_475,
'support_email' => nil,
'source' => 2,
'category' => 2,
'to_emails' => ['info@zammad.org'],
'from_email' => nil,
'cc_emails' => [],
'bcc_emails' => nil,
'email_failure_count' => nil,
'outgoing_failures' => nil,
'created_at' => '2021-05-14T12:30:19Z',
'updated_at' => '2021-05-14T12:30:19Z',
'attachments' => [
{
'id' => 80_012_226_885,
'name' => 'standalone_attachment.png',
'content_type' => 'image/png',
'size' => 11_447,
'created_at' => '2021-05-14T12:30:16Z',
'updated_at' => '2021-05-14T12:30:19Z',
'attachment_url' => 'https://s3.eu-central-1.amazonaws.com/euc-cdn.freshdesk.com/data/helpdesk/attachments/production/80012226885/original/standalone_attachment.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAS6FNSMY2RG7BSUFP%2F20210514%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210514T123300Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=750988d37a6f2f43830bfd19c895517aa051aa13b4ab26a1333369d414fef0be',
'thumb_url' => 'https://s3.eu-central-1.amazonaws.com/euc-cdn.freshdesk.com/data/helpdesk/attachments/production/80012226885/thumb/standalone_attachment.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAS6FNSMY2RG7BSUFP%2F20210514%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210514T123300Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=40b5fe1d7d418bcbd1e639b273a1038c7a73781c16d9881c2f31a11c6bebfdf9'
}
],
'auto_response' => false,
'ticket_id' => 1001,
'source_additional_info' => nil
}
end
let(:used_urls) do
[
'https://eucattachment.freshdesk.com/inline/attachment?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ODAwMTIyMjY4NTMsImRvbWFpbiI6InphbW1hZC5mcmVzaGRlc2suY29tIiwiYWNjb3VudF9pZCI6MTg5MDU2MH0.705lNehzm--aO36CGFg0SW73j0NG3UWcRcN1_DXgtwc',
'https://s3.eu-central-1.amazonaws.com/euc-cdn.freshdesk.com/data/helpdesk/attachments/production/80012226885/original/standalone_attachment.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAS6FNSMY2RG7BSUFP%2F20210514%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210514T123300Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=750988d37a6f2f43830bfd19c895517aa051aa13b4ab26a1333369d414fef0be',
]
end
let(:ticket) { create :ticket }
let(:id_map) do
{
'Ticket' => {
1001 => ticket.id,
},
'User' => {
80_014_400_475 => 1,
}
}
end
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: {},
id_map: id_map,
}
end
before do
# Mock the attachment and inline image download requests.
used_urls.each do |used_url|
stub_request(:get, used_url).to_return(status: 200, body: '123', headers: {})
end
end
it 'adds article with inline image' do # rubocop:disable RSpec/MultipleExpectations
expect { process(process_payload) }.to change(Ticket::Article, :count).by(1)
expect(Ticket::Article.last).to have_attributes(
to: 'info@zammad.org',
body: "\n<div>\n<div dir=\"ltr\">Let's see if inline images work in a subsequent article:</div>\n<div dir=\"ltr\"><img src=\"data:image/png;base64,MTIz\" style=\"width: auto;\"></div>\n</div>\n",
)
end
it 'adds correct number of attachments' do
process(process_payload)
expect(Ticket::Article.last.attachments.size).to eq 1
end
it 'adds attachment content' do # rubocop:disable RSpec/ExampleLength
process(process_payload)
expect(Ticket::Article.last.attachments.last).to have_attributes(
'filename' => 'standalone_attachment.png',
'size' => '3',
'preferences' => {
'Content-Type' => 'image/png',
'resizable' => false,
}
)
end
end
end

View file

@ -0,0 +1,39 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::Group, sequencer: :sequence do
context 'when importing groups from Freshdesk' do
let(:resource) do
{
'id' => 80_000_374_715,
'name' => 'QA',
'description' => 'Members of the QA team belong to this group',
'escalate_to' => nil,
'unassigned_for' => nil,
'business_hour_id' => nil,
'group_type' => 'support_agent_group',
'created_at' => '2021-04-09T13:23:59Z',
'updated_at' => '2021-04-09T13:23:59Z'
}
end
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: {},
id_map: {},
}
end
it 'adds groups' do # rubocop:disable RSpec/MultipleExpectations
expect { process(process_payload) }.to change(Group, :count).by(1)
expect(Group.last).to have_attributes(
name: 'QA',
active: true,
)
end
end
end

View file

@ -0,0 +1,157 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::TicketField, sequencer: :sequence do
context 'when trying to import ticket fields from Freshdesk', db_strategy: :reset do
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: {},
id_map: {},
}
end
let(:base_resource) do
{
'id' => 80_000_561_223,
'label' => 'My custom field',
'description' => nil,
'position' => 14,
'required_for_closure' => false,
'required_for_agents' => false,
'default' => false,
'customers_can_edit' => true,
'label_for_customers' => 'custom_dropdown',
'required_for_customers' => false,
'displayed_to_customers' => true,
'created_at' => '2021-04-12T20:48:40Z',
'updated_at' => '2021-04-12T20:48:40Z',
}
end
context 'when field is a dropdown' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_dropdown',
'type' => 'custom_dropdown',
'choices' => %w[key1 key2],
}
)
end
it 'adds a custom field' do
p resource
expect { process(process_payload) }.to change(Ticket, :column_names).by(['cf_custom_dropdown'])
end
end
context 'when field is a decimal' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_integer',
'type' => 'custom_decimal',
}
)
end
it 'adds a custom field' do
expect { process(process_payload) }.to change(Ticket, :column_names).by(['cf_custom_integer'])
end
end
context 'when field is a number' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_integer',
'type' => 'custom_number',
}
)
end
it 'adds a custom field' do
expect { process(process_payload) }.to change(Ticket, :column_names).by(['cf_custom_integer'])
end
end
context 'when field is a date' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_date',
'type' => 'custom_date',
}
)
end
it 'adds a custom field' do
expect { process(process_payload) }.to change(Ticket, :column_names).by(['cf_custom_date'])
end
end
context 'when field is a checkbox' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_checkbox',
'type' => 'custom_checkbox',
}
)
end
it 'adds a custom field' do
expect { process(process_payload) }.to change(Ticket, :column_names).by(['cf_custom_checkbox'])
end
end
context 'when field is a text' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_text',
'type' => 'custom_text',
}
)
end
it 'adds a custom field' do
expect { process(process_payload) }.to change(Ticket, :column_names).by(['cf_custom_text'])
end
end
context 'when field is a paragraph' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_paragraph',
'type' => 'custom_paragraph',
}
)
end
it 'adds a custom field' do
expect { process(process_payload) }.to change(Ticket, :column_names).by(['cf_custom_paragraph'])
end
end
context 'when field is invalid' do
let(:resource) do
base_resource.merge(
{
'name' => 'cf_custom_unknown',
'type' => 'custom_unknown',
}
)
end
it 'raises an error' do
expect { process(process_payload) }.to raise_error(ActiveRecord::RecordInvalid)
end
end
end
end

View file

@ -0,0 +1,174 @@
require 'rails_helper'
RSpec.describe ::Sequencer::Sequence::Import::Freshdesk::Ticket, sequencer: :sequence, db_strategy: 'reset' do
context 'when importing tickets from Freshdesk' do
let(:group) { create :group }
let(:resource) do
{
'cc_emails' => [],
'fwd_emails' => [],
'reply_cc_emails' => [],
'ticket_cc_emails' => [],
'fr_escalated' => false,
'spam' => false,
'email_config_id' => nil,
'group_id' => 80_000_374_718,
'priority' => 1,
'requester_id' => 80_014_400_475,
'responder_id' => 80_014_400_475,
'source' => 3,
'company_id' => nil,
'status' => 2,
'subject' => 'Inline Images Failing?',
'association_type' => nil,
'support_email' => nil,
'to_emails' => ['info@zammad.org'],
'product_id' => nil,
'id' => 13,
'type' => nil,
'due_by' => '2021-05-17T12:29:27Z',
'fr_due_by' => '2021-05-15T12:29:27Z',
'is_escalated' => false,
'custom_fields' => {
'cf_test_checkbox' => true,
'cf_custom_integer' => 999,
'cf_custom_dropdown' => 'key_2',
'cf_custom_decimal' => '1.1'
},
'created_at' => '2021-05-14T12:29:27Z',
'updated_at' => '2021-05-14T12:30:19Z',
'associated_tickets_count' => nil,
'tags' => [],
'description' => "<div style=\"font-family:-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; font-size:14px\">\n<div dir=\"ltr\">Inline images in the first article might not be working, see following:</div>\n<div dir=\"ltr\"><img src=\"https://eucattachment.freshdesk.com/inline/attachment?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ODAwMTIyMjY0NzksImRvbWFpbiI6InphbW1hZC5mcmVzaGRlc2suY29tIiwiYWNjb3VudF9pZCI6MTg5MDU2MH0.cdYIOOSi7ckCFIZlQ9eynELMzJp1ECVeTLlQMCDgKo4\" style=\"width: auto\" class=\"fr-fil fr-dib\" data-id=\"80012226479\"></div>\n</div>", 'description_text' => 'Inline images in the first article might not be working, see following:'
}
end
let(:field_map) do
{
'Ticket' => {
'cf_test_checkbox' => 'cf_test_checkbox',
'cf_custom_integer' => 'cf_custom_integer',
'cf_custom_dropdown' => 'cf_custom_dropdown',
'cf_custom_decimal' => 'cf_custom_decimal'
}
}
end
let(:id_map) do
{
'User' => {
80_014_400_475 => owner.id,
},
'Group' => {
80_000_374_718 => group.id,
},
}
end
let(:process_payload) do
{
import_job: build_stubbed(:import_job, name: 'Import::Freshdesk', payload: {}),
dry_run: false,
resource: resource,
field_map: field_map,
id_map: id_map,
}
end
let(:owner) { create :agent, group_ids: [group.id] }
let(:ticket_get_response_payload) do
attachment_payload = {
'attachments' => [
{
'id' => 80_012_226_885,
'name' => 'standalone_attachment.png',
'content_type' => 'image/png',
'size' => 11_447,
'created_at' => '2021-05-14T12:30:16Z',
'updated_at' => '2021-05-14T12:30:19Z',
'attachment_url' => 'https://s3.eu-central-1.amazonaws.com/euc-cdn.freshdesk.com/data/helpdesk/attachments/production/80012226885/original/standalone_attachment.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAS6FNSMY2RG7BSUFP%2F20210514%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210514T123300Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=750988d37a6f2f43830bfd19c895517aa051aa13b4ab26a1333369d414fef0be',
'thumb_url' => 'https://s3.eu-central-1.amazonaws.com/euc-cdn.freshdesk.com/data/helpdesk/attachments/production/80012226885/thumb/standalone_attachment.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAS6FNSMY2RG7BSUFP%2F20210514%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210514T123300Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=40b5fe1d7d418bcbd1e639b273a1038c7a73781c16d9881c2f31a11c6bebfdf9'
}
],
}
resource.merge(attachment_payload)
end
let(:used_urls) do
[
'https://eucattachment.freshdesk.com/inline/attachment?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ODAwMTIyMjY0NzksImRvbWFpbiI6InphbW1hZC5mcmVzaGRlc2suY29tIiwiYWNjb3VudF9pZCI6MTg5MDU2MH0.cdYIOOSi7ckCFIZlQ9eynELMzJp1ECVeTLlQMCDgKo4',
'https://s3.eu-central-1.amazonaws.com/euc-cdn.freshdesk.com/data/helpdesk/attachments/production/80012226885/original/standalone_attachment.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAS6FNSMY2RG7BSUFP%2F20210514%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210514T123300Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=750988d37a6f2f43830bfd19c895517aa051aa13b4ab26a1333369d414fef0be',
]
end
before do
create :object_manager_attribute_select, name: 'cf_custom_dropdown'
create :object_manager_attribute_integer, name: 'cf_custom_integer'
create :object_manager_attribute_boolean, name: 'cf_test_checkbox'
create :object_manager_attribute_text, name: 'cf_custom_decimal'
ObjectManager::Attribute.migration_execute
# Mock the attachment and inline image download requests.
used_urls.each do |used_url|
stub_request(:get, used_url).to_return(status: 200, body: '123', headers: {})
end
# Mock the ticket get request (Import::Freshdesk::Ticket::Fetch).
stub_request(:get, 'https://yours.freshdesk.com/api/v2/tickets/13').to_return(status: 200, body: JSON.generate(ticket_get_response_payload), headers: {})
end
# We only want to test here the Ticket API, so disable other modules in the sequence
# that make their own HTTP requests.
custom_sequence = Sequencer::Sequence::Import::Freshdesk::Ticket.sequence.dup
custom_sequence.delete('Import::Freshdesk::Ticket::TimeEntries')
custom_sequence.delete('Import::Freshdesk::Ticket::Conversations')
it 'adds tickets' do # rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength
allow(Sequencer::Sequence::Import::Freshdesk::Ticket).to receive(:sequence) { custom_sequence }
expect { process(process_payload) }.to change(Ticket, :count).by(1)
expect(Ticket.last).to have_attributes(
title: 'Inline Images Failing?',
note: nil,
create_article_type_id: 5,
create_article_sender_id: 2,
article_count: 1,
state_id: 2,
group_id: group.id,
priority_id: 1,
owner_id: owner.id,
customer_id: User.last.id,
cf_custom_dropdown: 'key_2',
cf_custom_integer: 999,
cf_test_checkbox: true,
cf_custom_decimal: '1.1',
)
end
it 'adds article with inline image' do # rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength
allow(Sequencer::Sequence::Import::Freshdesk::Ticket).to receive(:sequence) { custom_sequence }
expect { process(process_payload) }.to change(Ticket::Article, :count).by(1)
expect(Ticket::Article.last).to have_attributes(
to: 'info@zammad.org',
body: "\n<div>\n<div dir=\"ltr\">Inline images in the first article might not be working, see following:</div>\n<div dir=\"ltr\"><img src=\"data:image/png;base64,MTIz\" style=\"width: auto;\"></div>\n</div>\n",
)
end
it 'adds correct number of attachments' do
allow(Sequencer::Sequence::Import::Freshdesk::Ticket).to receive(:sequence) { custom_sequence }
process(process_payload)
expect(Ticket::Article.last.attachments.size).to eq 1
end
it 'adds attachment content' do # rubocop:disable RSpec/ExampleLength
allow(Sequencer::Sequence::Import::Freshdesk::Ticket).to receive(:sequence) { custom_sequence }
process(process_payload)
expect(Ticket::Article.last.attachments.last).to have_attributes(
'filename' => 'standalone_attachment.png',
'size' => '3',
'preferences' => {
'Content-Type' => 'image/png',
'resizable' => false,
}
)
end
end
end

View file

@ -0,0 +1,29 @@
require 'rails_helper'
RSpec.describe Sequencer::Unit::Freshdesk::Connected, sequencer: :unit do
context 'when checking the connection to Freshdesk' do
let(:params) do
{
dry_run: false,
import_job: instance_double(ImportJob),
field_map: {},
id_map: {},
}
end
let(:response_ok) { Net::HTTPOK.new(1.0, '200', 'OK') }
let(:response_unauthorized) { Net::HTTPUnauthorized.new(1.0, '401', 'Unauthorized') }
it 'check for correct connection' do
allow(described_class).to receive(:perform_request).with(any_args).and_return(response_ok)
expect(process(params)).to eq({ connected: true })
end
it 'check for unauthorized connection' do
allow(described_class).to receive(:perform_request).with(any_args).and_return(response_unauthorized)
expect(process(params)).to eq({ connected: false })
end
end
end

View file

@ -1,5 +1,5 @@
RSpec.shared_examples 'ApplicationModel::ChecksImport' do
describe '#id (for referential integrity during OTRS/Zendesk import)' do
describe '#id (for referential integrity during (e.g. OTRS/Zendesk/Freshdesk) import)' do
subject { build(described_class.name.underscore, id: next_id + 1) }
let(:next_id) do

View file

@ -0,0 +1,137 @@
require 'rails_helper'
RSpec.describe 'Import Freshdesk', type: :system, set_up: false, authenticated_as: false do
before(:all) do # rubocop:disable RSpec/BeforeAfterAll
required_envs = %w[IMPORT_FRESHDESK_ENDPOINT_SUBDOMAIN IMPORT_FRESHDESK_ENDPOINT_KEY]
required_envs.each do |key|
skip("NOTICE: Missing environment variable #{key} for test! (Please fill up: #{required_envs.join(' && ')})") if ENV[key].blank?
end
end
# TODO: check/clarify how the ENV-works in the CI-Envoirment?
# TODO: check https://git.znuny.com/zammad/zammad/-/merge_requests/1506/
# comment out bellow block to use VCR tape for running freshdesk import
# this allows to work around freshdesk rate limiting
# works great for debugging freshdesk locally
# around do |example|
# VCR.temporary_ignore_regexps += [/^(\S+\.|)freshdesk.com$/, /^\S+.zdusercontent.com$/]
# example.run
# VCR.temporary_ignore_regexps -= [/^(\S+\.|)freshdesk.com$/, /^\S+.zdusercontent.com$/]
# end
describe 'fields validation', :use_vcr do
before do
visit '#import'
find('.js-freshdesk').click
end
let(:subdomain_field) { find('#freshdesk-subdomain') }
let(:token_field) { find('#freshdesk-api-token') }
it 'invalid hostname' do
subdomain_field.fill_in with: 'reallybadexample'
expect(page).to have_css('.freshdesk-subdomain-error', text: 'Hostname not found!')
end
it 'valid hostname' do
subdomain_field.fill_in with: 'reallybadexample'
# wait for error to appear to validate it's hidden successfully
find('.freshdesk-subdomain-error', text: 'Hostname not found!')
subdomain_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_SUBDOMAIN']
expect(page).to have_no_css('.freshdesk-subdomain-error', text: 'Hostname not found!')
end
it 'invalid credentials' do
subdomain_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_SUBDOMAIN']
find('.js-freshdesk-credentials').click
token_field.fill_in with: '1nv4l1dT0K3N'
expect(page).to have_css('.freshdesk-api-token-error', text: 'Invalid credentials!')
end
it 'valid credentials' do
subdomain_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_SUBDOMAIN']
find('.js-freshdesk-credentials').click
token_field.fill_in with: '1nv4l1dT0K3N'
# wait for error to appear to validate it's hidden successfully
expect(page).to have_css('.freshdesk-api-token-error', text: 'Invalid credentials!')
token_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_KEY']
expect(page).to have_no_css('.freshdesk-api-token-error', text: 'Invalid credentials!')
end
it 'shows start button' do
subdomain_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_SUBDOMAIN']
find('.js-freshdesk-credentials').click
token_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_KEY']
expect(page).to have_css('.js-migration-start')
end
end
describe 'import progress', :use_vcr do
let(:subdomain_field) { find('#freshdesk-subdomain') }
let(:token_field) { find('#freshdesk-api-token') }
let(:job) { ImportJob.find_by(name: 'Import::Freshdesk') }
before do
VCR.use_cassette 'system/import/freshdesk/import_progress_setup' do
visit '#import'
find('.js-freshdesk').click
subdomain_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_SUBDOMAIN']
find('.js-freshdesk-credentials').click
token_field.fill_in with: ENV['IMPORT_FRESHDESK_ENDPOINT_KEY']
find('.js-migration-start').click
await_empty_ajax_queue
end
end
it 'shows groups progress' do
job.update! result: { Groups: { sum: 3, total: 5 } }
expect(page).to have_css('.js-groups .js-done', text: '3')
.and(have_css('.js-groups .js-total', text: '5'))
end
it 'shows users progress' do
job.update! result: { Users: { sum: 5, total: 9 } }
expect(page).to have_css('.js-users .js-done', text: '5')
.and(have_css('.js-users .js-total', text: '9'))
end
it 'shows organizations progress' do
job.update! result: { Organizations: { sum: 3, total: 5 } }
expect(page).to have_css('.js-organizations .js-done', text: '3')
.and(have_css('.js-organizations .js-total', text: '5'))
end
it 'shows tickets progress' do
job.update! result: { Tickets: { sum: 3, total: 5 } }
expect(page).to have_css('.js-tickets .js-done', text: '3')
.and(have_css('.js-tickets .js-total', text: '5'))
end
it 'shows login after import is finished' do
job.update! finished_at: Time.zone.now
Rake::Task['zammad:setup:auto_wizard'].execute
expect(page).to have_text('Login')
end
end
end

File diff suppressed because one or more lines are too long