- Added Exchange integration.
- Added Sequencer. - Prepared migration of LDAP integration to Sequencer. - Added and improved RSpec support helpers.
This commit is contained in:
parent
2b4b1a58cc
commit
4937d742ea
101 changed files with 4560 additions and 125 deletions
3
Gemfile
3
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'
|
||||
|
|
30
Gemfile.lock
30
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
|
||||
|
|
|
@ -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'
|
||||
)
|
|
@ -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) =>
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
<div class="js-lastImport"></div>
|
||||
<div class="js-notConfigured">
|
||||
<p><%- @T('No %s configured.', 'Exchange') %></p>
|
||||
<button type="submit" class="btn btn--primary js-wizard"><%- @T('Configure') %></button>
|
||||
</div>
|
||||
<div class="js-summary hide">
|
||||
<h2><%- @T('Settings') %></h2>
|
||||
<table class="settings-list" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="30%"><%- @T('Name') %>
|
||||
<th width="70%"><%- @T('Value') %>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('Endpoint') %>
|
||||
<td class="settings-list-row-control"><%= @config.endpoint %>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('User') %>
|
||||
<td class="settings-list-row-control"><%= @config.user %>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('Password') %>
|
||||
<td class="settings-list-row-control"><%= @M(@config.password) %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><%- @T('Mapping') %></h2>
|
||||
|
||||
<h3><%- @T('Folders') %></h3>
|
||||
<% if _.isEmpty(@folders): %>
|
||||
<table class="settings-list settings-list--stretch settings-list--placeholder">
|
||||
<thead><tr><th><%- @T('No Entries') %>
|
||||
</table>
|
||||
<% else: %>
|
||||
<table class="settings-list" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%- @T('Folder') %>
|
||||
<% for folder_name in @folders: %>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%= folder_name %>
|
||||
<% end %>
|
||||
</thead>
|
||||
<tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
|
||||
<h3><%- @T('User') %></h3>
|
||||
<% if _.isEmpty(@config.attributes): %>
|
||||
<table class="settings-list settings-list--stretch settings-list--placeholder">
|
||||
<thead><tr><th><%- @T('No Entries') %>
|
||||
</table>
|
||||
<% else: %>
|
||||
<table class="settings-list" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="40%"><%- @T('Exchange') %>
|
||||
<th width="60%"><%- @T('Zammad') %>
|
||||
<% for key, value of @config.attributes: %>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%= key %>
|
||||
<td class="settings-list-row-control"><%= value %>
|
||||
<% end %>
|
||||
</thead>
|
||||
<tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
|
||||
<button type="submit" class="btn btn--primary js-wizard"><%- @T('Change') %></button>
|
||||
</div>
|
|
@ -0,0 +1,54 @@
|
|||
<div class="box box--message">
|
||||
<h2><%- @T('Last sync') %></h2>
|
||||
<% if _.isEmpty(@job.started_at): %>
|
||||
<% if @job.result && @job.result.error: %>
|
||||
<div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
|
||||
<% else if @job.result && @job.result.info: %>
|
||||
<div class="alert alert--info" role="alert"><%- @T('Info: %s', @job.result.info) %></div>
|
||||
<% else: %>
|
||||
<p><%- @T('Job is waiting to get started...') %></p>
|
||||
<% end %>
|
||||
<% else: %>
|
||||
<% if @job.finished_at: %>
|
||||
<p><%- @Ttimestamp(@job.started_at) %> - <%- @Ttimestamp(@job.finished_at) %></p>
|
||||
<% if @job.result && @job.result.error: %>
|
||||
<div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
|
||||
<% else if @job.result && @job.result.info: %>
|
||||
<div class="alert alert--info" role="alert"><%- @T('Info: %s', @job.result.info) %></div>
|
||||
<% end %>
|
||||
<% else: %>
|
||||
<% if @job.result && @job.result.error: %>
|
||||
<p><%- @Ttimestamp(@job.started_at) %></p>
|
||||
<div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
|
||||
<% else if !@countDone: %>
|
||||
<p><%- @Ttimestamp(@job.started_at) %> - <%- @T('Counting entries. This may take a while.') %></p>
|
||||
<% else: %>
|
||||
<p><%- @Ttimestamp(@job.started_at) %> - <%- @T('Running...') %></p>
|
||||
<div class="flex">
|
||||
<progress max="<%= @job.result.sum %>" value="<%= @countDone %>"></progress>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if !_.isEmpty(@job.result) && @countDone: %>
|
||||
<ul>
|
||||
<li><%- @T('%s user to %s user', 'Exchange', 'Zammad') %> (<%= @countDone %>/<%= @job.result.sum %>):
|
||||
<ul>
|
||||
<li><%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>
|
||||
</ul>
|
||||
</li>
|
||||
<% if !_.isEmpty(@job.result.folders): %>
|
||||
<li><%- @T('%s folders', 'Exchange') %>:
|
||||
<ul>
|
||||
<% for folder, result of @job.result.folders: %>
|
||||
<li><%- folder %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>
|
||||
<% end %>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
<% if @job.finished_at: %>
|
||||
<button type="submit" class="btn btn--primary js-start-sync"><%- @T('Start new') %></button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
|
@ -0,0 +1,16 @@
|
|||
<ul>
|
||||
<li><%- @T('%s user to %s user', 'Exchange', 'Zammad') %> (<%= @countDone %>):
|
||||
<ul>
|
||||
<li><%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>
|
||||
</ul>
|
||||
</li>
|
||||
<% if !_.isEmpty(@job.result.folders): %>
|
||||
<li><%- @T('%s folders', 'Exchange') %>:
|
||||
<ul>
|
||||
<% for folder, result of @job.result.folders: %>
|
||||
<li><%- folder %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>
|
||||
<% end %>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
|
@ -0,0 +1,7 @@
|
|||
<tr class="js-entry">
|
||||
<td style="max-width: 240px" class="settings-list-control-cell js-exchangeAttribute">
|
||||
<td class="settings-list-control-cell js-userAttribute">
|
||||
<td class="settings-list-row-control">
|
||||
<div class="btn btn--text js-remove">
|
||||
<%- @Icon('trash') %> <%- @T('Remove') %>
|
||||
</div>
|
|
@ -0,0 +1,258 @@
|
|||
<div class="modal-dialog">
|
||||
|
||||
<form class="modal-content setup wizard js-discover">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
<table class="settings-list" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="30%"><%- @T('Name') %>
|
||||
<th width="70%"><%- @T('Value') %>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('User') %>
|
||||
<td class="settings-list-control-cell"><input type="text" name="user" class="form-control form-control--small js-user" value="" placeholder="" autocomplete="off" required>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('Password') %>
|
||||
<td class="settings-list-control-cell"><input type="password" name="password" class="form-control form-control--small js-password" value="" placeholder="" autocomplete="new-password" required>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-rightFooter">
|
||||
<button class="btn btn--primary align-right js-submit"><%- @T('Connect') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="modal-content setup wizard hide js-connect">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<p class="wizard-loadingText">
|
||||
<span class="loading icon"></span> <%- @T('Connecting ...') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</form>
|
||||
|
||||
<form class="modal-content setup wizard hide js-bind">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
<table class="settings-list" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="30%"><%- @T('Name') %>
|
||||
<th width="70%"><%- @T('Value') %>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('Endpoint') %>
|
||||
<td class="settings-list-control-cell"><input type="text" name="endpoint" class="form-control form-control--small" value="" placeholder="https://outlook.office365.com/EWS/Exchange.asmx" autocomplete="off" required>
|
||||
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('User') %>
|
||||
<td class="settings-list-control-cell"><input type="text" name="user" class="form-control form-control--small js-user" value="" placeholder="" autocomplete="off" required>
|
||||
<tr>
|
||||
<td class="settings-list-row-control"><%- @T('Password') %>
|
||||
<td class="settings-list-control-cell"><input type="password" name="password" class="form-control form-control--small js-password" value="" placeholder="" autocomplete="new-password" required>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-rightFooter">
|
||||
<button class="btn btn--primary align-right js-submit"><%- @T('Connect') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="modal-content setup wizard hide js-folders">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Folders') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
|
||||
<div class="column_select form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="folders"><%- @T('Import %s', 'Folders') %></label>
|
||||
</div>
|
||||
<div class="controls js-foldersSelect">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-rightFooter">
|
||||
<a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-bind"><%- @T('Go Back') %></a>
|
||||
</div>
|
||||
<div class="modal-rightFooter">
|
||||
<button class="btn btn--primary align-right js-submitTry"><%- @T('Continue') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="modal-content setup wizard hide js-analyze">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<p class="wizard-loadingText">
|
||||
<span class="loading icon"></span> <%- @T('Analyzing structure...') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</form>
|
||||
|
||||
<div class="modal-content setup wizard hide js-mapping">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Mapping') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
|
||||
<h2><%- @T('User') %></h2>
|
||||
<form class="js-userMappingForm">
|
||||
<table class="settings-list js-userAttributeMap" style="width: 100%;">
|
||||
<colgroup>
|
||||
<col width="240">
|
||||
<col>
|
||||
<col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%- @T('%s Attribute', 'Exchange') %>
|
||||
<th><%- @T('%s Attribute', 'Zammad') %>
|
||||
<th><%- @T('Action') %>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="settings-list-row-control" colspan="3">
|
||||
<div class="btn btn--text btn--create js-add">
|
||||
<%- @Icon('plus-small') %> <%- @T('Add') %>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-rightFooter">
|
||||
<a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-bind"><%- @T('Go Back') %></a>
|
||||
</div>
|
||||
<div class="modal-rightFooter">
|
||||
<button class="btn btn--primary align-right js-submitTry"><%- @T('Continue') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="modal-content setup wizard hide js-dry">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="js-preprogress">
|
||||
<p class="wizard-loadingText">
|
||||
<span class="loading icon"></span>
|
||||
<%- @T('Counting entries. This may take a while.') %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="js-analyzing hide">
|
||||
<p class="wizard-loadingText">
|
||||
<%- @T('Analyzing entries with given configuration...') %>
|
||||
</p>
|
||||
<div class="centered js-progress">
|
||||
<progress max="" value=""></progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</form>
|
||||
|
||||
<div class="modal-content setup wizard hide js-try">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
<p><%- @T('With your current configuration the following will happen') %>:</p>
|
||||
<div class="js-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-rightFooter">
|
||||
<a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-mapping"><%- @T('Go Back') %></a>
|
||||
</div>
|
||||
<div class="modal-rightFooter">
|
||||
<button class="btn btn--primary align-right js-submitSave"><%- @T('Save configuration') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="modal-content setup wizard hide js-error">
|
||||
<div class="modal-header">
|
||||
<div class="modal-close js-close">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<h1 class="modal-title"><%- @T('Exchange') %></h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-rightFooter">
|
||||
<button class="btn btn--primary align-right"><%- @T('Cancel') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
89
app/controllers/concerns/integration/import_job_base.rb
Normal file
89
app/controllers/concerns/integration/import_job_base.rb
Normal file
|
@ -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
|
66
app/controllers/integration/exchange_controller.rb
Normal file
66
app/controllers/integration/exchange_controller.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
11
config/routes/integration_exchange.rb
Normal file
11
config/routes/integration_exchange.rb
Normal file
|
@ -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
|
51
db/migrate/20170629000001_exchange_integration.rb
Normal file
51
db/migrate/20170629000001_exchange_integration.rb
Normal file
|
@ -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
|
|
@ -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',
|
||||
|
|
13
lib/import/exchange.rb
Normal file
13
lib/import/exchange.rb
Normal file
|
@ -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
|
76
lib/import/exchange/folder.rb
Normal file
76
lib/import/exchange/folder.rb
Normal file
|
@ -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
|
111
lib/import/exchange/item_attributes.rb
Normal file
111
lib/import/exchange/item_attributes.rb
Normal file
|
@ -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
|
70
lib/import/helper/attributes_examples.rb
Normal file
70
lib/import/helper/attributes_examples.rb
Normal file
|
@ -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
|
143
lib/import/integration_base.rb
Normal file
143
lib/import/integration_base.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
19
lib/import/mixin/sequence.rb
Normal file
19
lib/import/mixin/sequence.rb
Normal file
|
@ -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
|
43
lib/mixin/instance_wrapper.rb
Normal file
43
lib/mixin/instance_wrapper.rb
Normal file
|
@ -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
|
9
lib/mixin/rails_logger.rb
Normal file
9
lib/mixin/rails_logger.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
module Mixin
|
||||
module RailsLogger
|
||||
extend Forwardable
|
||||
extend SingleForwardable
|
||||
|
||||
instance_delegate [:logger] => self
|
||||
single_delegate [:logger] => :Rails
|
||||
end
|
||||
end
|
44
lib/mixin/required_sub_paths.rb
Normal file
44
lib/mixin/required_sub_paths.rb
Normal file
|
@ -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
|
11
lib/mixin/start_finish_logger.rb
Normal file
11
lib/mixin/start_finish_logger.rb
Normal file
|
@ -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
|
83
lib/sequencer.rb
Normal file
83
lib/sequencer.rb
Normal file
|
@ -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
|
18
lib/sequencer/mixin/exchange/folder.rb
Normal file
18
lib/sequencer/mixin/exchange/folder.rb
Normal file
|
@ -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
|
37
lib/sequencer/mixin/import_job/resource_loop.rb
Normal file
37
lib/sequencer/mixin/import_job/resource_loop.rb
Normal file
|
@ -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
|
43
lib/sequencer/mixin/prefixed_constantize.rb
Normal file
43
lib/sequencer/mixin/prefixed_constantize.rb
Normal file
|
@ -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
|
9
lib/sequencer/mixin/sub_sequence.rb
Normal file
9
lib/sequencer/mixin/sub_sequence.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class Sequencer
|
||||
module Mixin
|
||||
module SubSequence
|
||||
def sub_sequence(sequence, args = {})
|
||||
Sequencer.process(sequence, args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
16
lib/sequencer/sequence.rb
Normal file
16
lib/sequencer/sequence.rb
Normal file
|
@ -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
|
48
lib/sequencer/sequence/base.rb
Normal file
48
lib/sequencer/sequence/base.rb
Normal file
|
@ -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<Symbol>] 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<String>] 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
|
||||
# # => <Sequencer::Units @units=[....>
|
||||
#
|
||||
# @return [Object]
|
||||
def self.units
|
||||
Sequencer::Units.new(*sequence)
|
||||
end
|
||||
|
||||
# @see .units
|
||||
def units
|
||||
self.class.units
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
17
lib/sequencer/sequence/exchange/folder/attributes.rb
Normal file
17
lib/sequencer/sequence/exchange/folder/attributes.rb
Normal file
|
@ -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
|
|
@ -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
|
22
lib/sequencer/sequence/import/exchange/available_folders.rb
Normal file
22
lib/sequencer/sequence/import/exchange/available_folders.rb
Normal file
|
@ -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
|
33
lib/sequencer/sequence/import/exchange/folder_contact.rb
Normal file
33
lib/sequencer/sequence/import/exchange/folder_contact.rb
Normal file
|
@ -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
|
22
lib/sequencer/sequence/import/exchange/folder_contacts.rb
Normal file
22
lib/sequencer/sequence/import/exchange/folder_contacts.rb
Normal file
|
@ -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
|
272
lib/sequencer/state.rb
Normal file
272
lib/sequencer/state.rb
Normal file
|
@ -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
|
71
lib/sequencer/unit.rb
Normal file
71
lib/sequencer/unit.rb
Normal file
|
@ -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
|
212
lib/sequencer/unit/base.rb
Normal file
212
lib/sequencer/unit/base.rb
Normal file
|
@ -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<Symbol>] attributes an optional list of attributes that the Unit uses
|
||||
#
|
||||
# @yield [] A block returning a list of attributes
|
||||
#
|
||||
# @example Via regular Array<Symbol> 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<Symbol>] 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<Symbol>] attributes an optional list of attributes that the Unit provides
|
||||
#
|
||||
# @yield [] A block returning a list of attributes
|
||||
#
|
||||
# @example Via regular Array<Symbol> 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<Symbol>] 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
|
28
lib/sequencer/unit/common/attribute_mapper.rb
Normal file
28
lib/sequencer/unit/common/attribute_mapper.rb
Normal file
|
@ -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
|
23
lib/sequencer/unit/exchange/connection.rb
Normal file
23
lib/sequencer/unit/exchange/connection.rb
Normal file
|
@ -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
|
24
lib/sequencer/unit/exchange/folders/by_ids.rb
Normal file
24
lib/sequencer/unit/exchange/folders/by_ids.rb
Normal file
|
@ -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
|
31
lib/sequencer/unit/exchange/folders/id_path_map.rb
Normal file
31
lib/sequencer/unit/exchange/folders/id_path_map.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
35
lib/sequencer/unit/import/common/mapping/flat_keys.rb
Normal file
35
lib/sequencer/unit/import/common/mapping/flat_keys.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
25
lib/sequencer/unit/import/common/model/create.rb
Normal file
25
lib/sequencer/unit/import/common/model/create.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
50
lib/sequencer/unit/import/common/model/http_log.rb
Normal file
50
lib/sequencer/unit/import/common/model/http_log.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
25
lib/sequencer/unit/import/common/model/save.rb
Normal file
25
lib/sequencer/unit/import/common/model/save.rb
Normal file
|
@ -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
|
43
lib/sequencer/unit/import/common/model/skip/blank/base.rb
Normal file
43
lib/sequencer/unit/import/common/model/skip/blank/base.rb
Normal file
|
@ -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
|
17
lib/sequencer/unit/import/common/model/skip/blank/mapped.rb
Normal file
17
lib/sequencer/unit/import/common/model/skip/blank/mapped.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
21
lib/sequencer/unit/import/common/model/statistics/diff.rb
Normal file
21
lib/sequencer/unit/import/common/model/statistics/diff.rb
Normal file
|
@ -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
|
|
@ -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
|
56
lib/sequencer/unit/import/common/model/update.rb
Normal file
56
lib/sequencer/unit/import/common/model/update.rb
Normal file
|
@ -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
|
24
lib/sequencer/unit/import/common/user/attributes/downcase.rb
Normal file
24
lib/sequencer/unit/import/common/user/attributes/downcase.rb
Normal file
|
@ -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
|
|
@ -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
|
32
lib/sequencer/unit/import/exchange/attribute_examples.rb
Normal file
32
lib/sequencer/unit/import/exchange/attribute_examples.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
27
lib/sequencer/unit/import/exchange/folder_contact/mapping.rb
Normal file
27
lib/sequencer/unit/import/exchange/folder_contact/mapping.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
48
lib/sequencer/unit/import/exchange/folder_contacts/sum.rb
Normal file
48
lib/sequencer/unit/import/exchange/folder_contacts/sum.rb
Normal file
|
@ -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
|
35
lib/sequencer/unit/mixin/dynamic_attribute.rb
Normal file
35
lib/sequencer/unit/mixin/dynamic_attribute.rb
Normal file
|
@ -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
|
70
lib/sequencer/units.rb
Normal file
70
lib/sequencer/units.rb
Normal file
|
@ -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<String>] *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<Hash{Symbol => Array<Symbol>}>] 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
|
30
lib/sequencer/units/attribute.rb
Normal file
30
lib/sequencer/units/attribute.rb
Normal file
|
@ -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
|
87
lib/sequencer/units/attributes.rb
Normal file
87
lib/sequencer/units/attributes.rb
Normal file
|
@ -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<Hash{Symbol => 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<Symbol>] 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<Symbol>] 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
|
|
@ -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
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
42
spec/lib/sequencer/unit/common/attribute_mapper_spec.rb
Normal file
42
spec/lib/sequencer/unit/common/attribute_mapper_spec.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -1,7 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec::Matchers.define_negated_matcher :not_change, :change
|
||||
|
||||
RSpec.describe Scheduler do
|
||||
|
||||
before do
|
||||
|
|
1
spec/support/negated_matchers.rb
Normal file
1
spec/support/negated_matchers.rb
Normal file
|
@ -0,0 +1 @@
|
|||
RSpec::Matchers.define_negated_matcher :not_change, :change
|
19
spec/support/sequencer.rb
Normal file
19
spec/support/sequencer.rb
Normal file
|
@ -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
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue