- Added Exchange integration.

- Added Sequencer.
- Prepared migration of LDAP integration to Sequencer.
- Added and improved RSpec support helpers.
This commit is contained in:
Thorsten Eckel 2017-08-14 13:56:23 +02:00
parent 2b4b1a58cc
commit 4937d742ea
101 changed files with 4560 additions and 125 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View file

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

View 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

View 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

View file

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

View 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

View 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

View 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

View 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

View file

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

View 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

View 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

View file

@ -0,0 +1,9 @@
module Mixin
module RailsLogger
extend Forwardable
extend SingleForwardable
instance_delegate [:logger] => self
single_delegate [:logger] => :Rails
end
end

View 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

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

View 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

View 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

View 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

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

View 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

View 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

View file

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

View 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

View 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

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

View 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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View 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

View file

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

View 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

View 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

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

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

View 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

View 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

View file

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

View file

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

View file

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

View 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

View file

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

View file

@ -1,7 +1,5 @@
require 'rails_helper'
RSpec::Matchers.define_negated_matcher :not_change, :change
RSpec.describe Scheduler do
before do

View file

@ -0,0 +1 @@
RSpec::Matchers.define_negated_matcher :not_change, :change

19
spec/support/sequencer.rb Normal file
View 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