diff --git a/Gemfile b/Gemfile
index de130ccac..449f5ac68 100644
--- a/Gemfile
+++ b/Gemfile
@@ -80,6 +80,9 @@ gem 'browser'
gem 'slack-notifier'
gem 'clearbit'
gem 'zendesk_api'
+gem 'viewpoint'
+gem 'rubyntlm', git: 'https://github.com/wimm/rubyntlm.git'
+gem 'autodiscover', git: 'https://github.com/thorsteneckel/autodiscover.git'
# event machine
gem 'eventmachine'
diff --git a/Gemfile.lock b/Gemfile.lock
index ffba171c8..19b5096f5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,19 @@
+GIT
+ remote: https://github.com/thorsteneckel/autodiscover.git
+ revision: 29d713ee0c8c25fcf74c4292ff13fe1fa4d0d827
+ specs:
+ autodiscover (1.0.2)
+ httpclient
+ logging
+ nokogiri
+ nori
+
+GIT
+ remote: https://github.com/wimm/rubyntlm.git
+ revision: 53969639b87b9e5d5fef560f19cf0d977259591c
+ specs:
+ rubyntlm (0.1.2)
+
GEM
remote: https://rubygems.org/
specs:
@@ -166,6 +182,7 @@ GEM
domain_name (~> 0.5)
http-form_data (1.0.3)
http_parser.rb (0.6.0)
+ httpclient (2.8.3)
i18n (0.8.6)
icalendar (2.4.1)
inflection (1.0.0)
@@ -181,6 +198,10 @@ GEM
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2)
+ little-plugger (1.1.4)
+ logging (2.2.2)
+ little-plugger (~> 1.1)
+ multi_json (~> 1.10)
loofah (2.0.3)
nokogiri (>= 1.5.9)
lumberjack (1.0.10)
@@ -205,6 +226,7 @@ GEM
netrc (0.11.0)
nokogiri (1.8.0)
mini_portile2 (~> 2.2.0)
+ nori (2.6.0)
notiffany (0.1.1)
nenv (~> 0.1)
shellany (~> 0.0)
@@ -408,6 +430,11 @@ GEM
valid_email2 (2.0.0)
activemodel (>= 3.2)
mail (~> 2.5)
+ viewpoint (1.1.0)
+ httpclient
+ logging
+ nokogiri
+ rubyntlm
webmock (3.0.1)
addressable (>= 2.3.6)
crack (>= 0.3.2)
@@ -428,6 +455,7 @@ DEPENDENCIES
activerecord-nulldb-adapter
activerecord-session_store
argon2
+ autodiscover!
autoprefixer-rails
biz
browser
@@ -479,6 +507,7 @@ DEPENDENCIES
rb-fsevent
rspec-rails
rubocop
+ rubyntlm!
sass-rails
selenium-webdriver
simple-rss
@@ -496,6 +525,7 @@ DEPENDENCIES
uglifier
unicorn
valid_email2
+ viewpoint
webmock
writeexcel
zendesk_api
diff --git a/app/assets/javascripts/app/controllers/_integration/exchange.coffee b/app/assets/javascripts/app/controllers/_integration/exchange.coffee
new file mode 100644
index 000000000..26aaef204
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/_integration/exchange.coffee
@@ -0,0 +1,522 @@
+class Index extends App.ControllerIntegrationBase
+ featureIntegration: 'exchange_integration'
+ featureName: 'Exchange'
+ featureConfig: 'exchange_config'
+ description: [
+ ['This service enables Zammad to connect with your Exchange server.']
+ ]
+ events:
+ 'change .js-switch input': 'switch'
+
+ render: =>
+ super
+ new Form(
+ el: @$('.js-form')
+ )
+
+ #new App.ImportJob(
+ # el: @$('.js-importJob')
+ # facility: 'exchange'
+ #)
+
+ new App.HttpLog(
+ el: @$('.js-log')
+ facility: 'exchange'
+ )
+
+ switch: =>
+ super
+ active = @$('.js-switch input').prop('checked')
+ if active
+ job_start = =>
+ @ajax(
+ id: 'jobs_config'
+ type: 'POST'
+ url: "#{@apiPath}/integration/exchange/job_start"
+ processData: true
+ success: (data, status, xhr) =>
+ @render(true)
+ )
+
+ App.Delay.set(
+ job_start,
+ 600,
+ 'job_start',
+ )
+
+class Form extends App.Controller
+ elements:
+ '.js-lastImport': 'lastImport'
+ '.js-wizard': 'wizardButton'
+ events:
+ 'click .js-wizard': 'startWizard'
+ 'click .js-start-sync': 'startSync'
+
+ constructor: ->
+ super
+ @render()
+ @lastResult()
+ @activeDryRun()
+
+ currentConfig: ->
+ App.Setting.get('exchange_config') || {}
+
+ setConfig: (value) =>
+ App.Setting.set('exchange_config', value, {notify: true})
+ @startSync()
+
+ render: (top = false) =>
+ @config = @currentConfig()
+
+ folders = []
+ if !_.isEmpty(@config.folders)
+ for folder_id in @config.folders
+ folders.push @config.wizardData.backend_folders[folder_id]
+
+ @html App.view('integration/exchange')(
+ config: @config,
+ folders: folders
+ )
+ if _.isEmpty(@config)
+ @$('.js-notConfigured').removeClass('hide')
+ @$('.js-summary').addClass('hide')
+ else
+ @$('.js-notConfigured').addClass('hide')
+ @$('.js-summary').removeClass('hide')
+
+ if top
+ a = =>
+ @scrollToIfNeeded($('.content.active .page-header'))
+ @delay(a, 500)
+
+ startSync: =>
+ @ajax(
+ id: 'jobs_config'
+ type: 'POST'
+ url: "#{@apiPath}/integration/exchange/job_start"
+ processData: true
+ success: (data, status, xhr) =>
+ @render(true)
+ @lastResult()
+ )
+
+ startWizard: (e) =>
+ e.preventDefault()
+ new ConnectionWizard(
+ container: @el.closest('.content')
+ config: @config
+ callback: (config) =>
+ @setConfig(config)
+ )
+
+ lastResult: =>
+ @ajax(
+ id: 'jobs_start_index'
+ type: 'GET'
+ url: "#{@apiPath}/integration/exchange/job_start"
+ processData: true
+ success: (job, status, xhr) =>
+ if !_.isEmpty(job)
+ if !@lastResultShowJob || @lastResultShowJob.updated_at != job.updated_at
+ @lastResultShowJob = job
+ @lastResultShow(job)
+ if job.finished_at
+ @wizardButton.attr('disabled', false)
+ else
+ @wizardButton.attr('disabled', true)
+ @delay(@lastResult, 5000)
+ )
+
+ lastResultShow: (job) =>
+ if _.isEmpty(job)
+ @lastImport.html('')
+ return
+ countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped + job.result.failed
+ if !job.result.roles
+ job.result.roles = {}
+ for role_id, statistic of job.result.role_ids
+ role = App.Role.find(role_id)
+ job.result.roles[role.displayName()] = statistic
+ el = $(App.view('integration/exchange_last_import')(job: job, countDone: countDone))
+ @lastImport.html(el)
+
+ activeDryRun: =>
+ @ajax(
+ id: 'jobs_try_index'
+ type: 'GET'
+ url: "#{@apiPath}/integration/exchange/job_try"
+ data:
+ finished: false
+ processData: true
+ success: (job, status, xhr) =>
+ return if _.isEmpty(job)
+
+ # show analyzing
+ new ConnectionWizard(
+ container: @el.closest('.content')
+ config: job.payload
+ start: 'tryLoop'
+ callback: (config) =>
+ @wizardButton.attr('disabled', false)
+ @setConfig(config)
+ )
+ @wizardButton.attr('disabled', true)
+ )
+
+class State
+ @current: ->
+ App.Setting.get('exchange_integration')
+
+class ConnectionWizard extends App.WizardModal
+ wizardConfig: {}
+ slideMethod:
+ 'js-folders': 'foldersShow'
+ 'js-mapping': 'mappingShow'
+
+ events:
+ 'submit form.js-discover': 'discover'
+ 'submit form.js-bind': 'folders'
+ 'submit form.js-folders': 'mapping'
+ 'click .js-mapping .js-submitTry': 'mappingChange'
+ 'click .js-try .js-submitSave': 'save'
+ 'click .js-close': 'hide'
+ 'click .js-remove': 'removeRow'
+ 'click .js-userMappingForm .js-add': 'addUserMapping'
+ 'click .js-goToSlide': 'goToSlide'
+
+ elements:
+ '.modal-body': 'body'
+ '.js-foldersSelect': 'foldersSelect'
+ '.js-userMappingForm': 'userMappingForm'
+ '.js-expertForm': 'expertForm'
+
+ constructor: ->
+ super
+
+ if !_.isEmpty(@config)
+ @wizardConfig = @config
+
+ if @container
+ @el.addClass('modal--local')
+
+ @render()
+
+ @el.modal
+ keyboard: true
+ show: true
+ backdrop: true
+ container: @container
+ .on
+ 'show.bs.modal': @onShow
+ 'shown.bs.modal': @onShown
+ 'hidden.bs.modal': =>
+ @el.remove()
+
+ if @slide
+ @showSlide(@slide)
+ else
+ @showDiscoverDetails()
+
+ if @start
+ @[@start]()
+
+ render: =>
+ @html App.view('integration/exchange_wizard')()
+
+ save: (e) =>
+ e.preventDefault()
+ @callback(@wizardConfig)
+ @hide(e)
+
+ showSlide: (slide) =>
+ method = @slideMethod[slide]
+ if method && @[method]
+ @[method](true)
+ super
+
+ showDiscoverDetails: =>
+ @$('.js-discover input[name="user"]').val(@wizardConfig.user)
+ @$('.js-discover input[name="password"]').val(@wizardConfig.password)
+
+ showBindDetails: =>
+ @$('.js-bind input[name="endpoint"]').val(@wizardConfig.endpoint)
+ @$('.js-bind input[name="user"]').val(@wizardConfig.user)
+ @$('.js-bind input[name="password"]').val(@wizardConfig.password)
+
+ discover: (e) =>
+ e.preventDefault()
+ @showSlide('js-connect')
+ params = @formParam(e.target)
+ @ajax(
+ id: 'exchange_discover'
+ type: 'POST'
+ url: "#{@apiPath}/integration/exchange/autodiscover"
+ data: JSON.stringify(params)
+ processData: true
+ success: (data, status, xhr) =>
+ if data.result isnt 'ok'
+ @showSlide('js-discover')
+ @showAlert('js-discover', data.message)
+ return
+
+ @wizardConfig.endpoint = data.endpoint
+ @wizardConfig.user = params.user
+ @wizardConfig.password = params.password
+
+ @showSlide('js-bind')
+ @showBindDetails()
+
+ error: (xhr, statusText, error) =>
+ detailsRaw = xhr.responseText
+ details = {}
+ if !_.isEmpty(detailsRaw)
+ details = JSON.parse(detailsRaw)
+ @showSlide('js-discover')
+ @showAlert('js-discover', details.error || 'Unable to perform backend.')
+ )
+
+ folders: (e) =>
+ e.preventDefault()
+ @showSlide('js-analyze')
+ params = @formParam(e.target)
+ @ajax(
+ id: 'exchange_folders'
+ type: 'POST'
+ url: "#{@apiPath}/integration/exchange/folders"
+ data: JSON.stringify(params)
+ processData: true
+ success: (data, status, xhr) =>
+ if data.result isnt 'ok'
+ @showSlide('js-bind')
+ @showAlert('js-bind', data.message)
+ return
+
+ @wizardConfig.endpoint = params.endpoint
+ @wizardConfig.user = params.user
+ @wizardConfig.password = params.password
+
+ # update wizard data
+ @wizardConfig.wizardData = {}
+ @wizardConfig.wizardData.backend_folders = data.folders
+
+ @foldersShow()
+
+ error: (xhr, statusText, error) =>
+ detailsRaw = xhr.responseText
+ details = {}
+ if !_.isEmpty(detailsRaw)
+ details = JSON.parse(detailsRaw)
+ @showSlide('js-bind')
+ @showAlert('js-bind', details.error || 'Unable to perform backend.')
+ )
+
+ foldersShow: (alreadyShown) =>
+ @showSlide('js-folders') if !alreadyShown
+ @foldersSelect.html(@createColumnSelection('folders', @wizardConfig.wizardData.backend_folders, @wizardConfig.folders))
+
+ createColumnSelection: (name, options, selected) ->
+ return App.UiElement.column_select.render(
+ name: name
+ null: false
+ nulloption: false
+ options: options
+ value: selected
+ )
+
+ mapping: (e) =>
+ e.preventDefault()
+ @showSlide('js-analyze')
+ params = @formParam(e.target)
+
+ # folders might be a single selection so we
+ # have to ensure that is an Array so the
+ # backend and frontend can handle it properly
+ if typeof params.folders is 'string'
+ params.folders = [ params.folders ]
+
+ # add login params
+ params.endpoint = @wizardConfig.endpoint
+ params.user = @wizardConfig.user
+ params.password = @wizardConfig.password
+
+ @ajax(
+ id: 'exchange_mapping'
+ type: 'POST'
+ url: "#{@apiPath}/integration/exchange/mapping"
+ data: JSON.stringify(params)
+ processData: true
+ success: (data, status, xhr) =>
+ if data.result isnt 'ok'
+ @showSlide('js-folders')
+ @showAlert('js-folders', data.message)
+ return
+
+ attributes = {}
+ for key, value of App.User.attributesGet()
+ continue if key == 'login'
+ if (value.tag is 'input' || value.tag is 'richtext' || value.tag is 'textarea') && value.type isnt 'password'
+ attributes[key] = value.display || key
+
+ @wizardConfig.wizardData.attributes = attributes
+ @wizardConfig.folders = params.folders
+ @wizardConfig.wizardData.backend_attributes = data.attributes
+
+ @mappingShow()
+
+ error: (xhr, statusText, error) =>
+ detailsRaw = xhr.responseText
+ details = {}
+ if !_.isEmpty(detailsRaw)
+ details = JSON.parse(detailsRaw)
+ @showSlide('js-folders')
+ @showAlert('js-folders', details.error || 'Unable to perform backend.')
+ )
+
+ mappingShow: (alreadyShown) =>
+ @showSlide('js-mapping') if !alreadyShown
+ user_attribute_map = @wizardConfig.attributes
+
+ if _.isEmpty(user_attribute_map)
+ user_attribute_map =
+ given_name: 'firstname'
+ surname: 'lastname'
+ 'email_addresses.emailaddress1': 'email'
+ 'phone_numbers.businessphone': 'phone'
+
+ @userMappingForm.find('tbody tr.js-entry').remove()
+ @userMappingForm.find('tbody tr').before(@buildRowsUserMap(user_attribute_map))
+
+ mappingChange: (e) =>
+ e.preventDefault()
+
+ # user map
+ attributes = @formParam(@userMappingForm)
+ for key in ['source', 'dest']
+ if !_.isArray(attributes[key])
+ attributes[key] = [attributes[key]]
+ attributes_local =
+ item_id: 'login'
+ length = attributes.source.length-1
+ for count in [0..length]
+ if attributes.source[count] && attributes.dest[count]
+ attributes_local[attributes.source[count]] = attributes.dest[count]
+ @wizardConfig.attributes = attributes_local
+
+ @tryShow()
+
+ buildRowsUserMap: (user_attribute_map) =>
+
+ # show static login row
+ userUidDisplayValue = @wizardConfig.wizardData.backend_attributes['item_id']
+ el = [
+ $(App.view('integration/ldap_user_attribute_row_read_only')(
+ key: userUidDisplayValue,
+ value: 'Login'
+ ))
+ ]
+
+ for source, dest of user_attribute_map
+ continue if source == 'item_id'
+ continue if !(source of @wizardConfig.wizardData.backend_attributes)
+ el.push @buildRowUserAttribute(source, dest)
+ el
+
+ buildRowUserAttribute: (source, dest) =>
+ el = $(App.view('integration/exchange_user_attribute_row')())
+ el.find('.js-exchangeAttribute').html(@createSelection('source', @wizardConfig.wizardData.backend_attributes, source))
+ el.find('.js-userAttribute').html(@createSelection('dest', @wizardConfig.wizardData.attributes, dest))
+ el
+
+ createSelection: (name, options, selected, unknown) ->
+ return App.UiElement.searchable_select.render(
+ name: name
+ multiple: false
+ limit: 100
+ null: false
+ nulloption: false
+ options: options
+ value: selected
+ unknown: unknown
+ class: 'form-control--small'
+ )
+
+ removeRow: (e) ->
+ e.preventDefault()
+ $(e.target).closest('tr').remove()
+
+ addUserMapping: (e) =>
+ e.preventDefault()
+ @userMappingForm.find('tbody tr').last().before(@buildRowUserAttribute())
+
+ tryShow: (e) =>
+ if e
+ e.preventDefault()
+ @showSlide('js-analyze')
+
+ # create import job
+ @ajax(
+ id: 'exchange_try'
+ type: 'POST'
+ url: "#{@apiPath}/integration/exchange/job_try"
+ data: JSON.stringify(@wizardConfig)
+ processData: true
+ success: (data, status, xhr) =>
+ @tryLoop()
+ )
+
+ tryLoop: =>
+ @showSlide('js-dry')
+ @ajax(
+ id: 'jobs_try_index'
+ type: 'GET'
+ url: "#{@apiPath}/integration/exchange/job_try"
+ data:
+ finished: true
+ processData: true
+ success: (job, status, xhr) =>
+ if job.result && (job.result.error || job.result.info)
+ @showSlide('js-error')
+ @showAlert('js-error', (job.result.error || job.result.info))
+ return
+
+ total = 0
+ if job.result && _.keys(job.result).length > 0
+ @$('.js-preprogress').addClass('hide')
+ @$('.js-analyzing').removeClass('hide')
+
+ analized = 0
+ total = job.result.sum
+ for action, sum of job.result
+ continue if action == 'folders'
+ continue if action == 'sum'
+ analized += sum
+
+ @$('.js-progress progress').attr('value', analized)
+ @$('.js-progress progress').attr('max', total)
+
+ if job.finished_at
+ # reset initial state in case the back button is used
+ @$('.js-preprogress').removeClass('hide')
+ @$('.js-analyzing').addClass('hide')
+
+ @tryResult(job, total)
+ else
+ @delay(@tryLoop, 4000)
+ )
+
+ tryResult: (job, total) =>
+ @showSlide('js-try')
+ el = $(App.view('integration/exchange_summary')(job: job, countDone: total))
+ @el.find('.js-summary').html(el)
+
+App.Config.set(
+ 'IntegrationExchange'
+ {
+ name: 'Exchange'
+ target: '#system/integration/exchange'
+ description: 'Exchange integration for contacts management.'
+ controller: Index
+ state: State
+ }
+ 'NavBarIntegrations'
+)
diff --git a/app/assets/javascripts/app/controllers/_integration/ldap.coffee b/app/assets/javascripts/app/controllers/_integration/ldap.coffee
index dc0bbbdef..83a61dc98 100644
--- a/app/assets/javascripts/app/controllers/_integration/ldap.coffee
+++ b/app/assets/javascripts/app/controllers/_integration/ldap.coffee
@@ -28,13 +28,20 @@ class Index extends App.ControllerIntegrationBase
super
active = @$('.js-switch input').prop('checked')
if active
- @ajax(
- id: 'jobs_config'
- type: 'POST'
- url: "#{@apiPath}/integration/ldap/job_start"
- processData: true
- success: (data, status, xhr) =>
- @render(true)
+ job_start = =>
+ @ajax(
+ id: 'jobs_config'
+ type: 'POST'
+ url: "#{@apiPath}/integration/ldap/job_start"
+ processData: true
+ success: (data, status, xhr) =>
+ @render(true)
+ )
+
+ App.Delay.set(
+ job_start,
+ 600,
+ 'job_start',
)
class Form extends App.Controller
@@ -91,6 +98,7 @@ class Form extends App.Controller
processData: true
success: (data, status, xhr) =>
@render(true)
+ @lastResult()
)
startWizard: (e) =>
diff --git a/app/assets/javascripts/app/views/integration/exchange.jst.eco b/app/assets/javascripts/app/views/integration/exchange.jst.eco
new file mode 100644
index 000000000..69385e1cf
--- /dev/null
+++ b/app/assets/javascripts/app/views/integration/exchange.jst.eco
@@ -0,0 +1,71 @@
+
+
+ |
+ |
+
+ <%- @Icon('trash') %> <%- @T('Remove') %>
+
diff --git a/app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco b/app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco
new file mode 100644
index 000000000..25cdbcade
--- /dev/null
+++ b/app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco
@@ -0,0 +1,258 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%- @T('User') %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%- @T('With your current configuration the following will happen') %>:
+
+
+
+
+
+
+
+
+
diff --git a/app/controllers/concerns/integration/import_job_base.rb b/app/controllers/concerns/integration/import_job_base.rb
new file mode 100644
index 000000000..493351ec7
--- /dev/null
+++ b/app/controllers/concerns/integration/import_job_base.rb
@@ -0,0 +1,89 @@
+module Integration::ImportJobBase
+ extend ActiveSupport::Concern
+
+ def job_try_index
+ job_index(
+ dry_run: true,
+ take_finished: params[:finished] == 'true'
+ )
+ end
+
+ def job_try_create
+ ImportJob.dry_run(name: import_backend_namespace, payload: payload_dry_run)
+ render json: {
+ result: 'ok',
+ }
+ end
+
+ def job_start_index
+ job_index(dry_run: false)
+ end
+
+ def job_start_create
+ if !ImportJob.exists?(name: import_backend_namespace, finished_at: nil)
+ job = ImportJob.create(name: import_backend_namespace, payload: payload_import)
+ job.delay.start
+ end
+ render json: {
+ result: 'ok',
+ }
+ end
+
+ def payload_dry_run
+ params
+ end
+
+ def payload_import
+ import_setting
+ end
+
+ private
+
+ def answer_with
+ result = yield
+ render json: result.merge(result: 'ok')
+ rescue => e
+ logger.error(e)
+ render json: {
+ result: 'failed',
+ message: e.message,
+ }
+ end
+
+ def import_setting
+ Setting.get(import_setting_name)
+ end
+
+ def import_setting_name
+ "#{import_backend_name.downcase}_config"
+ end
+
+ def import_backend_namespace
+ "Import::#{import_backend_name}"
+ end
+
+ def import_backend_name
+ self.class.name.split('::').last.sub('Controller', '')
+ end
+
+ def job_index(dry_run:, take_finished: true)
+ job = ImportJob.find_by(
+ name: import_backend_namespace,
+ dry_run: dry_run,
+ finished_at: nil
+ )
+ if !job && take_finished
+ job = ImportJob.where(
+ name: import_backend_namespace,
+ dry_run: dry_run
+ ).order(created_at: :desc).limit(1).first
+ end
+
+ if job
+ model_show_render_item(job)
+ else
+ render json: {}
+ end
+ end
+
+end
diff --git a/app/controllers/integration/exchange_controller.rb b/app/controllers/integration/exchange_controller.rb
new file mode 100644
index 000000000..66e4ca30d
--- /dev/null
+++ b/app/controllers/integration/exchange_controller.rb
@@ -0,0 +1,66 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+class Integration::ExchangeController < ApplicationController
+ include Integration::ImportJobBase
+
+ prepend_before_action { authentication_check(permission: 'admin.integration.exchange') }
+
+ def autodiscover
+ answer_with do
+ client = Autodiscover::Client.new(
+ email: params[:user],
+ password: params[:password],
+ )
+
+ {
+ endpoint: client.autodiscover.ews_url,
+ }
+ end
+ end
+
+ def folders
+ answer_with do
+ Sequencer.process('Import::Exchange::AvailableFolders',
+ parameters: {
+ ews_config: {
+ endpoint: params[:endpoint],
+ user: params[:user],
+ password: params[:password],
+ }
+ })
+ end
+ end
+
+ def mapping
+ answer_with do
+ Sequencer.process('Import::Exchange::AttributesExamples',
+ parameters: {
+ ews_folder_ids: params[:folders],
+ ews_config: {
+ endpoint: params[:endpoint],
+ user: params[:user],
+ password: params[:password],
+ }
+ })
+ end
+ end
+
+ private
+
+ # currently a workaround till LDAP is migrated to Sequencer
+ def payload_dry_run
+ {
+ ews_attributes: params[:attributes],
+ ews_folder_ids: params[:folders],
+ ews_config: {
+ endpoint: params[:endpoint],
+ user: params[:user],
+ password: params[:password],
+ }
+ }
+ end
+
+ def payload_import
+ nil
+ end
+end
diff --git a/app/controllers/integration/ldap_controller.rb b/app/controllers/integration/ldap_controller.rb
index 16d2393fb..cbf30654c 100644
--- a/app/controllers/integration/ldap_controller.rb
+++ b/app/controllers/integration/ldap_controller.rb
@@ -4,6 +4,8 @@ require 'ldap/user'
require 'ldap/group'
class Integration::LdapController < ApplicationController
+ include Integration::ImportJobBase
+
prepend_before_action { authentication_check(permission: 'admin.integration.ldap') }
def discover
@@ -60,48 +62,4 @@ class Integration::LdapController < ApplicationController
message: e.message,
}
end
-
- def job_try_index
- job_index(
- dry_run: true,
- take_finished: params[:finished] == 'true'
- )
- end
-
- def job_try_create
- ImportJob.dry_run(name: 'Import::Ldap', payload: params)
- render json: {
- result: 'ok',
- }
- end
-
- def job_start_index
- job_index(dry_run: false)
- end
-
- def job_start_create
- backend = 'Import::Ldap'
- if !ImportJob.exists?(name: backend, finished_at: nil)
- job = ImportJob.create(name: backend, payload: Setting.get('ldap_config'))
- job.delay.start
- end
- render json: {
- result: 'ok',
- }
- end
-
- private
-
- def job_index(dry_run:, take_finished: true)
- job = ImportJob.find_by(name: 'Import::Ldap', dry_run: dry_run, finished_at: nil)
- if !job && take_finished
- job = ImportJob.where(name: 'Import::Ldap', dry_run: dry_run).order(created_at: :desc).limit(1).first
- end
-
- if job
- model_show_render_item(job)
- else
- render json: {}
- end
- end
end
diff --git a/config/routes/integration_exchange.rb b/config/routes/integration_exchange.rb
new file mode 100644
index 000000000..0c84a90f8
--- /dev/null
+++ b/config/routes/integration_exchange.rb
@@ -0,0 +1,11 @@
+Zammad::Application.routes.draw do
+ api_path = Rails.configuration.api_path
+
+ match api_path + '/integration/exchange/autodiscover', to: 'integration/exchange#autodiscover', via: :post
+ match api_path + '/integration/exchange/folders', to: 'integration/exchange#folders', via: :post
+ match api_path + '/integration/exchange/mapping', to: 'integration/exchange#mapping', via: :post
+ match api_path + '/integration/exchange/job_try', to: 'integration/exchange#job_try_index', via: :get
+ match api_path + '/integration/exchange/job_try', to: 'integration/exchange#job_try_create', via: :post
+ match api_path + '/integration/exchange/job_start', to: 'integration/exchange#job_start_index', via: :get
+ match api_path + '/integration/exchange/job_start', to: 'integration/exchange#job_start_create', via: :post
+end
diff --git a/db/migrate/20170629000001_exchange_integration.rb b/db/migrate/20170629000001_exchange_integration.rb
new file mode 100644
index 000000000..88447f2c6
--- /dev/null
+++ b/db/migrate/20170629000001_exchange_integration.rb
@@ -0,0 +1,51 @@
+class ExchangeIntegration < ActiveRecord::Migration
+ def up
+
+ # return if it's a new setup
+ return if !Setting.find_by(name: 'system_init_done')
+
+ Setting.set('import_backends', ['Import::Ldap', 'Import::Exchange'])
+
+ Setting.create_if_not_exists(
+ title: 'Exchange config',
+ name: 'exchange_config',
+ area: 'Integration::Exchange',
+ description: 'Defines the Exchange config.',
+ options: {},
+ state: {},
+ preferences: {
+ prio: 2,
+ permission: ['admin.integration'],
+ },
+ frontend: false,
+ )
+ Setting.create_if_not_exists(
+ title: 'Exchange integration',
+ name: 'exchange_integration',
+ area: 'Integration::Switch',
+ description: 'Defines if Exchange is enabled or not.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'exchange_integration',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: false,
+ preferences: {
+ prio: 1,
+ authentication: true,
+ permission: ['admin.integration'],
+ },
+ frontend: true
+ )
+ end
+
+end
diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb
index 9181600fa..628516ab6 100644
--- a/db/seeds/settings.rb
+++ b/db/seeds/settings.rb
@@ -2401,7 +2401,7 @@ Setting.create_if_not_exists(
area: 'Import',
description: 'A list of active import backends that get scheduled automatically.',
options: {},
- state: ['Import::Ldap'],
+ state: ['Import::Ldap', 'Import::Exchange'],
preferences: {
permission: ['admin'],
},
@@ -2874,6 +2874,46 @@ Setting.create_if_not_exists(
},
frontend: true
)
+Setting.create_if_not_exists(
+ title: 'Exchange config',
+ name: 'exchange_config',
+ area: 'Integration::Exchange',
+ description: 'Defines the Exchange config.',
+ options: {},
+ state: {},
+ preferences: {
+ prio: 2,
+ permission: ['admin.integration'],
+ },
+ frontend: false,
+)
+Setting.create_if_not_exists(
+ title: 'Exchange integration',
+ name: 'exchange_integration',
+ area: 'Integration::Switch',
+ description: 'Defines if Exchange is enabled or not.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'exchange_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: 'LDAP config',
name: 'ldap_config',
diff --git a/lib/import/exchange.rb b/lib/import/exchange.rb
new file mode 100644
index 000000000..cd8d307cb
--- /dev/null
+++ b/lib/import/exchange.rb
@@ -0,0 +1,13 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+module Import
+ class Exchange < Import::IntegrationBase
+ include Import::Mixin::Sequence
+
+ private
+
+ def sequence_name
+ 'Import::Exchange::FolderContacts'
+ end
+ end
+end
diff --git a/lib/import/exchange/folder.rb b/lib/import/exchange/folder.rb
new file mode 100644
index 000000000..7a4559f0a
--- /dev/null
+++ b/lib/import/exchange/folder.rb
@@ -0,0 +1,76 @@
+require 'mixin/rails_logger'
+
+module Import
+ class Exchange
+ class Folder
+ include ::Mixin::RailsLogger
+
+ def initialize(connection)
+ @connection = connection
+ @lookup_map = {}
+ end
+
+ def id_folder_map
+ @id_folder_map ||= all.collect do |folder|
+ [folder.id, folder]
+ end.to_h
+
+ # duplicate object to avoid errors where keys get
+ # added via #get_folder while iterating over
+ # the result of this method
+ @lookup_map = @id_folder_map.dup
+ @id_folder_map
+ end
+
+ def find(id)
+ @lookup_map[id] ||= @connection.get_folder(id)
+ end
+
+ def all
+ # request folders only if neccessary and store the result
+ @all ||= children(%i(root msgfolderroot publicfoldersroot))
+ end
+
+ def children(parent_identifiers)
+ parent_identifiers.each_with_object([]) do |parent_identifier, result|
+
+ child_folders = request_children(parent_identifier)
+
+ next if child_folders.blank?
+
+ child_folder_ids = child_folders.collect(&:id)
+ child_folders += children(child_folder_ids)
+
+ result.concat(child_folders)
+ end
+ end
+
+ def display_path(folder)
+ display_name = folder.display_name
+ return display_name if !folder.parent_folder_id
+
+ parent_folder = find(folder.parent_folder_id)
+ return display_name if !parent_folder
+
+ parent_folder = id_folder_map[folder.parent_folder_id]
+ return display_name if !parent_folder
+
+ # recursive
+ parent_folder_path = display_path(parent_folder)
+
+ "#{parent_folder_path} -> #{display_name}"
+ rescue Viewpoint::EWS::EwsError
+ folder.display_name
+ end
+
+ private
+
+ def request_children(parent_identifier)
+ @connection.folders(root: parent_identifier)
+ rescue Viewpoint::EWS::EwsFolderNotFound => e
+ logger.warn(e)
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/import/exchange/item_attributes.rb b/lib/import/exchange/item_attributes.rb
new file mode 100644
index 000000000..bc0d19ec0
--- /dev/null
+++ b/lib/import/exchange/item_attributes.rb
@@ -0,0 +1,111 @@
+module Import
+ class Exchange
+ class ItemAttributes
+
+ def self.extract(resource)
+ new(resource).extract
+ end
+
+ def initialize(resource)
+ @resource = resource
+ end
+
+ def extract
+ @attributes ||= begin
+ properties = @resource.get_all_properties!
+ result = normalize(properties)
+ flattened = flatten(result)
+ booleanized = booleanize_values(flattened)
+ end
+ end
+
+ private
+
+ def booleanize_values(properties)
+ properties.each do |key, value|
+ if value.is_a?(String)
+ next if !%w(true false).include?(value)
+ properties[key] = value == 'true'
+ elsif value.is_a?(Hash)
+ properties[key] = booleanize_values(value)
+ end
+ end
+ end
+
+ def normalize(properties)
+ result = {}
+ properties.each do |key, value|
+
+ next if key == :body
+
+ if value[:text]
+ result[key] = value[:text]
+ elsif value[:attribs]
+ result[key] = value[:attribs]
+ elsif value[:elems]
+ result[key] = sub_elems(value[:elems])
+ end
+ end
+
+ result
+ end
+
+ def sub_elems(elems)
+ result = {}
+ elems.each do |elem|
+ if elem[:entry]
+ result.merge!( sub_elem_entry( elem[:entry] ) )
+ else
+ result.merge!( normalize(elem) )
+ end
+ end
+ result
+ end
+
+ def sub_elem_entry(entry)
+ entry_value = {}
+ if entry[:elems]
+ entry_value = sub_elems(entry[:elems])
+ end
+
+ if entry[:text]
+ entry_value[:text] = entry[:text]
+ end
+
+ if entry[:attribs].present?
+ entry_value.merge!(entry[:attribs])
+ end
+
+ entry_key = entry_value.delete(:key)
+ {
+ entry_key => entry_value
+ }
+ end
+
+ def flatten(properties, prefix: nil)
+
+ result = {}
+ properties.each do |key, value|
+
+ result_key = key
+ if prefix
+ result_key = if %i(text id).include?(key) && ( !result[result_key] || result[result_key] == value )
+ prefix
+ else
+ "#{prefix}.#{key}".to_sym
+ end
+ end
+ result_key = result_key.to_s.downcase
+
+ if value.is_a?(Hash)
+ sub_result = flatten(value, prefix: result_key)
+ result.merge!(sub_result)
+ else
+ result[result_key] = value.to_s
+ end
+ end
+ result
+ end
+ end
+ end
+end
diff --git a/lib/import/helper/attributes_examples.rb b/lib/import/helper/attributes_examples.rb
new file mode 100644
index 000000000..1ae3c00a9
--- /dev/null
+++ b/lib/import/helper/attributes_examples.rb
@@ -0,0 +1,70 @@
+module Import
+ module Helper
+ class AttributesExamples
+ attr_reader :examples, :enough, :max_unkown
+
+ def initialize(&block)
+ @max_unkown = 50
+ @no_new_counter = 1
+ @examples = {}
+ @known = []
+
+ # Support both builder styles:
+ #
+ # Import::Helper::AttributesExamples.new do
+ # extract(attributes)
+ # end
+ #
+ # and
+ #
+ # Import::Helper::AttributesExamples.new do |extractor|
+ # extractor.extract(attributes)
+ # end
+ return if !block_given?
+ if block.arity.zero?
+ instance_eval(&block)
+ else
+ yield self
+ end
+ end
+
+ def extract(attributes)
+ unknown = attributes.keys - @known
+
+ return if !unknown?(unknown)
+
+ store(attributes, unknown)
+
+ @known.concat(unknown)
+ @no_new_counter = 0
+ end
+
+ private
+
+ def unknown?(unknown)
+ return true if unknown.present?
+
+ @no_new_counter += 1
+
+ # check max 50 entries with no or no new attributes in a row
+ @enough_examples = @no_new_counter != 50
+
+ false
+ end
+
+ def store(attributes, unknown)
+ unknown.each do |attribute|
+ value = attributes[attribute]
+
+ next if value.nil?
+
+ example = value.to_s.force_encoding('UTF-8').encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?')
+ example.gsub!(/^(.{20,}?).*$/m, '\1...')
+
+ @examples[attribute] = "#{attribute} (e. g. #{example})"
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/import/integration_base.rb b/lib/import/integration_base.rb
new file mode 100644
index 000000000..ac995f9dd
--- /dev/null
+++ b/lib/import/integration_base.rb
@@ -0,0 +1,143 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+module Import
+
+ # This base class handles regular integrations.
+ # It provides generic interfaces for settings and active state.
+ # It ensures that all requirements for a regular integration are met before a import can start.
+ # It handles the case of an Scheduler interruption.
+ #
+ # It's required to implement the +start_import+ method which only has to start the import.
+ class IntegrationBase < Import::Base
+
+ def self.inherited(subclass)
+ subclass.extend(Forwardable)
+
+ # delegate instance methods to the generic class implementations
+ subclass.delegate [:identifier, :active?, :config, :display_name] => subclass
+ end
+
+ # Defines the integration identifier used for
+ # automatic config lookup and error message generation.
+ #
+ # @example
+ # Import::Ldap.identifier
+ # #=> "Ldap"
+ #
+ # return [String]
+ def self.identifier
+ name.split('::').last
+ end
+
+ # Provides the name that is used in texts visible to the user.
+ #
+ # @example
+ # Import::Exchange.display_name
+ # #=> "Exchange"
+ #
+ # return [String]
+ def self.display_name
+ identifier
+ end
+
+ # Checks if the integration is active.
+ #
+ # @example
+ # Import::Ldap.active?
+ # #=> true
+ #
+ # return [Boolean]
+ def self.active?
+ Setting.get("#{identifier.downcase}_integration") || false
+ end
+
+ # Provides the integration configuration.
+ #
+ # @example
+ # Import::Ldap.config
+ # #=> {"ssl_verify"=>true, "host_url"=>"ldaps://192...", ...}
+ #
+ # return [Hash] the configuration
+ def self.config
+ Setting.get("#{identifier.downcase}_config") || {}
+ end
+
+ # Stores the integration configuration.
+ #
+ # @example
+ # Import::Ldap.config = {"ssl_verify"=>true, "host_url"=>"ldaps://192...", ...}
+ #
+ # return [nil]
+ def self.config=(value)
+ Setting.set("#{identifier.downcase}_config", value)
+ end
+
+ # Checks if the integration is activated and configured.
+ # Otherwise it won't get queued since it will display
+ # an error which is confusing and wrong.
+ #
+ # @example
+ # Import::Ldap.queueable?
+ # #=> true
+ #
+ # return [Boolean]
+ def self.queueable?
+ active? && config.present?
+ end
+
+ # Starts a live or dry run import.
+ #
+ # @example
+ # instance = Import::Ldap.new(import_job)
+ #
+ # @raise [RuntimeError] Raised if an import should start but the integration is disabled
+ #
+ # return [nil]
+ def start
+ return if !requirements_completed?
+ start_import
+ end
+
+ # Gets called when the Scheduler gets (re-)started and an ImportJob was still
+ # in the queue. The job will always get restarted to avoid the gap till the next
+ # run triggered by the Scheduler. The result will get updated to inform the user
+ # in the agent interface result view.
+ #
+ # @example
+ # instance = Import::Ldap.new(import_job)
+ # instance.reschedule?(delayed_job)
+ # #=> true
+ #
+ # return [true]
+ def reschedule?(_delayed_job)
+ inform('Restarting due to scheduler restart.')
+ true
+ end
+
+ private
+
+ def start_import
+ raise "Missing implementation of method '#{__method__}' for #{self.class.name}"
+ end
+
+ def requirements_completed?
+ return true if @import_job.dry_run
+
+ if !active?
+ message = "Sync cancelled. #{display_name} integration deactivated. Activate via the switch."
+ elsif config.blank? && @import_job.payload.blank?
+ message = "Sync cancelled. #{display_name} configration or ImportJob payload missing."
+ end
+
+ return true if !message
+ inform(message)
+ false
+ end
+
+ def inform(message)
+ @import_job.update_attribute(:result, {
+ info: message
+ })
+ end
+ end
+end
diff --git a/lib/import/ldap.rb b/lib/import/ldap.rb
index 5b82e60a4..882981bb6 100644
--- a/lib/import/ldap.rb
+++ b/lib/import/ldap.rb
@@ -4,50 +4,17 @@ require 'ldap'
require 'ldap/group'
module Import
- class Ldap < Import::Base
+ class Ldap < Import::IntegrationBase
- # Checks if the integration is activated and configured.
- # Otherwise it won't get queued since it will display
- # an error which is confusing and wrong.
+ # Provides the name that is used in texts visible to the user.
#
# @example
- # Import::LDAP.queueable?
- # #=> true
+ # Import::Ldap.display_name
+ # #=> "LDAP"
#
- # return [Boolean]
- def self.queueable?
- Setting.get('ldap_integration') && Setting.get('ldap_config').present?
- end
-
- # Starts a live or dry run LDAP import.
- #
- # @example
- # instance = Import::LDAP.new(import_job)
- #
- # @raise [RuntimeError] Raised if an import should start but the ldap integration is disabled
- #
- # return [nil]
- def start
- return if !requirements_completed?
- start_import
- end
-
- # Gets called when the Scheduler gets (re-)started and a LDAP ImportJob was still
- # in the queue. The job will always get restarted to avoid the gap till the next
- # run triggered by the Scheduler. The result will get updated to inform the user
- # in the agent interface result view.
- #
- # @example
- # instance = Import::LDAP.new(import_job)
- # instance.reschedule?(delayed_job)
- # #=> true
- #
- # return [true]
- def reschedule?(_delayed_job)
- @import_job.update_attribute(:result, {
- info: 'Restarting due to scheduler restart.'
- })
- true
+ # return [String]
+ def self.display_name
+ identifier.upcase
end
private
@@ -63,23 +30,5 @@ module Import
@import_job.result = Import::Ldap::UserFactory.statistics
end
-
- def requirements_completed?
- return true if @import_job.dry_run
-
- if !Setting.get('ldap_integration')
- message = 'Sync cancelled. LDAP integration deactivated. Activate via the switch.'
- elsif Setting.get('ldap_config').blank? && @import_job.payload.blank?
- message = 'Sync cancelled. LDAP configration or ImportJob payload missing.'
- end
-
- return true if !message
-
- @import_job.update_attribute(:result, {
- info: message
- })
-
- false
- end
end
end
diff --git a/lib/import/mixin/sequence.rb b/lib/import/mixin/sequence.rb
new file mode 100644
index 000000000..262933e44
--- /dev/null
+++ b/lib/import/mixin/sequence.rb
@@ -0,0 +1,19 @@
+module Import
+ module Mixin
+ module Sequence
+ private
+
+ def sequence_name
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+
+ def process
+ Sequencer.process(sequence_name,
+ parameters: {
+ import_job: @import_job
+ })
+ end
+ alias start_import process
+ end
+ end
+end
diff --git a/lib/mixin/instance_wrapper.rb b/lib/mixin/instance_wrapper.rb
new file mode 100644
index 000000000..96e8ff8e3
--- /dev/null
+++ b/lib/mixin/instance_wrapper.rb
@@ -0,0 +1,43 @@
+module Mixin
+ # This modules enables to redirect all calls to methods that are
+ # not defined to the declared instance variable. This comes handy
+ # when you wan't extend a Ruby core class like Hash.
+ # To inherit directly from such classes is a bad idea and should be avoided.
+ # This way allows it indirectly.
+ module InstanceWrapper
+ module ClassMethods
+ # Creates the class macro `wrap` that activates
+ # the wrapping for the given instance variable name.
+ #
+ # @param [Symbol] variable the name of the instance variable to wrap around
+ #
+ # @example
+ # wrap :@some_hash
+ #
+ # @return [nil]
+ def wrap(variable)
+ define_method(:instance) {
+ instance_variable_get(variable)
+ }
+ end
+ end
+
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ private
+
+ def method_missing(method, *args, &block)
+ if instance.respond_to?(method)
+ instance.send(method, *args, &block)
+ else
+ super
+ end
+ end
+
+ def respond_to_missing?(method_sym, include_all)
+ instance.respond_to?(method_sym, include_all)
+ end
+ end
+end
diff --git a/lib/mixin/rails_logger.rb b/lib/mixin/rails_logger.rb
new file mode 100644
index 000000000..e014f1d08
--- /dev/null
+++ b/lib/mixin/rails_logger.rb
@@ -0,0 +1,9 @@
+module Mixin
+ module RailsLogger
+ extend Forwardable
+ extend SingleForwardable
+
+ instance_delegate [:logger] => self
+ single_delegate [:logger] => :Rails
+ end
+end
diff --git a/lib/mixin/required_sub_paths.rb b/lib/mixin/required_sub_paths.rb
new file mode 100644
index 000000000..fa436749e
--- /dev/null
+++ b/lib/mixin/required_sub_paths.rb
@@ -0,0 +1,44 @@
+module Mixin
+ module RequiredSubPaths
+
+ def self.included(_base)
+ path = caller_locations.first.path
+ sub_path = File.join(File.dirname(path), File.basename(path, '.rb'))
+ eager_load_recursive(sub_path)
+ end
+
+ # Loads a directory recursivly.
+ # The specialty of this method is that it will first load all
+ # files in a directory and then start with the sub directories.
+ # This is needed since otherwise some parent namespaces might not
+ # be initialized yet.
+ #
+ # The cause of this is that Rails autoload doesn't work properly
+ # for same named classes or modules in different namespaces.
+ # Here is a good description how autoload works:
+ # http://urbanautomaton.com/blog/2013/08/27/rails-autoloading-hell/
+ #
+ # This avoids a) Rails autoloading issues and b) require '...' workarounds
+ def self.eager_load_recursive(path)
+
+ excluded = ['.', '..']
+ sub_paths = []
+ Dir.entries(path).each do |entry|
+ next if excluded.include?(entry)
+
+ sub_path = File.join(path, entry)
+
+ if File.directory?(sub_path)
+ sub_paths.push(sub_path)
+ elsif sub_path =~ /\A(.*)\.rb\z/
+ require_path = $1
+ require(require_path)
+ end
+ end
+
+ sub_paths.each do |sub_path|
+ eager_load_recursive(sub_path)
+ end
+ end
+ end
+end
diff --git a/lib/mixin/start_finish_logger.rb b/lib/mixin/start_finish_logger.rb
new file mode 100644
index 000000000..7bee862ac
--- /dev/null
+++ b/lib/mixin/start_finish_logger.rb
@@ -0,0 +1,11 @@
+module Mixin
+ module StartFinishLogger
+ include ::Mixin::RailsLogger
+
+ def log_start_finish(level, prefix)
+ logger.public_send(level, "#{prefix} started.")
+ yield
+ logger.public_send(level, "#{prefix} finished.")
+ end
+ end
+end
diff --git a/lib/sequencer.rb b/lib/sequencer.rb
new file mode 100644
index 000000000..7a606eed1
--- /dev/null
+++ b/lib/sequencer.rb
@@ -0,0 +1,83 @@
+require 'mixin/rails_logger'
+require 'mixin/start_finish_logger'
+
+class Sequencer
+ include ::Mixin::RailsLogger
+ include ::Mixin::StartFinishLogger
+
+ attr_reader :sequence
+
+ # Convenience wrapper for instant processing with the given attributes.
+ #
+ # @example
+ # Sequencer.process('Example::Sequence')
+ #
+ # @example
+ # Sequencer.process('Example::Sequence',
+ # parameters: {
+ # some: 'value',
+ # },
+ # expecting: [:result, :answer]
+ # )
+ #
+ # @return [Hash{Symbol => Object}] the final result state attributes and values
+ def self.process(sequence, *args)
+ new(sequence, *args).process
+ end
+
+ # Initializes a new Sequencer instance for the given Sequence with parameters and expecting result.
+ #
+ # @example
+ # Sequencer.new('Example::Sequence')
+ #
+ # @example
+ # Sequencer.new('Example::Sequence',
+ # parameters: {
+ # some: 'value',
+ # },
+ # expecting: [:result, :answer]
+ # )
+ def initialize(sequence, parameters: {}, expecting: nil)
+ @sequence = Sequencer::Sequence.constantize(sequence)
+ @parameters = parameters
+ @expecting = expecting
+
+ # fall back to sequence default expecting if no explicit
+ # expecting was given for this sequence
+ return if !@expecting.nil?
+ @expecting = @sequence.expecting
+ end
+
+ # Processes the Sequence the instance was initialized with.
+ #
+ # @example
+ # sequence.process
+ #
+ # @return [Hash{Symbol => Object}] the final result state attributes and values
+ def process
+ log_start_finish(:info, "Sequence '#{@sequence.name}'") do
+
+ sequence.units.each_with_index do |unit, index|
+
+ state.process do
+
+ log_start_finish(:info, "Sequence '#{sequence.name}' Unit '#{unit.name}' (index: #{index})") do
+ unit.process(state)
+ end
+ end
+ end
+ end
+
+ state.to_h.tap do |result|
+ logger.debug("Returning Sequence '#{@sequence.name}' result: #{result.inspect}")
+ end
+ end
+
+ private
+
+ def state
+ @state ||= Sequencer::State.new(sequence,
+ parameters: @parameters,
+ expecting: @expecting)
+ end
+end
diff --git a/lib/sequencer/mixin/exchange/folder.rb b/lib/sequencer/mixin/exchange/folder.rb
new file mode 100644
index 000000000..0740da383
--- /dev/null
+++ b/lib/sequencer/mixin/exchange/folder.rb
@@ -0,0 +1,18 @@
+class Sequencer
+ module Mixin
+ module Exchange
+ module Folder
+
+ def self.included(base)
+ base.uses :ews_connection
+ end
+
+ private
+
+ def ews_folder
+ @ews_folder ||= ::Import::Exchange::Folder.new(ews_connection)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/mixin/import_job/resource_loop.rb b/lib/sequencer/mixin/import_job/resource_loop.rb
new file mode 100644
index 000000000..a27e7e670
--- /dev/null
+++ b/lib/sequencer/mixin/import_job/resource_loop.rb
@@ -0,0 +1,37 @@
+require 'sequencer/mixin/sub_sequence'
+
+class Sequencer
+ module Mixin
+ module ImportJob
+ module ResourceLoop
+ include ::Sequencer::Mixin::SubSequence
+
+ private
+
+ def resource_sequence(sequence_name, items)
+ default_params = {
+ dry_run: import_job.dry_run,
+ import_job: import_job,
+ }
+
+ items.each do |item|
+ resource_params = {}
+ if block_given?
+ resource_params = yield item
+ else
+ resource_params[:resource] = item
+ end
+
+ resource_params[:resource] = resource_params[:resource].with_indifferent_access
+
+ sub_sequence(sequence_name,
+ parameters: default_params.merge(resource_params))
+ end
+
+ # store possible unsaved values in result e.g. statistics
+ import_job.save!
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/mixin/prefixed_constantize.rb b/lib/sequencer/mixin/prefixed_constantize.rb
new file mode 100644
index 000000000..243bee274
--- /dev/null
+++ b/lib/sequencer/mixin/prefixed_constantize.rb
@@ -0,0 +1,43 @@
+class Sequencer
+ module Mixin
+ # Classes that extend this module need a PREFIX constant.
+ module PrefixedConstantize
+ # Returns the class for a given name String independend of the prefix.
+ #
+ # @param [String] sequence the name String for the requested class
+ #
+ # @example
+ # Sequencer::Sequence.constantize('ExampleSequence')
+ # #=> Sequencer::Sequence::ExampleSequence
+ #
+ # @example
+ # Sequencer::Unit.constantize('Sequencer::Unit::Example::Unit')
+ # #=> Sequencer::Unit::Example::Unit
+ #
+ # @return [Object] the class for the given String
+ def constantize(name_string)
+ namespace(name_string).constantize
+ end
+
+ # Returns the complete class namespace for a given name String
+ # independend of the prefix.
+ #
+ # @param [String] sequence the name String for the requested class namespace
+ #
+ # @example
+ # Sequencer::Sequence.namespace('ExampleSequence')
+ # #=> 'Sequencer::Sequence::ExampleSequence'
+ #
+ # @example
+ # Sequencer::Unit.namespace('Sequencer::Unit::Example::Unit')
+ # #=> 'Sequencer::Unit::Example::Unit'
+ #
+ # @return [String] the class namespace for the given String
+ def namespace(name_string)
+ prefix = const_get(:PREFIX)
+ return name_string if name_string.start_with?(prefix)
+ "#{prefix}#{name_string}"
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/mixin/sub_sequence.rb b/lib/sequencer/mixin/sub_sequence.rb
new file mode 100644
index 000000000..75348641c
--- /dev/null
+++ b/lib/sequencer/mixin/sub_sequence.rb
@@ -0,0 +1,9 @@
+class Sequencer
+ module Mixin
+ module SubSequence
+ def sub_sequence(sequence, args = {})
+ Sequencer.process(sequence, args)
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/sequence.rb b/lib/sequencer/sequence.rb
new file mode 100644
index 000000000..906ee2ba3
--- /dev/null
+++ b/lib/sequencer/sequence.rb
@@ -0,0 +1,16 @@
+require 'sequencer/mixin/prefixed_constantize'
+
+class Sequencer
+ class Sequence
+ extend ::Sequencer::Mixin::PrefixedConstantize
+
+ PREFIX = 'Sequencer::Sequence::'.freeze
+
+ attr_reader :units, :expecting
+
+ def initialize(units:, expecting: [])
+ @units = units
+ @expecting = expecting
+ end
+ end
+end
diff --git a/lib/sequencer/sequence/base.rb b/lib/sequencer/sequence/base.rb
new file mode 100644
index 000000000..382d47baa
--- /dev/null
+++ b/lib/sequencer/sequence/base.rb
@@ -0,0 +1,48 @@
+class Sequencer
+ class Sequence
+ class Base
+
+ # Defines the default attributes that will get returned for the sequence.
+ # These can be overwritten by giving the :expecting key to a sequence process call.
+ #
+ # @example
+ # Sequencer::Sequence::Example.expecting
+ # # => [:result, :list]
+ #
+ # @return [Array] the list of expected result keys.
+ def self.expecting
+ []
+ end
+
+ # Defines the list of Units that form the sequence. The units will get
+ # processed in the order they are defined in. The namespaces can be
+ # absolute or without the `Sequencer::Unit` prefix.
+ #
+ # @example
+ # Sequencer::Sequence::Example.sequence
+ # # => ['Import::Example::Resource', 'Sequencer::Unit::Import::Model::Create', ...]
+ #
+ # @return [Array] the list of units forming the sequence.
+ def self.sequence
+ raise "Missing implementation of '#{__method__}' method for '#{name}'"
+ end
+
+ # This is an internally used method that converts the defined sequence to a
+ # Sequencer::Units instance which has special methods.
+ #
+ # @example
+ # Sequencer::Sequence::Example.units
+ # # =>
+ #
+ # @return [Object]
+ def self.units
+ Sequencer::Units.new(*sequence)
+ end
+
+ # @see .units
+ def units
+ self.class.units
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/sequence/exchange/folder/attributes.rb b/lib/sequencer/sequence/exchange/folder/attributes.rb
new file mode 100644
index 000000000..547d86e4f
--- /dev/null
+++ b/lib/sequencer/sequence/exchange/folder/attributes.rb
@@ -0,0 +1,17 @@
+class Sequencer
+ class Sequence
+ module Exchange
+ module Folder
+ class Attributes < Sequencer::Sequence::Base
+
+ def self.sequence
+ [
+ 'Exchange::Connection',
+ 'Exchange::Folder::Attributes',
+ ]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/sequence/import/exchange/attributes_examples.rb b/lib/sequencer/sequence/import/exchange/attributes_examples.rb
new file mode 100644
index 000000000..6c5cdab2b
--- /dev/null
+++ b/lib/sequencer/sequence/import/exchange/attributes_examples.rb
@@ -0,0 +1,23 @@
+class Sequencer
+ class Sequence
+ module Import
+ module Exchange
+ class AttributesExamples < Sequencer::Sequence::Base
+
+ def self.expecting
+ [:attributes]
+ end
+
+ def self.sequence
+ [
+ 'Exchange::Connection',
+ 'Exchange::Folders::ByIds',
+ 'Import::Exchange::AttributeExamples',
+ 'Import::Exchange::AttributeMapper::AttributeExamples',
+ ]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/sequence/import/exchange/available_folders.rb b/lib/sequencer/sequence/import/exchange/available_folders.rb
new file mode 100644
index 000000000..63c37b927
--- /dev/null
+++ b/lib/sequencer/sequence/import/exchange/available_folders.rb
@@ -0,0 +1,22 @@
+class Sequencer
+ class Sequence
+ module Import
+ module Exchange
+ class AvailableFolders < Sequencer::Sequence::Base
+
+ def self.expecting
+ [:folders]
+ end
+
+ def self.sequence
+ [
+ 'Exchange::Connection',
+ 'Exchange::Folders::IdPathMap',
+ 'Import::Exchange::AttributeMapper::AvailableFolders',
+ ]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/sequence/import/exchange/folder_contact.rb b/lib/sequencer/sequence/import/exchange/folder_contact.rb
new file mode 100644
index 000000000..d475920ca
--- /dev/null
+++ b/lib/sequencer/sequence/import/exchange/folder_contact.rb
@@ -0,0 +1,33 @@
+class Sequencer
+ class Sequence
+ module Import
+ module Exchange
+ class FolderContact < Sequencer::Sequence::Base
+
+ def self.sequence
+ [
+ 'Import::Exchange::FolderContact::RemoteId',
+ 'Import::Exchange::FolderContact::Mapping',
+ 'Import::Common::Model::Skip::Blank::Mapped',
+ 'Import::Exchange::FolderContact::StaticAttributes',
+ 'Import::Common::Model::ExternalSync::Lookup',
+ 'Import::Common::Model::Associations::Extract',
+ 'Import::Common::User::Attributes::Downcase',
+ 'Import::Common::User::Email::CheckValidity',
+ 'Import::Common::Model::Attributes::AddByIds',
+ 'Import::Common::Model::Update',
+ 'Import::Common::Model::Create',
+ 'Import::Common::Model::Associations::Assign',
+ 'Import::Common::Model::Save',
+ 'Import::Common::Model::ExternalSync::Create',
+ 'Import::Exchange::FolderContact::HttpLog',
+ 'Import::Exchange::FolderContact::Statistics::Diff',
+ 'Import::Common::ImportJob::Statistics::Update',
+ 'Import::Common::ImportJob::Statistics::Store',
+ ]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/sequence/import/exchange/folder_contacts.rb b/lib/sequencer/sequence/import/exchange/folder_contacts.rb
new file mode 100644
index 000000000..9d07f75cd
--- /dev/null
+++ b/lib/sequencer/sequence/import/exchange/folder_contacts.rb
@@ -0,0 +1,22 @@
+class Sequencer
+ class Sequence
+ module Import
+ module Exchange
+ class FolderContacts < Sequencer::Sequence::Base
+
+ def self.sequence
+ [
+ 'Import::Exchange::FolderContacts::DryRunPayload',
+ 'Exchange::Connection',
+ 'Import::Exchange::FolderContacts::FolderIds',
+ 'Import::Exchange::FolderContacts::Sum',
+ 'Import::Common::ImportJob::Statistics::Update',
+ 'Import::Common::ImportJob::Statistics::Store',
+ 'Import::Exchange::FolderContacts::SubSequence',
+ ]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/state.rb b/lib/sequencer/state.rb
new file mode 100644
index 000000000..39a7107a6
--- /dev/null
+++ b/lib/sequencer/state.rb
@@ -0,0 +1,272 @@
+require 'mixin/rails_logger'
+require 'mixin/start_finish_logger'
+
+class Sequencer
+ class State
+ include ::Mixin::RailsLogger
+ include ::Mixin::StartFinishLogger
+
+ def initialize(sequence, parameters: {}, expecting: nil)
+ @index = -1
+ @units = sequence.units
+ @result_index = @units.count
+ @values = {}
+
+ initialize_attributes(sequence.units)
+ initialize_parameters(parameters.with_indifferent_access)
+ initialize_expectations(expecting || sequence.expecting)
+ end
+
+ # Stores a value for the given attribute. Value can be a regular object
+ # or the result of a given code block.
+ # The attribute gets validated against the .provides list of attributes.
+ # In the case than an attribute gets provided that is not declared to
+ # be provided an exception will be raised.
+ #
+ # @param [Symbol] attribute the attribute for which the value gets provided.
+ # @param [Object] value the value that should get stored for the given attribute.
+ # @yield [] executes the given block and takes the result as the value.
+ # @yieldreturn [Object] the value for the given attribute.
+ #
+ # @example
+ # state.provide(:sum, 3)
+ #
+ # @example
+ # state.provide(:sum) do
+ # some_value = rand(100)
+ # some_value * 3
+ # end
+ #
+ # @raise [RuntimeError] if the attribute is not provideable from the calling Unit
+ #
+ # @return [nil]
+ def provide(attribute, value = nil)
+ if provideable?(attribute)
+ value = yield if block_given?
+ set(attribute, value)
+ else
+ value = "UNEXECUTED BLOCK: #{caller(1..1).first}" if block_given?
+ unprovideable_setter(attribute, value)
+ end
+ end
+
+ # Returns the value of the given attribute.
+ # The attribute gets validated against the .uses list of attributes. In the
+ # case than an attribute gets used that is not declared to be used
+ # an exception will be raised.
+ #
+ # @param [Symbol] attribute the attribute for which the value is requested.
+ #
+ # @example
+ # state.use(:answer)
+ # #=> 42
+ #
+ # @raise [RuntimeError] if the attribute is not useable from the calling Unit
+ #
+ # @return [nil]
+ def use(attribute)
+ if useable?(attribute)
+ get(attribute)
+ else
+ unaccessable_getter(attribute)
+ end
+ end
+
+ # Returns the value of the given attribute.
+ # The attribute DOES NOT get validated against the .uses list of attributes.
+ #
+ # @param [Symbol] attribute the attribute for which the value is requested.
+ #
+ # @example
+ # state.optional(:answer)
+ # #=> 42
+ #
+ # @example
+ # state.optional(:unknown)
+ # #=> nil
+ #
+ # @return [Object, nil]
+ def optional(attribute)
+ return get(attribute) if @attributes.known?(attribute)
+ logger.debug("Access to unknown optional attribute '#{attribute}'.")
+ nil
+ end
+
+ # Checks if a value for the given attribute is provided.
+ # The attribute DOES NOT get validated against the .uses list of attributes.
+ #
+ # @param [Symbol] attribute the attribute which should get checked.
+ #
+ # @example
+ # state.provided?(:answer)
+ # #=> true
+ #
+ # @example
+ # state.provided?(:unknown)
+ # #=> false
+ #
+ # @return [Boolean]
+ def provided?(attribute)
+ optional(attribute) != nil
+ end
+
+ # Unsets the value for the given attribute.
+ # The attribute gets validated against the .uses list of attributes.
+ # In the case than an attribute gets unset that is not declared
+ # to be used an exception will be raised.
+ #
+ # @param [Symbol] attribute the attribute for which the value gets unset.
+ #
+ # @example
+ # state.unset(:answer)
+ #
+ # @raise [RuntimeError] if the attribute is not useable from the calling Unit
+ #
+ # @return [nil]
+ def unset(attribute)
+ value = nil
+ if useable?(attribute)
+ set(attribute, value)
+ else
+ unprovideable_setter(attribute, value)
+ end
+ end
+
+ # Handles state processing of the next Unit in the Sequence while executing
+ # the given block. After the Unit is processed the state will get cleaned up
+ # and no longer needed attribute values will get discarded.
+ #
+ # @yield [] executes the given block and handles the state changes before and afterwards.
+ #
+ # @example
+ # state.process do
+ # unit.process
+ # end
+ #
+ # @return [nil]
+ def process
+ @index += 1
+ yield
+ cleanup
+ end
+
+ # Handles state processing of the next Unit in the Sequence while executing
+ # the given block. After the Unit is processed the state will get cleaned up
+ # and no longer needed attribute values will get discarded.
+ #
+ # @example
+ # state.to_h
+ # #=> {"ssl_verify"=>true, "host_url"=>"ldaps://192...", ...}
+ #
+ # @return [Hash{Symbol => Object}]
+ def to_h
+ available.map { |identifier| [identifier, @values[identifier]] }.to_h
+ end
+
+ private
+
+ def available
+ @attributes.select do |_identifier, attribute|
+ @index.between?(attribute.from, attribute.to)
+ end.keys
+ end
+
+ def unit(index = nil)
+ @units[index || @index]
+ end
+
+ def provideable?(attribute)
+ unit.provides.include?(attribute)
+ end
+
+ def useable?(attribute)
+ unit.uses.include?(attribute)
+ end
+
+ def set(attribute, value)
+ logger.debug("Setting '#{attribute}' value (#{value.class.name}): #{value.inspect}")
+ @values[attribute] = value
+ end
+
+ def get(attribute)
+ value = @values[attribute]
+ logger.debug("Getting '#{attribute}' value (#{value.class.name}): #{value.inspect}")
+ value
+ end
+
+ def unprovideable_setter(attribute, value)
+ message = "Unprovideable attribute '#{attribute}' set with value (#{value.class.name}): '#{value}'"
+ logger.error(message)
+ raise message
+ end
+
+ def unaccessable_getter(attribute)
+ message = "Unaccessable getter used for attribute '#{attribute}'"
+ logger.error(message)
+ raise message
+ end
+
+ def initialize_attributes(units)
+ log_start_finish(:debug, 'Attributes lifespan initialization') do
+ @attributes = Sequencer::Units::Attributes.new(units.declarations)
+ logger.debug("Attributes lifespan: #{@attributes.inspect}")
+ end
+ end
+
+ def initialize_parameters(parameters)
+ logger.debug("Initializing Sequencer::State with initial parameters: #{parameters.inspect}")
+
+ log_start_finish(:debug, 'Attribute value provisioning check and initialization') do
+
+ @attributes.each do |identifier, attribute|
+
+ if !attribute.will_be_used?
+ logger.debug("Attribute '#{identifier}' is provided by Unit(s) but never used.")
+ next
+ end
+
+ init_param = parameters.key?(identifier)
+ provided_attr = attribute.will_be_provided?
+
+ if !init_param && !provided_attr
+ message = "Attribute '#{identifier}' is used in Unit '#{unit(attribute.to).name}' (index: #{attribute.to}) but is not provided or given via initial parameters."
+ logger.error(message)
+ raise message
+ end
+
+ # skip if attribute is provided by an Unit but not
+ # an initial parameter
+ next if !init_param
+
+ # update 'from' lifespan information for attribute
+ # since it's provided via the initial parameter
+ attribute.from = @index
+
+ # set initial value
+ set(identifier, parameters[identifier])
+ end
+ end
+ end
+
+ def initialize_expectations(expected_attributes)
+ expected_attributes.each do |identifier|
+ logger.debug("Adding attribute '#{identifier}' to the list of expected result attributes.")
+ @attributes[identifier].to = @result_index
+ end
+ end
+
+ def cleanup
+ log_start_finish(:info, "State cleanup of Unit #{unit.name} (index: #{@index})") do
+
+ @attributes.delete_if do |identifier, attribute|
+ remove = !attribute.will_be_used?
+ remove ||= attribute.to <= @index
+ if remove && attribute.will_be_used?
+ logger.debug("Removing unneeded attribute '#{identifier}': #{@values[identifier]}")
+ end
+ remove
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit.rb b/lib/sequencer/unit.rb
new file mode 100644
index 000000000..723ef4a2e
--- /dev/null
+++ b/lib/sequencer/unit.rb
@@ -0,0 +1,71 @@
+require 'sequencer/mixin/prefixed_constantize'
+
+class Sequencer
+ class Unit
+ include ::Mixin::RequiredSubPaths
+ extend ::Sequencer::Mixin::PrefixedConstantize
+
+ PREFIX = 'Sequencer::Unit::'.freeze
+
+ # Convenience wrapper for processing a single Unit.
+ #
+ # ATTENTION: This should only be used for development, testing or debugging purposes.
+ # There might be a check in the future to prevent using this method in other scopes.
+ #
+ # @see #initialize
+ # @see #process
+ def self.process(unit, parameters, &block)
+ new(unit).process(parameters, &block)
+ end
+
+ # Initializes a new Sequencer::Unit for processing it.
+ #
+ # ATTENTION: This should only be used for development, testing or debugging purposes.
+ # There might be a check in the future to prevent using this method in other scopes.
+ #
+ # @param [String] unit the name String for the Unit that should get processed
+ def initialize(unit)
+ @unit = self.class.constantize(unit)
+ end
+
+ # Processes the Sequencer::Unit that the instance was initialized with.
+ #
+ # ATTENTION: This should only be used for development, testing or debugging purposes.
+ # There might be a check in the future to prevent using this method in other scopes.
+ #
+ # @param [Hash{Symbol => Object}] parameters the parameters for initializing the Sequencer::State
+ # @yield [instance] optional block to access the Unit instance
+ # @yieldparam instance [Object] the Unit instance for e.g. adding expectations
+ def process(parameters)
+ @parameters = parameters
+ instance = @unit.new(state)
+
+ # yield instance to apply expectations
+ yield instance if block_given?
+
+ state.process do
+ instance.process
+ end
+
+ state.to_h
+ end
+
+ private
+
+ def state
+ @state ||= begin
+ units = Sequencer::Units.new(
+ @unit.name
+ )
+
+ sequence = Sequencer::Sequence.new(
+ units: units,
+ expecting: @unit.provides,
+ )
+
+ Sequencer::State.new(sequence,
+ parameters: @parameters)
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/base.rb b/lib/sequencer/unit/base.rb
new file mode 100644
index 000000000..cd97afe82
--- /dev/null
+++ b/lib/sequencer/unit/base.rb
@@ -0,0 +1,212 @@
+require 'mixin/rails_logger'
+
+class Sequencer
+ class Unit
+ class Base
+ include ::Mixin::RailsLogger
+
+ attr_reader :state
+
+ # Creates the class macro `uses` that allows a Unit to
+ # declare the attributes it will use via parameter or block.
+ # On the other hand it returns the declared attributes if
+ # called without parameters.
+ #
+ # This method can be called multiple times and will add the
+ # given attributes to the list. It takes care of handling
+ # duplicates so no uniq check is required. It's safe to use
+ # for inheritance structures and modules.
+ #
+ # It additionally creates a getter instance method for each declared
+ # attribute like e.g. attr_reader does. This allows direct access
+ # to an attribute via `attribute_name`. See examples.
+ #
+ # @param [Array] attributes an optional list of attributes that the Unit uses
+ #
+ # @yield [] A block returning a list of attributes
+ #
+ # @example Via regular Array parameter
+ # uses :instance, :action, :connection
+ #
+ # @example Via block
+ # uses do
+ # additional = method(parameter)
+ # [:some, additional]
+ # end
+ #
+ # @example Listing declared attributes
+ # Unit::Name.uses
+ # # => [:instance, :action, :connection, :some, :suprise]
+ #
+ # @example Using declared attribute in the Unit via state object
+ # state.use(:instance).id
+ #
+ # @example Using declared attribute in the Unit via getter
+ # instance.id
+ #
+ # @return [Array] the list of all declared uses of a Unit.
+ def self.uses(*attributes, &block)
+ declaration_accessor(
+ key: __method__,
+ attributes: attributes(*attributes, &block)
+ ) do |attribute|
+ use_getter(attribute)
+ end
+ end
+
+ # Creates the class macro `provides` that allows a Unit to
+ # declare the attributes it will provided via parameter or block.
+ # On the other hand it returns the declared attributes if
+ # called without parameters.
+ #
+ # This method can be called multiple times and will add the
+ # given attributes to the list. It takes care of handling
+ # duplicates so no uniq check is required. It's safe to use
+ # for inheritance structures and modules.
+ #
+ # It additionally creates a setter instance method for each declared
+ # attribute like e.g. attr_writer does. This allows direct access
+ # to an attribute via `self.attribute_name = `. See examples.
+ #
+ # A Unit should usually not provide more than one or two attributes.
+ # If your Unit provides it's doing to much and should be splitted
+ # into multiple Units.
+ #
+ # @param [Array] attributes an optional list of attributes that the Unit provides
+ #
+ # @yield [] A block returning a list of attributes
+ #
+ # @example Via regular Array parameter
+ # provides :instance, :action, :connection
+ #
+ # @example Via block
+ # provides do
+ # additional = method(parameter)
+ # [:some, additional]
+ # end
+ #
+ # @example Listing declared attributes
+ # Unit::Name.provides
+ # # => [:instance, :action, :connection, :some, :suprise]
+ #
+ # @example Providing declared attribute in the Unit via state object parameter
+ # state.provide(:action, :created)
+ #
+ # @example Providing declared attribute in the Unit via state object block
+ # state.provide(:instance) do
+ # # ...
+ # instance
+ # end
+ #
+ # @example Providing declared attribute in the Unit via setter
+ # self.action = :created
+ #
+ # @return [Array] the list of all declared provides of a Unit.
+ def self.provides(*attributes, &block)
+ declaration_accessor(
+ key: __method__,
+ attributes: attributes(*attributes, &block)
+ ) do |attribute|
+ provide_setter(attribute)
+ end
+ end
+
+ def self.attributes(*attributes)
+ # exectute block if given and add
+ # the result to the (possibly empty)
+ # list of given attributes
+ attributes.concat(yield) if block_given?
+ attributes
+ end
+
+ # This method is the heart of the #uses and #provides method.
+ # It takes the declaration key and decides based on the given
+ # parameters if the given attributes should get stored or
+ # the stored values returned.
+ def self.declaration_accessor(key:, attributes:)
+
+ # if no attributes were given (storing)
+ # return the already stored list of attributes
+ return declarations(key).to_a if attributes.blank?
+
+ # loop over all given attributes and
+ # add them to the list of already stored
+ # attributes for the given declaration key
+ attributes.each do |attribute|
+ next if !declarations(key).add?(attribute)
+
+ # yield callback if given to create
+ # getter or setter or whatever
+ yield(attribute) if block_given?
+ end
+ end
+
+ # This method creates the convenience method
+ # getter for the given attribute.
+ def self.use_getter(attribute)
+ define_method(attribute) do
+ instance_variable_cached(attribute) do
+ state.use(attribute)
+ end
+ end
+ end
+
+ # This method creates the convenience method
+ # setter for the given attribute.
+ def self.provide_setter(attribute)
+ define_method("#{attribute}=") do |value|
+ state.provide(attribute, value)
+ end
+ end
+
+ # This method is the attribute store for the given declaration key.
+ def self.declarations(key)
+ instance_variable_cached("#{key}_declarations") do
+ declarations_initial(key)
+ end
+ end
+
+ # This method initializes the attribute store for the given declaration key.
+ # It checks if a parent class already has an existing store and duplicates it
+ # for independent usage. Otherwise it creates a new one.
+ def self.declarations_initial(key)
+ return Set.new([]) if !superclass.respond_to?(:declarations)
+ superclass.send(:declarations, key).dup
+ end
+
+ # This method creates an accessor to a cached instance variable for the given scope.
+ # It will create a new variable with the result of the given block as an initial value.
+ # On later calls it will return the already initialized, cached variable state.
+ # The variable will be created by default as a class variable. If a instance scope is
+ # passed it will create an instance variable instead.
+ def self.instance_variable_cached(key, scope: self)
+ cache = "@#{key}"
+ value = scope.instance_variable_get(cache)
+ return value if value
+ value = yield
+ scope.instance_variable_set(cache, value)
+ end
+
+ # This method is an instance wrapper around the class method .instance_variable_cached.
+ # It will behave the same but passed the instance scope to create an
+ # cached instance variable.
+ def instance_variable_cached(key, &block)
+ self.class.instance_variable_cached(key, scope: self, &block)
+ end
+
+ # This method is an convenience wrapper to create an instance
+ # and then directly processing it.
+ def self.process(*args)
+ new(*args).process
+ end
+
+ def initialize(state)
+ @state = state
+ end
+
+ def process
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/common/attribute_mapper.rb b/lib/sequencer/unit/common/attribute_mapper.rb
new file mode 100644
index 000000000..8922dd190
--- /dev/null
+++ b/lib/sequencer/unit/common/attribute_mapper.rb
@@ -0,0 +1,28 @@
+class Sequencer
+ class Unit
+ module Common
+ class AttributeMapper < Sequencer::Unit::Base
+
+ def self.map
+ raise "Missing implementation of '#{__method__}' method for '#{name}'"
+ end
+
+ def self.uses
+ map.keys
+ end
+
+ def self.provides
+ map.values
+ end
+
+ def process
+ self.class.map.each do |original, renamed|
+ state.provide(renamed) do
+ state.use(original)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/exchange/connection.rb b/lib/sequencer/unit/exchange/connection.rb
new file mode 100644
index 000000000..fddcba8c0
--- /dev/null
+++ b/lib/sequencer/unit/exchange/connection.rb
@@ -0,0 +1,23 @@
+class Sequencer
+ class Unit
+ module Exchange
+ class Connection < Sequencer::Unit::Base
+
+ uses :ews_config
+ provides :ews_connection
+
+ def process
+ # check if EWS connection is already given (sub sequence)
+ return if state.provided?(:ews_connection)
+
+ state.provide(:ews_connection) do
+ config = ews_config
+ config ||= ::Import::Exchange.config
+
+ Viewpoint::EWSClient.new(config[:endpoint], config[:user], config[:password])
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/exchange/folders/by_ids.rb b/lib/sequencer/unit/exchange/folders/by_ids.rb
new file mode 100644
index 000000000..8a08d530f
--- /dev/null
+++ b/lib/sequencer/unit/exchange/folders/by_ids.rb
@@ -0,0 +1,24 @@
+require 'sequencer/mixin/exchange/folder'
+
+class Sequencer
+ class Unit
+ module Exchange
+ module Folders
+ class ByIds < Sequencer::Unit::Base
+ include ::Sequencer::Mixin::Exchange::Folder
+
+ uses :ews_folder_ids
+ provides :ews_folders
+
+ def process
+ state.provide(:ews_folders) do
+ ews_folder_ids.collect do |folder_id|
+ ews_folder.find(folder_id)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/exchange/folders/id_path_map.rb b/lib/sequencer/unit/exchange/folders/id_path_map.rb
new file mode 100644
index 000000000..9b6d48847
--- /dev/null
+++ b/lib/sequencer/unit/exchange/folders/id_path_map.rb
@@ -0,0 +1,31 @@
+require 'sequencer/mixin/exchange/folder'
+
+class Sequencer
+ class Unit
+ module Exchange
+ module Folders
+ class IdPathMap < Sequencer::Unit::Base
+ include ::Sequencer::Mixin::Exchange::Folder
+
+ provides :ews_folder_id_path_map
+
+ def process
+ state.provide(:ews_folder_id_path_map) do
+
+ ids = state.optional(:ews_folder_ids)
+ ids ||= []
+
+ ews_folder.id_folder_map.collect do |id, folder|
+ next if ids.present? && ids.exclude?(id)
+ next if folder.total_count.blank?
+ next if folder.total_count.zero?
+
+ [id, ews_folder.display_path(folder)]
+ end.compact.to_h
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/import_job/payload/to_state.rb b/lib/sequencer/unit/import/common/import_job/payload/to_state.rb
new file mode 100644
index 000000000..0c0d17324
--- /dev/null
+++ b/lib/sequencer/unit/import/common/import_job/payload/to_state.rb
@@ -0,0 +1,25 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module ImportJob
+ module Payload
+ class ToState < Sequencer::Unit::Base
+
+ uses :import_job
+
+ def process
+ provides = self.class.provides
+ raise "Can't find any provides for #{self.class.name}" if provides.blank?
+
+ provides.each do |attribute|
+ state.provide(attribute, import_job.payload[attribute])
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/import_job/statistics/store.rb b/lib/sequencer/unit/import/common/import_job/statistics/store.rb
new file mode 100644
index 000000000..33aa8a9e8
--- /dev/null
+++ b/lib/sequencer/unit/import/common/import_job/statistics/store.rb
@@ -0,0 +1,35 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module ImportJob
+ module Statistics
+ class Store < Sequencer::Unit::Base
+
+ uses :import_job, :statistics
+
+ def process
+ # update the attribute temporarily so we can update it when:
+ # - the last update is more than 10 seconds in the past
+ # - all instances are processed but the last statistics entry is not written here.
+ # This will be done in the calling Unit of the executed sub sequence
+ import_job.result = statistics
+
+ return if !store?
+ import_job.save!
+ end
+
+ private
+
+ def store?
+ return true if import_job.updated_at.blank?
+ # update every 10 seconds to reduce DB load
+ import_job.updated_at > Time.zone.now + 10.seconds
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/import_job/statistics/update.rb b/lib/sequencer/unit/import/common/import_job/statistics/update.rb
new file mode 100644
index 000000000..57af16340
--- /dev/null
+++ b/lib/sequencer/unit/import/common/import_job/statistics/update.rb
@@ -0,0 +1,50 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module ImportJob
+ module Statistics
+ class Update < Sequencer::Unit::Base
+
+ uses :statistics_diff
+ provides :statistics
+
+ def process
+ state.provide(:statistics) do
+ sum_deeply(
+ existing: statistics,
+ additions: statistics_diff
+ )
+ end
+
+ # reset diff to avoid situations where old diff gets added multiple times
+ state.unset(:statistics_diff)
+ end
+
+ private
+
+ def statistics
+ import_job = state.optional(:import_job)
+ return {} if import_job.nil?
+ import_job.result
+ end
+
+ def sum_deeply(existing:, additions:)
+ existing.merge(additions) do |_key, oldval, newval|
+ if oldval.is_a?(Hash) || newval.is_a?(Hash)
+ sum_deeply(
+ existing: oldval,
+ additions: newval
+ )
+ else
+ oldval + newval
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/import_job/sub_sequence/general.rb b/lib/sequencer/unit/import/common/import_job/sub_sequence/general.rb
new file mode 100644
index 000000000..e581e1782
--- /dev/null
+++ b/lib/sequencer/unit/import/common/import_job/sub_sequence/general.rb
@@ -0,0 +1,66 @@
+require 'sequencer/mixin/sub_sequence'
+
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module ImportJob
+ module SubSequence
+ class General < Sequencer::Unit::Base
+ include ::Sequencer::Mixin::SubSequence
+
+ uses :import_job
+
+ def process
+ resource_sequence
+ end
+
+ private
+
+ # INFO: Cache results via `@sequence ||= ...`, if needed
+ def sequence
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+
+ # INFO: Cache results via `@resources ||= ...`, if needed
+ def resources
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+
+ def default_parameters
+ {
+ dry_run: import_job.dry_run,
+ import_job: import_job,
+ }
+ end
+
+ def resource_sequence
+ return if resources.blank?
+
+ defaults = default_parameters
+
+ resources.each do |resource|
+
+ arguments = {
+ parameters: defaults.merge(resource: resource)
+ }
+
+ yield resource, arguments if block_given?
+
+ arguments[:parameters][:resource] = arguments[:parameters][:resource].with_indifferent_access
+
+ result = sub_sequence(sequence, arguments)
+
+ processed(result)
+ end
+ end
+
+ def processed(_result)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/import_job/sub_sequence/resource.rb b/lib/sequencer/unit/import/common/import_job/sub_sequence/resource.rb
new file mode 100644
index 000000000..7037d6e07
--- /dev/null
+++ b/lib/sequencer/unit/import/common/import_job/sub_sequence/resource.rb
@@ -0,0 +1,15 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module ImportJob
+ module SubSequence
+ class Resource < Sequencer::Unit::Import::Common::ImportJob::SubSequence::General
+ uses :resource
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/mapping/flat_keys.rb b/lib/sequencer/unit/import/common/mapping/flat_keys.rb
new file mode 100644
index 000000000..302671ced
--- /dev/null
+++ b/lib/sequencer/unit/import/common/mapping/flat_keys.rb
@@ -0,0 +1,35 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Mapping
+ class FlatKeys < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
+
+ uses :resource
+ provides :mapped
+
+ def process
+ provide_mapped do
+ mapped
+ end
+ end
+
+ private
+
+ def mapped
+ resource_with_indifferent_access = resource.with_indifferent_access
+ mapping.symbolize_keys.collect do |source, local|
+ [local, resource_with_indifferent_access[source]]
+ end.to_h.with_indifferent_access
+ end
+
+ def mapping
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/mapping/mixin/provide_mapped.rb b/lib/sequencer/unit/import/common/mapping/mixin/provide_mapped.rb
new file mode 100644
index 000000000..544dc9727
--- /dev/null
+++ b/lib/sequencer/unit/import/common/mapping/mixin/provide_mapped.rb
@@ -0,0 +1,30 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Mapping
+ module Mixin
+ module ProvideMapped
+
+ def self.included(base)
+ base.provides :mapped
+ end
+
+ private
+
+ def existing_mapped
+ @existing_mapped ||= state.optional(:mapped) || ActiveSupport::HashWithIndifferentAccess.new
+ end
+
+ def provide_mapped
+ state.provide(:mapped) do
+ existing_mapped.merge(yield)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/associations/assign.rb b/lib/sequencer/unit/import/common/model/associations/assign.rb
new file mode 100644
index 000000000..00752ad1b
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/associations/assign.rb
@@ -0,0 +1,59 @@
+require 'sequencer/unit/import/common/model/mixin/handle_failure'
+
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Associations
+ class Assign < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
+
+ uses :instance, :associations, :instance_action, :dry_run
+ provides :instance_action
+
+ def process
+ return if dry_run
+ return if instance.blank?
+
+ instance.assign_attributes(associations)
+
+ # execute associations check only if needed for performance reasons
+ return if instance_action != :unchanged
+ return if !changed?
+ state.provide(:instance_action, :changed)
+ rescue => e
+ handle_failure(e)
+ end
+
+ private
+
+ def changed?
+ logger.debug("Changed instance associations: #{changes.inspect}")
+ changes.present?
+ end
+
+ def changes
+ @changes ||= begin
+ return {} if associations.blank?
+ associations.collect do |association, value|
+ before = compareable(instance.send(association))
+ after = compareable(value)
+ next if before == after
+ [association, [before, after]]
+ end.compact.to_h.with_indifferent_access
+ end
+ end
+
+ def compareable(value)
+ return nil if value.blank?
+ return value.sort if value.respond_to(:sort)
+ value
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/associations/extract.rb b/lib/sequencer/unit/import/common/model/associations/extract.rb
new file mode 100644
index 000000000..40142b08e
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/associations/extract.rb
@@ -0,0 +1,65 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Associations
+ class Extract < Sequencer::Unit::Base
+
+ uses :model_class, :mapped
+ provides :associations
+
+ def process
+ state.provide(:associations) do
+ associations.collect do |association|
+ next if !mapped.key?(association)
+
+ # remove from the mapped values if it's an association
+ value = mapped.delete(association)
+
+ # skip if we don't track them
+ next if tracked_associations.exclude?(association)
+
+ [association, value]
+ end.compact.to_h
+ end
+ end
+
+ private
+
+ def associations
+ @associations ||= begin
+ associations = []
+ # loop over all reflections
+ model_class.reflect_on_all_associations.each do |reflection|
+
+ # refection name is something like groups or organization (singular/plural)
+ associations.push(reflection.name)
+
+ # key is something like group_id or organization_id (singular)
+ key = reflection.klass.name.foreign_key
+
+ # add trailing 's' to get pluralized key
+ reflection_name = reflection.name.to_s
+ if reflection_name.singularize == reflection_name
+ key = "#{key}s"
+ end
+
+ # store _id/_ids name
+ associations.push(key.to_sym)
+ end
+ associations
+ end
+ end
+
+ def tracked_associations
+ # track all associations by default
+ associations
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/attributes/add_by_ids.rb b/lib/sequencer/unit/import/common/model/attributes/add_by_ids.rb
new file mode 100644
index 000000000..23ab42192
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/attributes/add_by_ids.rb
@@ -0,0 +1,24 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Attributes
+ class AddByIds < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped
+
+ def process
+ provide_mapped do
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/attributes/check_mandatory.rb b/lib/sequencer/unit/import/common/model/attributes/check_mandatory.rb
new file mode 100644
index 000000000..dbb1ba529
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/attributes/check_mandatory.rb
@@ -0,0 +1,32 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Attributes
+ class CheckMandatory < Sequencer::Unit::Base
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction
+
+ uses :mapped
+ provides :instance_action
+
+ def process
+ mandatory.each do |mapped_attribute|
+ next if mapped[mapped_attribute].present?
+ state.provide(:instance_action, :skipped)
+ break
+ end
+ end
+
+ private
+
+ def mandatory
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/attributes/remote_id.rb b/lib/sequencer/unit/import/common/model/attributes/remote_id.rb
new file mode 100644
index 000000000..412fbb773
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/attributes/remote_id.rb
@@ -0,0 +1,32 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Attributes
+ class RemoteId < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
+
+ uses :resource
+ provides :remote_id
+
+ def process
+ state.provide(:remote_id) do
+ resource.fetch(attribute)
+ end
+ rescue KeyError => e
+ handle_failure(e)
+ end
+
+ private
+
+ def attribute
+ :id
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/create.rb b/lib/sequencer/unit/import/common/model/create.rb
new file mode 100644
index 000000000..8aab65fb5
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/create.rb
@@ -0,0 +1,25 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ class Create < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction
+
+ uses :mapped, :model_class
+ provides :instance, :instance_action
+
+ def process
+ instance = model_class.new(mapped)
+ state.provide(:instance, instance)
+ state.provide(:instance_action, :created)
+ rescue => e
+ handle_failure(e)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/external_sync/create.rb b/lib/sequencer/unit/import/common/model/external_sync/create.rb
new file mode 100644
index 000000000..b1354a454
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/external_sync/create.rb
@@ -0,0 +1,29 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module ExternalSync
+ class Create < Sequencer::Unit::Base
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance
+
+ uses :instance, :instance_action, :remote_id, :dry_run, :external_sync_source, :model_class
+
+ def process
+ return if dry_run
+ return if instance_action != :created
+
+ ::ExternalSync.create(
+ source: external_sync_source,
+ source_id: remote_id,
+ object: model_class.name,
+ o_id: instance.id
+ )
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/external_sync/local.rb b/lib/sequencer/unit/import/common/model/external_sync/local.rb
new file mode 100644
index 000000000..5d4c6f1f8
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/external_sync/local.rb
@@ -0,0 +1,63 @@
+require 'sequencer/unit/import/common/model/mixin/handle_failure'
+
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module ExternalSync
+ class Local < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance
+
+ uses :mapped, :remote_id, :model_class, :external_sync_source, :instance_action
+ provides :instance
+
+ def process
+ return if state.provided?(:instance)
+
+ return if value.blank?
+ return if instance.blank?
+
+ create_external_sync
+
+ state.provide(:instance, instance)
+ end
+
+ private
+
+ def attribute
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+
+ def value
+ mapped[attribute]
+ end
+
+ def instance
+ @instance ||= begin
+ model_class.where(attribute => value).find do |local|
+ !ExternalSync.exists?(
+ source: external_sync_source,
+ object: model_class.name,
+ o_id: local.id
+ )
+ end
+ end
+ end
+
+ def create_external_sync
+ ExternalSync.create(
+ source: external_sync_source,
+ source_id: remote_id,
+ object: import_class.name,
+ o_id: instance.id
+ )
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/external_sync/lookup.rb b/lib/sequencer/unit/import/common/model/external_sync/lookup.rb
new file mode 100644
index 000000000..e9894d794
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/external_sync/lookup.rb
@@ -0,0 +1,34 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module ExternalSync
+ class Lookup < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance
+
+ uses :remote_id, :model_class, :external_sync_source
+ provides :instance
+
+ def process
+ synced_instance = ::ExternalSync.find_by(
+ source: external_sync_source,
+ source_id: remote_id,
+ object: model_class.name,
+ )
+ return if !synced_instance
+
+ state.provide(:instance) do
+ model_class.find(synced_instance.o_id)
+ end
+ rescue => e
+ handle_failure(e)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/http_log.rb b/lib/sequencer/unit/import/common/model/http_log.rb
new file mode 100644
index 000000000..811e4f383
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/http_log.rb
@@ -0,0 +1,50 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ class HttpLog < Sequencer::Unit::Base
+
+ uses :dry_run, :instance_action, :remote_id, :mapped, :exception
+
+ def process
+ return if dry_run
+ ::HttpLog.create(
+ direction: 'out',
+ facility: facility,
+ method: 'tcp',
+ url: "#{instance_action} -> #{remote_id}",
+ status: status,
+ ip: nil,
+ request: {
+ content: mapped,
+ },
+ response: {
+ message: response
+ },
+ created_by_id: 1,
+ updated_by_id: 1,
+ )
+ end
+
+ private
+
+ def status
+ @status ||= begin
+ instance_action == :failed ? :failed : :success
+ end
+ end
+
+ def response
+ exception ? exception.message : status
+ end
+
+ def facility
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/mixin/handle_failure.rb b/lib/sequencer/unit/import/common/model/mixin/handle_failure.rb
new file mode 100644
index 000000000..d2502de14
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/mixin/handle_failure.rb
@@ -0,0 +1,24 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Mixin
+ module HandleFailure
+
+ def self.included(base)
+ base.provides :exception, :instance_action
+ end
+
+ def handle_failure(e)
+ logger.error(e)
+ state.provide(:exception, e)
+ state.provide(:instance_action, :failed)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/mixin/skip_on_provided_instance_action.rb b/lib/sequencer/unit/import/common/model/mixin/skip_on_provided_instance_action.rb
new file mode 100644
index 000000000..2e9964d69
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/mixin/skip_on_provided_instance_action.rb
@@ -0,0 +1,22 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Mixin
+ module SkipOnProvidedInstanceAction
+
+ def process
+ if state.provided?(:instance_action)
+ logger.debug("Skipping. Attribute 'instance_action' already provided.")
+ else
+ super
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/mixin/skip_on_skipped_instance.rb b/lib/sequencer/unit/import/common/model/mixin/skip_on_skipped_instance.rb
new file mode 100644
index 000000000..8d849c2e9
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/mixin/skip_on_skipped_instance.rb
@@ -0,0 +1,26 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Mixin
+ module SkipOnSkippedInstance
+
+ def self.prepended(base)
+ base.uses :instance_action
+ end
+
+ def process
+ if instance_action == :skipped
+ logger.debug("Skipping. Attribute 'instance_action' is set to :skipped.")
+ else
+ super
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/save.rb b/lib/sequencer/unit/import/common/model/save.rb
new file mode 100644
index 000000000..8dec6b8ab
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/save.rb
@@ -0,0 +1,25 @@
+require 'sequencer/unit/import/common/model/mixin/handle_failure'
+
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ class Save < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
+
+ uses :instance, :dry_run
+
+ def process
+ return if dry_run
+ return if instance.blank?
+ instance.save!
+ rescue => e
+ handle_failure(e)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/skip/blank/base.rb b/lib/sequencer/unit/import/common/model/skip/blank/base.rb
new file mode 100644
index 000000000..d57a10126
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/skip/blank/base.rb
@@ -0,0 +1,43 @@
+require 'sequencer/unit/mixin/dynamic_attribute'
+
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Skip
+ module Blank
+ class Base < Sequencer::Unit::Base
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction
+ include ::Sequencer::Unit::Mixin::DynamicAttribute
+
+ provides :instance_action
+
+ def process
+ return if !skip?
+ logger.debug("Skipping. Blank #{attribute} found: #{attribute_value.inspect}")
+ state.provide(:instance_action, :skipped)
+ end
+
+ private
+
+ def ignore
+ [:id]
+ end
+
+ def skip?
+ return true if attribute_value.blank?
+ relevant_blank?
+ end
+
+ def relevant_blank?
+ !attribute_value.except(*ignore).values.any?(&:present?)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/skip/blank/mapped.rb b/lib/sequencer/unit/import/common/model/skip/blank/mapped.rb
new file mode 100644
index 000000000..25acaad86
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/skip/blank/mapped.rb
@@ -0,0 +1,17 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Skip
+ module Blank
+ class Mapped < Sequencer::Unit::Import::Common::Model::Skip::Blank::Base
+ uses :mapped
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/skip/blank/resource.rb b/lib/sequencer/unit/import/common/model/skip/blank/resource.rb
new file mode 100644
index 000000000..757ca4165
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/skip/blank/resource.rb
@@ -0,0 +1,17 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Skip
+ module Blank
+ class Resource < Sequencer::Unit::Import::Common::Model::Skip::Blank::Base
+ uses :resource
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/skip/missing_mandatory/base.rb b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/base.rb
new file mode 100644
index 000000000..cb327161e
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/base.rb
@@ -0,0 +1,46 @@
+require 'sequencer/unit/mixin/dynamic_attribute'
+
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Skip
+ module MissingMandatory
+ class Base < Sequencer::Unit::Base
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction
+ include ::Sequencer::Unit::Mixin::DynamicAttribute
+
+ provides :instance_action
+
+ def process
+ return if !skip?
+ logger.debug("Skipping. Missing mandatory attributes for #{attribute}: #{attribute_value.inspect}")
+ state.provide(:instance_action, :skipped)
+ end
+
+ private
+
+ def mandatory
+ raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'"
+ end
+
+ def skip?
+ return true if attribute_value.blank?
+ mandatory_missing?
+ end
+
+ def mandatory_missing?
+ values = attribute_value.fetch_values(*mandatory)
+ !values.any?(&:present?)
+ rescue KeyError => e
+ false
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/skip/missing_mandatory/resource.rb b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/resource.rb
new file mode 100644
index 000000000..f33bc280b
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/resource.rb
@@ -0,0 +1,17 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Skip
+ module MissingMandatory
+ class Resource < Sequencer::Unit::Import::Common::Model::Skip::MissingMandatory::Base
+ uses :resource
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/statistics/diff.rb b/lib/sequencer/unit/import/common/model/statistics/diff.rb
new file mode 100644
index 000000000..787123b1d
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/statistics/diff.rb
@@ -0,0 +1,21 @@
+require 'sequencer/unit/import/common/model/statistics/mixin/diff'
+
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Statistics
+ class Diff < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Statistics::Mixin::Diff
+
+ def process
+ state.provide(:statistics_diff, diff)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/statistics/mixin/diff.rb b/lib/sequencer/unit/import/common/model/statistics/mixin/diff.rb
new file mode 100644
index 000000000..1dd1194c7
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/statistics/mixin/diff.rb
@@ -0,0 +1,47 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ module Statistics
+ module Mixin
+ module Diff
+
+ def self.included(base)
+ base.uses :instance_action
+ base.provides :statistics_diff
+ end
+
+ private
+
+ def actions
+ %i(skipped created updated unchanged failed deactivated)
+ end
+
+ def diff
+ raise "Unknown action '#{instance_action}'" if !possible?
+ defaults.merge(
+ instance_action => 1,
+ sum: 1,
+ )
+ end
+
+ def possible?
+ possible_actions.include?(instance_action)
+ end
+
+ def defaults
+ possible_actions.collect { |key| [key, 0] }.to_h
+ end
+
+ def possible_actions
+ @possible_actions ||= actions
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/model/update.rb b/lib/sequencer/unit/import/common/model/update.rb
new file mode 100644
index 000000000..fa0947317
--- /dev/null
+++ b/lib/sequencer/unit/import/common/model/update.rb
@@ -0,0 +1,56 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module Model
+ class Update < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction
+
+ uses :instance, :mapped
+ provides :instance_action
+
+ def process
+ # check if no instance is given - so we can't update it
+ return if !instance
+
+ # lock the current instance for write access
+ instance.with_lock do
+ # delete since we have an update and
+ # the record is already created
+ mapped.delete(:created_by_id)
+
+ # assign regular attributes
+ instance.assign_attributes(mapped)
+
+ action = changed? ? :updated : :unchanged
+ state.provide(:instance_action, action)
+ end
+ rescue => e
+ handle_failure(e)
+ end
+
+ private
+
+ def changed?
+ logger.debug("Changed instance attributes: #{changes.inspect}")
+ changes.present?
+ end
+
+ def changes
+ @changes ||= begin
+ if instance.changed?
+ # dry run
+ instance.changes
+ else
+ # live run
+ instance.previous_changes
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/user/attributes/downcase.rb b/lib/sequencer/unit/import/common/user/attributes/downcase.rb
new file mode 100644
index 000000000..c5791e062
--- /dev/null
+++ b/lib/sequencer/unit/import/common/user/attributes/downcase.rb
@@ -0,0 +1,24 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module User
+ module Attributes
+ class Downcase < Sequencer::Unit::Base
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance
+
+ uses :mapped
+
+ def process
+ %i(login email).each do |attribute|
+ next if mapped[attribute].blank?
+ mapped[attribute].downcase!
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/common/user/email/check_validity.rb b/lib/sequencer/unit/import/common/user/email/check_validity.rb
new file mode 100644
index 000000000..28f035d4f
--- /dev/null
+++ b/lib/sequencer/unit/import/common/user/email/check_validity.rb
@@ -0,0 +1,45 @@
+class Sequencer
+ class Unit
+ module Import
+ module Common
+ module User
+ module Email
+ class CheckValidity < Sequencer::Unit::Base
+ prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance
+
+ uses :mapped
+
+ def process
+ return if mapped[:email].blank?
+
+ # TODO: This should be done totally somewhere central
+ mapped[:email] = ensure_valid_email(mapped[:email])
+ end
+
+ private
+
+ def ensure_valid_email(source)
+ # TODO: should get unified with User#check_email
+ email = extract_email(source)
+ return if !email
+ email.downcase
+ end
+
+ def extract_email(source)
+ # Support format like "Bob Smith (bob@example.com)"
+ if source =~ /\((.+@.+)\)/
+ source = $1
+ end
+
+ Mail::Address.new(source).address
+ rescue
+ return source if source !~ /<\s*([^>]+)/
+ $1.strip
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/attribute_examples.rb b/lib/sequencer/unit/import/exchange/attribute_examples.rb
new file mode 100644
index 000000000..19401609d
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/attribute_examples.rb
@@ -0,0 +1,32 @@
+require 'sequencer/mixin/exchange/folder'
+
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ class AttributeExamples < Sequencer::Unit::Base
+ include ::Sequencer::Mixin::Exchange::Folder
+
+ uses :ews_folder_ids
+ provides :ews_attributes_examples
+
+ def process
+ state.provide(:ews_attributes_examples) do
+ ::Import::Helper::AttributesExamples.new do |extractor|
+
+ ews_folder_ids.collect do |folder_id|
+
+ ews_folder.find(folder_id).items.each do |resource|
+ attributes = ::Import::Exchange::ItemAttributes.extract(resource)
+ extractor.extract(attributes)
+ break if extractor.enough
+ end
+ end
+ end.examples
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/attribute_mapper/attribute_examples.rb b/lib/sequencer/unit/import/exchange/attribute_mapper/attribute_examples.rb
new file mode 100644
index 000000000..482a8cf3c
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/attribute_mapper/attribute_examples.rb
@@ -0,0 +1,18 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module AttributeMapper
+ class AttributeExamples < Sequencer::Unit::Common::AttributeMapper
+
+ def self.map
+ {
+ ews_attributes_examples: :attributes,
+ }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/attribute_mapper/available_folders.rb b/lib/sequencer/unit/import/exchange/attribute_mapper/available_folders.rb
new file mode 100644
index 000000000..c1b91c187
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/attribute_mapper/available_folders.rb
@@ -0,0 +1,18 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module AttributeMapper
+ class AvailableFolders < Sequencer::Unit::Common::AttributeMapper
+
+ def self.map
+ {
+ ews_folder_id_path_map: :folders,
+ }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contact/http_log.rb b/lib/sequencer/unit/import/exchange/folder_contact/http_log.rb
new file mode 100644
index 000000000..eefb9c553
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contact/http_log.rb
@@ -0,0 +1,18 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContact
+ class HttpLog < Import::Common::Model::HttpLog
+
+ private
+
+ def facility
+ 'EWS'
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contact/mapping.rb b/lib/sequencer/unit/import/exchange/folder_contact/mapping.rb
new file mode 100644
index 000000000..076492699
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contact/mapping.rb
@@ -0,0 +1,27 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContact
+ class Mapping < Sequencer::Unit::Import::Common::Mapping::FlatKeys
+
+ uses :import_job
+
+ private
+
+ def mapping
+ from_import_job || ::Import::Exchange.config[:attributes]
+ end
+
+ def from_import_job
+ return if !state.provided?(:import_job)
+ payload = import_job.payload
+ return if payload.blank?
+ payload[:ews_attributes]
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contact/remote_id.rb b/lib/sequencer/unit/import/exchange/folder_contact/remote_id.rb
new file mode 100644
index 000000000..8cbfe713f
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contact/remote_id.rb
@@ -0,0 +1,17 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContact
+ class RemoteId < Sequencer::Unit::Import::Common::Model::Attributes::RemoteId
+ private
+
+ def attribute
+ :item_id
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contact/static_attributes.rb b/lib/sequencer/unit/import/exchange/folder_contact/static_attributes.rb
new file mode 100644
index 000000000..e7e22e026
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contact/static_attributes.rb
@@ -0,0 +1,19 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContact
+ class StaticAttributes < Sequencer::Unit::Base
+
+ provides :model_class, :external_sync_source
+
+ def process
+ state.provide(:model_class, ::User)
+ state.provide(:external_sync_source, 'EWS::FolderContact')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contact/statistics/diff.rb b/lib/sequencer/unit/import/exchange/folder_contact/statistics/diff.rb
new file mode 100644
index 000000000..9ed81f27f
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contact/statistics/diff.rb
@@ -0,0 +1,39 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContact
+ module Statistics
+ class Diff < Sequencer::Unit::Base
+ include ::Sequencer::Unit::Import::Common::Model::Statistics::Mixin::Diff
+
+ uses :ews_folder_name
+
+ def process
+ state.provide(:statistics_diff) do
+ # remove :sum since it's already set via
+ # the exchange item attribte
+ result = diff.except(:sum)
+
+ # build structure for a general diff
+ # and a folder specific sub structure
+ result.merge(
+ folders: {
+ ews_folder_name => result
+ }
+ )
+ end
+ end
+
+ private
+
+ def actions
+ %i(created updated unchanged skipped failed)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/dry_run_payload.rb b/lib/sequencer/unit/import/exchange/folder_contacts/dry_run_payload.rb
new file mode 100644
index 000000000..5827d25d1
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contacts/dry_run_payload.rb
@@ -0,0 +1,14 @@
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContacts
+ class DryRunPayload < Sequencer::Unit::Import::Common::ImportJob::Payload::ToState
+
+ provides :ews_config, :ews_folder_ids
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/folder_ids.rb b/lib/sequencer/unit/import/exchange/folder_contacts/folder_ids.rb
new file mode 100644
index 000000000..5e29a9cee
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contacts/folder_ids.rb
@@ -0,0 +1,27 @@
+require 'sequencer/mixin/exchange/folder'
+
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContacts
+ class FolderIds < Sequencer::Unit::Base
+ include ::Sequencer::Mixin::Exchange::Folder
+
+ provides :ews_folder_ids
+
+ def process
+ # check if ids are already processed
+ return if state.provided?(:ews_folder_ids)
+
+ state.provide(:ews_folder_ids) do
+ config = ::Import::Exchange.config
+ config[:folders]
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/sub_sequence.rb b/lib/sequencer/unit/import/exchange/folder_contacts/sub_sequence.rb
new file mode 100644
index 000000000..6ddd1c970
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contacts/sub_sequence.rb
@@ -0,0 +1,37 @@
+require 'sequencer/mixin/exchange/folder'
+require 'sequencer/mixin/import_job/resource_loop'
+
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContacts
+ class SubSequence < Sequencer::Unit::Base
+ include ::Sequencer::Mixin::Exchange::Folder
+ include ::Sequencer::Mixin::ImportJob::ResourceLoop
+
+ uses :ews_folder_ids, :import_job
+
+ def process
+
+ ews_folder_ids.each do |folder_id|
+ folder = ews_folder.find(folder_id)
+ display_path = ews_folder.display_path(folder)
+
+ resource_sequence('Import::Exchange::FolderContact', folder.items) do |item|
+
+ logger.debug("Extracting attributes from Exchange item: #{item.get_all_properties!.inspect}")
+
+ {
+ resource: ::Import::Exchange::ItemAttributes.extract(item),
+ ews_folder_name: display_path,
+ }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/sum.rb b/lib/sequencer/unit/import/exchange/folder_contacts/sum.rb
new file mode 100644
index 000000000..1f73d01d4
--- /dev/null
+++ b/lib/sequencer/unit/import/exchange/folder_contacts/sum.rb
@@ -0,0 +1,48 @@
+require 'sequencer/mixin/exchange/folder'
+
+class Sequencer
+ class Unit
+ module Import
+ module Exchange
+ module FolderContacts
+ class Sum < Sequencer::Unit::Base
+ include ::Sequencer::Mixin::Exchange::Folder
+
+ uses :ews_folder_ids
+ provides :statistics_diff
+
+ def process
+ state.provide(:statistics_diff, diff)
+ end
+
+ private
+
+ def diff
+ result = {
+ sum: 0,
+ }
+ folder_sum_map.each do |display_path, sum|
+
+ result[display_path] = {
+ sum: sum
+ }
+
+ result[:sum] += sum
+ end
+ result
+ end
+
+ def folder_sum_map
+ ews_folder_ids.collect do |folder_id|
+ folder = ews_folder.find(folder_id)
+ display_path = ews_folder.display_path(folder)
+
+ [display_path, folder.total_count]
+ end.to_h
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/unit/mixin/dynamic_attribute.rb b/lib/sequencer/unit/mixin/dynamic_attribute.rb
new file mode 100644
index 000000000..e37012d90
--- /dev/null
+++ b/lib/sequencer/unit/mixin/dynamic_attribute.rb
@@ -0,0 +1,35 @@
+# rubocop:disable Lint/NestedMethodDefinition
+class Sequencer
+ class Unit
+ module Mixin
+ module DynamicAttribute
+
+ def self.included(base)
+
+ class << base
+
+ def inherited(base)
+ base.extend(Forwardable)
+ base.instance_delegate [:attribute] => base
+ end
+
+ def attribute
+ @attribute ||= begin
+ if uses.size != 1
+ raise "DynamicAttribute classes can use exactly one attribute. Found #{uses.size}."
+ end
+ uses.first
+ end
+ end
+ end
+ end
+
+ private
+
+ def attribute_value
+ @attribute_value ||= state.use(attribute)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/units.rb b/lib/sequencer/units.rb
new file mode 100644
index 000000000..e926ead87
--- /dev/null
+++ b/lib/sequencer/units.rb
@@ -0,0 +1,70 @@
+require 'mixin/instance_wrapper'
+
+class Sequencer
+ class Units
+ include ::Enumerable
+ include ::Mixin::InstanceWrapper
+
+ wrap :@units
+
+ # Initializes a new Sequencer::Units instance with the given Units Array.
+ #
+ # @param [*Array] *units a list of Units with or without the Sequencer::Unit prefix.
+ #
+ # @example
+ # Sequencer::Units.new('Example::Unit', 'Sequencer::Unit::Second::Unit')
+ def initialize(*units)
+ @units = units
+ end
+
+ # Required #each implementation for ::Enumerable functionality. Constantizes
+ # the list of units given when initialized.
+ #
+ # @example
+ # units.each do |unit|
+ # unit.process(sequencer)
+ # end
+ #
+ # @return [nil]
+ def each
+ @units.each do |unit|
+ yield constantize(unit)
+ end
+ end
+
+ # Provides an Array of :uses and :provides declarations for each Unit.
+ #
+ # @example
+ # units.declarations
+ # #=> [{uses: [:question], provides: [:answer], ...}]
+ #
+ # @return [Array Array}>] the declarations of the Units
+ def declarations
+ collect do |unit|
+ {
+ uses: unit.uses,
+ provides: unit.provides,
+ }
+ end
+ end
+
+ # Enables the access to an Unit class via index.
+ #
+ # @param [Integer] index the index for the requested Unit class.
+ #
+ # @example
+ # units[1]
+ # #=> Sequencer::Unit::Example
+ #
+ # @return [Object] the Unit class for the requested index
+ def [](index)
+ constantize(@units[index])
+ end
+
+ private
+
+ def constantize(unit)
+ Sequencer::Unit.constantize(unit)
+ end
+ end
+end
diff --git a/lib/sequencer/units/attribute.rb b/lib/sequencer/units/attribute.rb
new file mode 100644
index 000000000..3e716bb2b
--- /dev/null
+++ b/lib/sequencer/units/attribute.rb
@@ -0,0 +1,30 @@
+class Sequencer
+ class Units
+ class Attribute
+
+ attr_accessor :from, :to
+
+ # Checks if the attribute will be provided by one or more Units.
+ #
+ # @example
+ # attribute.will_be_provided?
+ # # => true
+ #
+ # @return [Boolean]
+ def will_be_provided?
+ !from.nil?
+ end
+
+ # Checks if the attribute will be used by one or more Units.
+ #
+ # @example
+ # attribute.will_be_used?
+ # # => true
+ #
+ # @return [Boolean]
+ def will_be_used?
+ !to.nil?
+ end
+ end
+ end
+end
diff --git a/lib/sequencer/units/attributes.rb b/lib/sequencer/units/attributes.rb
new file mode 100644
index 000000000..f9d47aa29
--- /dev/null
+++ b/lib/sequencer/units/attributes.rb
@@ -0,0 +1,87 @@
+require 'mixin/instance_wrapper'
+
+class Sequencer
+ class Units
+ class Attributes
+ include ::Mixin::InstanceWrapper
+
+ wrap :@attributes
+
+ # Initializes the lifespan store for the attributes
+ # of the given Units declarations.
+ #
+ # @param [Array Array<:Symbol>}>] declarations the list of Unit declarations.
+ #
+ # @example
+ # declarations = [{uses: [:attribute1, ...], provides: [:result], ...}]
+ # attributes = Sequencer::Units::Attributes(declarations)
+ #
+ # @return [nil]
+ def initialize(declarations)
+ @declarations = declarations
+
+ initialize_attributes
+ initialize_lifespan
+ end
+
+ # Lists all `provides` declarations of the Units the instance was initialized with.
+ #
+ # @example
+ # attributes.provided
+ # # => [:result, ...]
+ #
+ # @return [Array] the list of all `provides` declarations
+ def provided
+ select do |_attribute, instance|
+ instance.will_be_provided?
+ end.keys
+ end
+
+ # Lists all `uses` declarations of the Units the instance was initialized with.
+ #
+ # @example
+ # attributes.used
+ # # => [:attribute1, ...]
+ #
+ # @return [Array] the list of all `uses` declarations
+ def used
+ select do |_attribute, instance|
+ instance.will_be_used?
+ end.keys
+ end
+
+ # Checks if the given attribute is known in the list of Unit declarations.
+ #
+ # @example
+ # attributes.known?(:attribute2)
+ # # => false
+ #
+ # @return [Boolean]
+ def known?(attribute)
+ key?(attribute)
+ end
+
+ private
+
+ def initialize_attributes
+ @attributes = Hash.new do |hash, key|
+ hash[key] = Sequencer::Units::Attribute.new
+ end
+ end
+
+ def initialize_lifespan
+ @declarations.each_with_index do |unit, index|
+
+ unit[:uses].try(:each) do |attribute|
+ self[attribute].to = index
+ end
+
+ unit[:provides].try(:each) do |attribute|
+ next if self[attribute].will_be_provided?
+ self[attribute].from = index
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/import/base_resource_spec.rb b/spec/lib/import/base_resource_spec.rb
index f0fb77016..8b5321321 100644
--- a/spec/lib/import/base_resource_spec.rb
+++ b/spec/lib/import/base_resource_spec.rb
@@ -1,7 +1,5 @@
require 'rails_helper'
-RSpec::Matchers.define_negated_matcher :not_change, :change
-
RSpec.describe Import::BaseResource do
it "needs an implementation of the 'import_class' method" do
diff --git a/spec/lib/import/ldap/user_spec.rb b/spec/lib/import/ldap/user_spec.rb
index f49140828..14e76c5bd 100644
--- a/spec/lib/import/ldap/user_spec.rb
+++ b/spec/lib/import/ldap/user_spec.rb
@@ -1,8 +1,6 @@
require 'rails_helper'
require 'import/ldap/user'
-RSpec::Matchers.define_negated_matcher :not_change, :change
-
RSpec.describe Import::Ldap::User do
let(:uid) { 'exampleuid' }
diff --git a/spec/lib/import/otrs/state_factory_spec.rb b/spec/lib/import/otrs/state_factory_spec.rb
index 205cfb892..734025fec 100644
--- a/spec/lib/import/otrs/state_factory_spec.rb
+++ b/spec/lib/import/otrs/state_factory_spec.rb
@@ -1,8 +1,6 @@
require 'rails_helper'
require 'lib/import/transaction_factory_examples'
-RSpec::Matchers.define_negated_matcher :not_change, :change
-
RSpec.describe Import::OTRS::StateFactory do
it_behaves_like 'Import::TransactionFactory'
diff --git a/spec/lib/sequencer/unit/common/attribute_mapper_spec.rb b/spec/lib/sequencer/unit/common/attribute_mapper_spec.rb
new file mode 100644
index 000000000..991180335
--- /dev/null
+++ b/spec/lib/sequencer/unit/common/attribute_mapper_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+RSpec.describe Sequencer::Unit::Common::AttributeMapper, sequencer: :unit do
+
+ let(:map) {
+ {
+ old_key: :new_key,
+ second: :new_second,
+ }
+ }
+
+ it 'expects an implementation of the .map method' do
+ expect do
+ described_class.map
+ end.to raise_error(RuntimeError)
+ end
+
+ it 'declares uses from map keys' do
+ expect(described_class).to receive(:map).and_return(map)
+ expect(described_class.uses).to eq(map.keys)
+ end
+
+ it 'declares provides from map values' do
+ expect(described_class).to receive(:map).and_return(map)
+ expect(described_class.provides).to eq(map.values)
+ end
+
+ it 'maps as configured' do
+
+ old = {
+ old_key: :value,
+ second: :second_value,
+ }
+
+ allow(described_class).to receive(:map).and_return(map)
+ result = process(old)
+
+ expect(result.keys.size).to eq 2
+ expect(result[:new_key]).to eq old[:old_key]
+ expect(result[:new_second]).to eq old[:second]
+ end
+end
diff --git a/spec/lib/sequencer/unit/import/common/mapping/flat_keys_spec.rb b/spec/lib/sequencer/unit/import/common/mapping/flat_keys_spec.rb
new file mode 100644
index 000000000..0ce2aacd2
--- /dev/null
+++ b/spec/lib/sequencer/unit/import/common/mapping/flat_keys_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe Sequencer::Unit::Import::Common::Mapping::FlatKeys, sequencer: :unit do
+
+ it 'raises an error if mapping method is not implemented' do
+ expect do
+ process(
+ resource: {
+ remote_attribute: 'value',
+ }
+ )
+ end.to raise_error(RuntimeError, /mapping/)
+ end
+
+ it 'maps flat key structures' do
+
+ parameters = {
+ resource: {
+ remote_attribute: 'value',
+ }
+ }
+
+ mapping = {
+ remote_attribute: :local_attribute
+ }
+
+ provided = process(parameters) do |instance|
+ expect(instance).to receive(:mapping).and_return(mapping)
+ end
+
+ expect(provided).to eq(
+ mapped: {
+ 'local_attribute' => 'value',
+ }
+ )
+ expect(provided[:mapped]).to be_a(ActiveSupport::HashWithIndifferentAccess)
+ end
+end
diff --git a/spec/models/scheduler_spec.rb b/spec/models/scheduler_spec.rb
index 6f9302e65..7432186d0 100644
--- a/spec/models/scheduler_spec.rb
+++ b/spec/models/scheduler_spec.rb
@@ -1,7 +1,5 @@
require 'rails_helper'
-RSpec::Matchers.define_negated_matcher :not_change, :change
-
RSpec.describe Scheduler do
before do
diff --git a/spec/support/negated_matchers.rb b/spec/support/negated_matchers.rb
new file mode 100644
index 000000000..93f5b66e5
--- /dev/null
+++ b/spec/support/negated_matchers.rb
@@ -0,0 +1 @@
+RSpec::Matchers.define_negated_matcher :not_change, :change
diff --git a/spec/support/sequencer.rb b/spec/support/sequencer.rb
new file mode 100644
index 000000000..bbbfa9f8b
--- /dev/null
+++ b/spec/support/sequencer.rb
@@ -0,0 +1,19 @@
+module SequencerUnit
+
+ def process(parameters, &block)
+ Sequencer::Unit.process(described_class.name, parameters, &block)
+ end
+end
+
+module SequencerSequence
+
+ def process(parameters = {})
+ Sequencer.process(described_class.name,
+ parameters: parameters)
+ end
+end
+
+RSpec.configure do |config|
+ config.include SequencerUnit, sequencer: :unit
+ config.include SequencerSequence, sequencer: :sequence
+end
diff --git a/spec/support/system_init.rb b/spec/support/system_init.rb
index 2ff4deb8c..3b4bcec18 100644
--- a/spec/support/system_init.rb
+++ b/spec/support/system_init.rb
@@ -1,11 +1,15 @@
RSpec.configure do |config|
config.before(:suite) do
- FactoryGirl.create(:user,
- login: 'admin',
- firstname: 'Admin',
- lastname: 'Admin',
- email: 'admin@example.com',
- password: 'admin',
- roles: [Role.lookup(name: 'Admin')],)
+
+ email = 'admin@example.com'
+ if !::User.exists?(email: email)
+ FactoryGirl.create(:user,
+ login: 'admin',
+ firstname: 'Admin',
+ lastname: 'Admin',
+ email: email,
+ password: 'admin',
+ roles: [Role.lookup(name: 'Admin')],)
+ end
end
end
|