onebody-updateagent 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.markdown +81 -0
- data/bin/update_onebody +105 -0
- data/example.yml +26 -0
- data/lib/onebody-updateagent.rb +21 -0
- data/lib/updateagent/converters/acs_converter.rb +100 -0
- data/lib/updateagent/hash_extensions.rb +24 -0
- data/lib/updateagent/resources.rb +13 -0
- data/lib/updateagent/schema.rb +15 -0
- data/lib/updateagent/updateagent.rb +245 -0
- data/lib/updateagent/updaters/external_group_updater.rb +30 -0
- data/lib/updateagent/updaters/family_updater.rb +7 -0
- data/lib/updateagent/updaters/people_updater.rb +86 -0
- metadata +93 -0
data/README.markdown
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
OneBody UpdateAgent
|
2
|
+
===================
|
3
|
+
|
4
|
+
Ruby gem that pushes data from a membership data source to a remote OneBody instance via the REST API.
|
5
|
+
|
6
|
+
Download and Install
|
7
|
+
--------------------
|
8
|
+
|
9
|
+
Install [Ruby](http://ruby-lang.org) if you don't already have it.
|
10
|
+
|
11
|
+
Run the following command ("sudo" may be required in some environments):
|
12
|
+
|
13
|
+
gem install seven1m-onebody-updateagent -s http://gems.github.com
|
14
|
+
|
15
|
+
Configuration
|
16
|
+
-------------
|
17
|
+
|
18
|
+
1. Run `update_onebody` at a terminal, then take note of where the example.yml config file resides.
|
19
|
+
2. Copy the example config file to a convenient location and edit appropriately.
|
20
|
+
|
21
|
+
Your "site" address will probably be something like "http://example.com" or
|
22
|
+
"http://yoursite.beonebody.com".
|
23
|
+
|
24
|
+
You can get your user api key from OneBody (you must be a super admin) by running the following
|
25
|
+
command (on the server):
|
26
|
+
|
27
|
+
cd /path/to/onebody
|
28
|
+
rake onebody:api:key EMAIL=admin@example.com
|
29
|
+
|
30
|
+
If your instance is hosted at <http://beonebody.com>, you may email <support@beonebody.com>
|
31
|
+
to get your api key.
|
32
|
+
|
33
|
+
Preparation
|
34
|
+
-----------
|
35
|
+
|
36
|
+
Using your membership management software, reporting solution, database utility, custom script, etc.,
|
37
|
+
export your people and family data to a single comma separated values (CSV) file, e.g. people.csv.
|
38
|
+
|
39
|
+
Duplicate family data should be present for each member of the same family.
|
40
|
+
|
41
|
+
The first row of the file is the attribute headings, and must match the attributes available:
|
42
|
+
* [Person Attributes](http://github.com/seven1m/onebody/tree/master/app/models/person.rb)
|
43
|
+
* [Family Attributes](http://github.com/seven1m/onebody/tree/master/app/models/family.rb)
|
44
|
+
(prefix each attribute with "family_")
|
45
|
+
|
46
|
+
Not all attributes are required.
|
47
|
+
|
48
|
+
Optionally, if you have shell access to your hosted instance of OneBody, you can run
|
49
|
+
`rake onebody:export:people:csv` to export the current OneBody data (if you have any records in
|
50
|
+
the OneBody database) as a starting point.
|
51
|
+
|
52
|
+
Use the following attributes to track the identity/foreign keys from your existing membership
|
53
|
+
management database. Do *not* include "id" and "family_id" columns.
|
54
|
+
* legacy\_id
|
55
|
+
* legacy\_family\_id
|
56
|
+
|
57
|
+
Converters
|
58
|
+
----------
|
59
|
+
|
60
|
+
As of this writing, one Church Management System (ChMS) is supported via a Converter. The
|
61
|
+
Converter translates field names and data into the formats and locations expected by OneBody.
|
62
|
+
|
63
|
+
To use a converter, you must specify it in your config file. The example config file has these
|
64
|
+
settings disabled; simply remove the pound sign at the beginning of each line to enable the
|
65
|
+
use of the converter. Specify the name and any additional settings.
|
66
|
+
|
67
|
+
Usage
|
68
|
+
-----
|
69
|
+
|
70
|
+
To run UpdateAgent:
|
71
|
+
|
72
|
+
update_onebody -c path/to/config.yml path/to/people.csv
|
73
|
+
|
74
|
+
If you plan to schedule UpdateAgent to run periodically without human intervention,
|
75
|
+
you'll want to at least use the `-y` switch, which assumes *yes* to any questions:
|
76
|
+
|
77
|
+
update_onebody -c path/to/config.yml -y path/to/people.csv
|
78
|
+
|
79
|
+
You may also log all output:
|
80
|
+
|
81
|
+
update_onebody -c path/to/config.yml -y -l path/to/updateagent.log path/to/people.csv
|
data/bin/update_onebody
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'optparse'
|
4
|
+
require 'yaml'
|
5
|
+
|
6
|
+
options = {:confirm => true, :force => false}
|
7
|
+
config = nil
|
8
|
+
opt_parser = OptionParser.new do |opts|
|
9
|
+
opts.banner = "Usage: update_onebody -c path/to/config.yml [options] path/to/people.csv"
|
10
|
+
opts.on("-y", "--no-confirm", "Assume 'yes' to any questions") do |v|
|
11
|
+
options[:confirm] = false
|
12
|
+
end
|
13
|
+
opts.on("-l", "--log LOGFILE", "Output to log rather than stdout") do |log|
|
14
|
+
$stdout = $stderr = File.open(log, 'a')
|
15
|
+
end
|
16
|
+
opts.on("-f", "--force", "Force update all records") do |f|
|
17
|
+
options[:force] = true
|
18
|
+
end
|
19
|
+
opts.on("-t", "--test COUNT", "Test input and conversion without sending data to OneBody - show first COUNT records") do |test|
|
20
|
+
options[:test] = test.to_i
|
21
|
+
end
|
22
|
+
opts.on("-c", "--config-file PATH", "Path to configuration file") do |c|
|
23
|
+
options[:config_file] = c
|
24
|
+
config = YAML::load(File.read(c))
|
25
|
+
ONEBODY_SITE = config['site']
|
26
|
+
ONEBODY_USER_EMAIL = config['user_email']
|
27
|
+
ONEBODY_USER_KEY = config['user_key']
|
28
|
+
if c = config['converter']
|
29
|
+
ONEBODY_USE_CONVERTER = c['name']
|
30
|
+
end
|
31
|
+
end
|
32
|
+
opts.on("-g", "--groups", "Sync groups.csv rather than people.csv") do |g|
|
33
|
+
options[:sync_groups] = true
|
34
|
+
end
|
35
|
+
opts.on("-d", "--delete", "Delete people and families not found in source file") do |d|
|
36
|
+
options[:delete] = true
|
37
|
+
end
|
38
|
+
end
|
39
|
+
opt_parser.parse!
|
40
|
+
|
41
|
+
unless options[:config_file]
|
42
|
+
puts opt_parser.help
|
43
|
+
puts
|
44
|
+
puts 'You must specify a config file containing site and login info.'
|
45
|
+
puts "See #{File.expand_path(File.dirname(File.dirname(__FILE__)))}/example.yml"
|
46
|
+
puts
|
47
|
+
exit
|
48
|
+
end
|
49
|
+
|
50
|
+
if ARGV[0] # path/to/people.csv or path/to/groups.csv
|
51
|
+
$: << 'lib'
|
52
|
+
require 'onebody-updateagent'
|
53
|
+
puts "Update Agent running at #{Time.now.strftime('%m/%d/%Y %I:%M %p')}"
|
54
|
+
if options[:sync_groups]
|
55
|
+
agent = ExternalGroupUpdater.new(ARGV[0])
|
56
|
+
puts "comparing records..."
|
57
|
+
agent.compare
|
58
|
+
if agent.has_work?
|
59
|
+
if options[:confirm]
|
60
|
+
case ask("#{agent.create.length + agent.update.length} record(s) to push. Continue? (Yes, No) ") { |q| q.in = %w(yes no y n) }
|
61
|
+
when 'no', 'n'
|
62
|
+
puts "Canceled by user\n"
|
63
|
+
exit
|
64
|
+
end
|
65
|
+
end
|
66
|
+
agent.push
|
67
|
+
puts "Completed at #{Time.now.strftime('%m/%d/%Y %I:%M %p')}\n\n"
|
68
|
+
else
|
69
|
+
puts "Nothing to push\n\n"
|
70
|
+
end
|
71
|
+
else # sync people
|
72
|
+
agent = PeopleUpdater.new(ARGV[0], config)
|
73
|
+
if options[:test]
|
74
|
+
require 'pp'
|
75
|
+
pp agent.data[0...options[:test]]
|
76
|
+
else
|
77
|
+
puts "comparing records..."
|
78
|
+
agent.compare(options[:force])
|
79
|
+
if agent.has_work?
|
80
|
+
if options[:confirm]
|
81
|
+
case ask("#{agent.create.length} record(s) to create and #{agent.update.length} record(s) to update. Continue? (Yes, No, Review) ") { |q| q.in = %w(yes no review y n r) }
|
82
|
+
when 'review', 'r'
|
83
|
+
agent.present
|
84
|
+
unless agent.confirm
|
85
|
+
puts "Canceled by user\n"
|
86
|
+
exit
|
87
|
+
end
|
88
|
+
when 'no', 'n'
|
89
|
+
puts "Canceled by user\n"
|
90
|
+
exit
|
91
|
+
end
|
92
|
+
end
|
93
|
+
agent.start_sync
|
94
|
+
agent.push
|
95
|
+
agent.send_notification
|
96
|
+
agent.finish_sync
|
97
|
+
puts "Completed at #{Time.now.strftime('%m/%d/%Y %I:%M %p')}\n\n"
|
98
|
+
else
|
99
|
+
puts "Nothing to push\n\n"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
else
|
104
|
+
puts opt_parser.help
|
105
|
+
end
|
data/example.yml
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
site: http://localhost:3000
|
2
|
+
user_email: admin@example.com
|
3
|
+
user_key: dafH2KIiAcnLEr5JxjmX2oveuczq0R6u7Ijd329DtjatgdYcKp
|
4
|
+
notifications:
|
5
|
+
from_email: noreply@crccministries.com
|
6
|
+
to_email: youremail@example.com
|
7
|
+
host: mailserver
|
8
|
+
port: 25
|
9
|
+
# uncomment below to enable the Converter (see README.markdown)
|
10
|
+
#converter:
|
11
|
+
# name: ACS
|
12
|
+
# visible:
|
13
|
+
# - Member
|
14
|
+
# - Visitor
|
15
|
+
# - Prospect
|
16
|
+
# - Attender
|
17
|
+
# - College
|
18
|
+
# - Other Child
|
19
|
+
# - Other Youth
|
20
|
+
# visible_on_printed_directory:
|
21
|
+
# - Member
|
22
|
+
# - Attender
|
23
|
+
# full_access:
|
24
|
+
# - Member
|
25
|
+
# - Attender
|
26
|
+
# - College
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'rubygems'
|
3
|
+
require 'fastercsv'
|
4
|
+
require 'highline/import'
|
5
|
+
require 'activeresource'
|
6
|
+
require 'digest/sha1'
|
7
|
+
require 'net/smtp'
|
8
|
+
|
9
|
+
HighLine.track_eof = false
|
10
|
+
|
11
|
+
require 'updateagent/resources'
|
12
|
+
require 'updateagent/schema'
|
13
|
+
require 'updateagent/hash_extensions'
|
14
|
+
require 'updateagent/updateagent'
|
15
|
+
require 'updateagent/updaters/people_updater'
|
16
|
+
require 'updateagent/updaters/family_updater'
|
17
|
+
require 'updateagent/updaters/external_group_updater'
|
18
|
+
|
19
|
+
if defined?(ONEBODY_USE_CONVERTER)
|
20
|
+
require "updateagent/converters/#{ONEBODY_USE_CONVERTER.downcase}_converter"
|
21
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
class ACSConverter
|
2
|
+
|
3
|
+
def initialize(options)
|
4
|
+
@options = options
|
5
|
+
end
|
6
|
+
|
7
|
+
def straight
|
8
|
+
{
|
9
|
+
"FamilyNumber" => "legacy_family_id",
|
10
|
+
"LastName" => "last_name",
|
11
|
+
"Suffix" => "suffix",
|
12
|
+
"Address1" => "family_address1",
|
13
|
+
"Address2" => "family_address2",
|
14
|
+
"City" => "family_city",
|
15
|
+
"State" => "family_state",
|
16
|
+
"ZIPCode" => "family_zip",
|
17
|
+
"DateOfBirth" => "birthday",
|
18
|
+
"HomeEmailAddr" => "email",
|
19
|
+
"FamilyName" => "family_name",
|
20
|
+
"Gender" => "gender"
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
def convert(records)
|
25
|
+
create_family_names(records)
|
26
|
+
create_family_sequences(records)
|
27
|
+
records.map do |record|
|
28
|
+
convert_record(record)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def convert_record(record)
|
33
|
+
returning({}) do |new_record|
|
34
|
+
record.each do |key, value|
|
35
|
+
if new_key = straight[key]
|
36
|
+
new_record[new_key] = value
|
37
|
+
end
|
38
|
+
end
|
39
|
+
new_record['legacy_id'] = new_record['legacy_family_id'] + record['IndividualNumber']
|
40
|
+
new_record['first_name'] = get_first_name(record)
|
41
|
+
new_record['family_home_phone'] = get_phone(record, 'Home')
|
42
|
+
new_record['work_phone'] = get_phone(record, 'Business')
|
43
|
+
new_record['mobile_phone'] = get_phone(record, 'Cell')
|
44
|
+
new_record['fax'] = get_phone(record, 'FAX')
|
45
|
+
new_record['family_last_name'] = new_record['last_name']
|
46
|
+
new_record['family_name'] = @family_names[new_record['legacy_family_id']]
|
47
|
+
new_record['visible_to_everyone'] = @options['visible'].include?(record['MemberStatus'])
|
48
|
+
new_record['visible_on_printed_directory'] = @options['visible_on_printed_directory'].include?(record['MemberStatus'])
|
49
|
+
new_record['full_access'] = @options['full_access'].include?(record['MemberStatus'])
|
50
|
+
new_record['can_sign_in'] = @options['full_access'].include?(record['MemberStatus'])
|
51
|
+
new_record['email'] = record['HomeEmailAddr'].to_s.any? ? record['HomeEmailAddr'] : record['BusinessEmailAddr']
|
52
|
+
new_record['child'] = new_record['birthday'].to_s.empty? ? record['FamilyPosition'] == 'Child' : nil
|
53
|
+
new_record['sequence'] = @families_by_sequence[new_record['legacy_family_id']].index(record['IndividualNumber'].to_i) + 1
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def create_family_names(records)
|
58
|
+
# this could probably be less messy
|
59
|
+
@families = {}
|
60
|
+
records.each do |record|
|
61
|
+
@families[record['FamilyNumber']] ||= {}
|
62
|
+
@families[record['FamilyNumber']][record['FamilyPosition']] = record
|
63
|
+
end
|
64
|
+
@family_names = {}
|
65
|
+
@families.each do |family_number, family|
|
66
|
+
family['Head'] ||= family.delete('Spouse') || family.delete('Child')
|
67
|
+
if family['Head']
|
68
|
+
if family['Spouse']
|
69
|
+
@family_names[family_number] = "#{get_first_name(family['Head'])} & #{get_first_name(family['Spouse'])} #{family['Head']['LastName']}"
|
70
|
+
else
|
71
|
+
@family_names[family_number] = "#{get_first_name(family['Head'])} #{family['Head']['LastName']}"
|
72
|
+
end
|
73
|
+
else
|
74
|
+
@family_names[family_number] = "#{get_first_name(family['Other'])} #{family['Other']['LastName']}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def create_family_sequences(records)
|
80
|
+
@families_by_sequence = {}
|
81
|
+
records.each do |record|
|
82
|
+
@families_by_sequence[record['FamilyNumber']] ||= []
|
83
|
+
@families_by_sequence[record['FamilyNumber']] << record['IndividualNumber'].to_i
|
84
|
+
end
|
85
|
+
@families_by_sequence.each { |k, v| v.sort! }
|
86
|
+
end
|
87
|
+
|
88
|
+
def get_first_name(record)
|
89
|
+
record['GoesByName'].to_s.any? ? record['GoesByName'] : record['FirstName']
|
90
|
+
end
|
91
|
+
|
92
|
+
def get_phone(record, type)
|
93
|
+
unless record[type + 'Unlisted'].to_s.downcase == 'true'
|
94
|
+
phone = record[type + 'Phone'].to_s.scan(/\d/).join
|
95
|
+
phone << ' ' + record[type + 'Extension'] if record[type + 'Extension'].to_s.any?
|
96
|
+
phone
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class Hash
|
2
|
+
# creates a uniq sha1 digest of the hash's values
|
3
|
+
# should mirror similar code in OneBody's lib/db_tools.rb
|
4
|
+
def values_for_hash(*attrs)
|
5
|
+
attrs = keys.sort unless attrs.any?
|
6
|
+
attrs = attrs.first if attrs.first.is_a?(Array)
|
7
|
+
values = attrs.map do |attr|
|
8
|
+
value = self[attr.to_s]
|
9
|
+
if value.respond_to?(:strftime)
|
10
|
+
value.strftime('%Y-%m-%d %H:%M:%S')
|
11
|
+
elsif value == true
|
12
|
+
1
|
13
|
+
elsif value == false
|
14
|
+
0
|
15
|
+
else
|
16
|
+
value
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def values_hash(*attrs)
|
22
|
+
Digest::SHA1.hexdigest(values_for_hash(*attrs).join)
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class Base < ActiveResource::Base
|
2
|
+
self.site = ONEBODY_SITE
|
3
|
+
self.user = ONEBODY_USER_EMAIL
|
4
|
+
self.password = ONEBODY_USER_KEY
|
5
|
+
end
|
6
|
+
|
7
|
+
class Person < Base; end
|
8
|
+
class Family < Base; end
|
9
|
+
class ExternalGroup < Base; end
|
10
|
+
|
11
|
+
class Sync < Base
|
12
|
+
self.site = "#{ONEBODY_SITE}/admin"
|
13
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class Schema
|
2
|
+
def initialize(resource)
|
3
|
+
@schema = resource.get(:schema)
|
4
|
+
end
|
5
|
+
def type(t)
|
6
|
+
@schema.select { |c| c['type'] == t }.map { |c| c['name'] }.uniq
|
7
|
+
end
|
8
|
+
end
|
9
|
+
person_schema = Schema.new(Person)
|
10
|
+
family_schema = Schema.new(Family)
|
11
|
+
|
12
|
+
DATETIME_ATTRIBUTES = person_schema.type(:datetime) + family_schema.type(:datetime).map { |c| 'family_' + c }
|
13
|
+
BOOLEAN_ATTRIBUTES = person_schema.type(:boolean) + family_schema.type(:boolean).map { |c| 'family_' + c }
|
14
|
+
INTEGER_ATTRIBUTES = person_schema.type(:integer) + family_schema.type(:integer).map { |c| 'family_' + c }
|
15
|
+
IGNORE_ATTRIBUTES = %w(updated_at created_at family_updated_at family_latitude family_longitude)
|
@@ -0,0 +1,245 @@
|
|
1
|
+
# general class to handle comparing and pushing data to the remote end
|
2
|
+
class UpdateAgent
|
3
|
+
|
4
|
+
MAX_HASHES_AT_A_TIME = 100
|
5
|
+
MAX_TO_BATCH_AT_A_TIME = 10
|
6
|
+
SLEEP_PERIOD = 3
|
7
|
+
|
8
|
+
DEBUG = false
|
9
|
+
|
10
|
+
def initialize(data=nil, options={})
|
11
|
+
@options = options
|
12
|
+
@attributes = []
|
13
|
+
@data = []
|
14
|
+
@create = []
|
15
|
+
@update = []
|
16
|
+
if data
|
17
|
+
if data.is_a?(Array)
|
18
|
+
@data = data
|
19
|
+
@attributes = data.first.keys.sort
|
20
|
+
else
|
21
|
+
read_from_file(data)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
check_for_invalid_columns
|
25
|
+
end
|
26
|
+
|
27
|
+
def check_for_invalid_columns
|
28
|
+
if invalid = @data.detect { |row| row['id'] }
|
29
|
+
puts "Error: one or more records contain an 'id' column."
|
30
|
+
puts "You must utilize 'legacy_id' rather than 'id' so that"
|
31
|
+
puts "identity and foreign keys are maintained from your"
|
32
|
+
puts "existing membership management database."
|
33
|
+
exit
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# load data from csv file and do some type conversion for bools and dates
|
38
|
+
# first row must be attribute names
|
39
|
+
def read_from_file(filename)
|
40
|
+
csv = FasterCSV.open(filename, 'r')
|
41
|
+
@attributes = csv.shift
|
42
|
+
record_count = 0
|
43
|
+
@data = csv.map do |row|
|
44
|
+
hash = {}
|
45
|
+
row.each_with_index do |value, index|
|
46
|
+
key = @attributes[index]
|
47
|
+
next if IGNORE_ATTRIBUTES.include?(key)
|
48
|
+
if DATETIME_ATTRIBUTES.include?(key)
|
49
|
+
if value.blank?
|
50
|
+
value = nil
|
51
|
+
else
|
52
|
+
begin
|
53
|
+
value = DateTime.parse(value)
|
54
|
+
rescue ArgumentError
|
55
|
+
puts "Invalid date in #{filename} record #{index} (#{key}) - #{value}"
|
56
|
+
exit(1)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
elsif BOOLEAN_ATTRIBUTES.include?(key)
|
60
|
+
if value == '' or value == nil
|
61
|
+
value = nil
|
62
|
+
elsif %w(no false 0).include?(value.downcase)
|
63
|
+
value = false
|
64
|
+
else
|
65
|
+
value = true
|
66
|
+
end
|
67
|
+
elsif INTEGER_ATTRIBUTES.include?(key)
|
68
|
+
value = value.to_s != '' ? value.scan(/\d/).join.to_i : nil
|
69
|
+
end
|
70
|
+
hash[key] = value
|
71
|
+
end
|
72
|
+
record_count += 1
|
73
|
+
print "reading record #{record_count}\r"
|
74
|
+
hash['deleted'] = false
|
75
|
+
hash
|
76
|
+
end
|
77
|
+
puts
|
78
|
+
@attributes << 'deleted'
|
79
|
+
@attributes.reject! { |a| IGNORE_ATTRIBUTES.include?(a) }
|
80
|
+
end
|
81
|
+
|
82
|
+
def ids
|
83
|
+
@data.map { |r| r['id'] }.compact
|
84
|
+
end
|
85
|
+
|
86
|
+
def legacy_ids
|
87
|
+
@data.map { |r| r['legacy_id'] }.compact
|
88
|
+
end
|
89
|
+
|
90
|
+
def compare(force=false)
|
91
|
+
compare_hashes(legacy_ids, force)
|
92
|
+
end
|
93
|
+
|
94
|
+
def has_work?
|
95
|
+
(@create + @update).any?
|
96
|
+
end
|
97
|
+
|
98
|
+
def present
|
99
|
+
puts "The following #{resource.name.downcase} records will be pushed..."
|
100
|
+
puts 'legacy id name'
|
101
|
+
puts '---------- -------------------------------------'
|
102
|
+
@create.each { |r| present_record(r, true) }
|
103
|
+
@update.each { |r| present_record(r) }
|
104
|
+
puts
|
105
|
+
end
|
106
|
+
|
107
|
+
def present_record(row, new=false)
|
108
|
+
puts "#{row['legacy_id'].to_s.ljust(10)} #{name_for(row).to_s.ljust(40)} #{new ? '(new)' : ' '}"
|
109
|
+
if DEBUG
|
110
|
+
puts row.values_hash(@attributes)
|
111
|
+
puts row['remote_hash']
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def confirm
|
116
|
+
agree('Do you want to continue, pushing these records to OneBody? ')
|
117
|
+
end
|
118
|
+
|
119
|
+
# use ActiveResource to create/update records on remote end
|
120
|
+
def push
|
121
|
+
@errors = []
|
122
|
+
puts 'Updating remote end...'
|
123
|
+
index = 0
|
124
|
+
print "#{resource.name} 0/0\r"; STDOUT.flush
|
125
|
+
(@create + @update).each_slice(MAX_TO_BATCH_AT_A_TIME) do |records|
|
126
|
+
response = resource.post(:batch, {}, records.to_xml)
|
127
|
+
statuses = Hash.from_xml(response.body)['records']
|
128
|
+
statuses.each do |status|
|
129
|
+
record = data_by_id[status['legacy_id']]
|
130
|
+
record['id'] = status['id']
|
131
|
+
if status['status'] == 'error'
|
132
|
+
puts "#{status['legacy_id']}: #{status['error']}"
|
133
|
+
@errors << {:record => record, :error => status['error']}
|
134
|
+
record['error_messages'] = status['error']
|
135
|
+
end
|
136
|
+
end
|
137
|
+
index += records.length
|
138
|
+
print "#{resource.name} #{index}/#{@create.length + @update.length}\r"; STDOUT.flush
|
139
|
+
sleep SLEEP_PERIOD
|
140
|
+
end
|
141
|
+
puts
|
142
|
+
end
|
143
|
+
|
144
|
+
def errors
|
145
|
+
(@create + @update).select { |r| r['error_messages'] }
|
146
|
+
end
|
147
|
+
|
148
|
+
def send_notification
|
149
|
+
if n = @options['notifications']
|
150
|
+
puts 'Sending notification...'
|
151
|
+
if @errors and @errors.any?
|
152
|
+
subject = 'OneBody UpdateAgent Errors'
|
153
|
+
body = "There were #{@errors.length} error(s) running UpdateAgent.\n\nPlease visit #{ONEBODY_SITE}/admin/syncs for details."
|
154
|
+
else
|
155
|
+
subject = 'OneBody UpdateAgent Success'
|
156
|
+
body = "OneBody UpdateAgent completed without any errors.\n"
|
157
|
+
end
|
158
|
+
Net::SMTP.start(n['host'], n['port'].to_i) do |smtp|
|
159
|
+
smtp.send_message(
|
160
|
+
"From: #{n['from_email']}\nTo: #{n['to_email']}\nSubject: #{subject}\n\n#{body}",
|
161
|
+
n['from_email'],
|
162
|
+
n['to_email']
|
163
|
+
)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def start_sync
|
169
|
+
@sync = Sync.create(:complete => false)
|
170
|
+
end
|
171
|
+
|
172
|
+
def finish_sync(create_items=true, mark_complete=true)
|
173
|
+
if create_items
|
174
|
+
@sync.post(
|
175
|
+
:create_items,
|
176
|
+
{},
|
177
|
+
(
|
178
|
+
@create.map { |r| to_sync_item(r, 'create') } +
|
179
|
+
@update.map { |r| to_sync_item(r, 'update') }
|
180
|
+
).to_xml
|
181
|
+
)
|
182
|
+
end
|
183
|
+
if mark_complete
|
184
|
+
@sync.complete = true
|
185
|
+
@sync.error_count = errors.length
|
186
|
+
@sync.success_count = (@create + @update).length - errors.length
|
187
|
+
@sync.save
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def to_sync_item(record, operation)
|
192
|
+
h = {
|
193
|
+
:syncable_type => self.resource.name,
|
194
|
+
:syncable_id => record['id'],
|
195
|
+
:name => name_for(record),
|
196
|
+
:legacy_id => record['legacy_id'],
|
197
|
+
:operation => operation,
|
198
|
+
:status => record['error_messages'] ? 'error' : 'success'
|
199
|
+
}
|
200
|
+
if record['error_messages']
|
201
|
+
h[:error_messages] = record['error_messages'].split('; ')
|
202
|
+
end
|
203
|
+
return h
|
204
|
+
end
|
205
|
+
|
206
|
+
def data_by_id
|
207
|
+
@data_by_id ||= begin
|
208
|
+
by_id = {}
|
209
|
+
@data.each { |r| by_id[r['legacy_id'].to_i] = r }
|
210
|
+
by_id
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
attr_accessor :attributes, :data, :sync
|
215
|
+
attr_reader :update, :create
|
216
|
+
|
217
|
+
class << self; attr_accessor :resource; end
|
218
|
+
def resource; self.class.resource; end
|
219
|
+
|
220
|
+
protected
|
221
|
+
|
222
|
+
# ask remote end for value hashe for each record (50 at a time)
|
223
|
+
# mark records to create or update based on response
|
224
|
+
def compare_hashes(ids, force=false)
|
225
|
+
ids.each_slice(MAX_HASHES_AT_A_TIME) do |some_ids|
|
226
|
+
print '.'; STDOUT.flush
|
227
|
+
options = {:attrs => @attributes.join(','), :legacy_id => some_ids.join(',')}
|
228
|
+
options.merge!(:debug => true) if DEBUG
|
229
|
+
response = resource.post(:hashify, {}, options.to_xml)
|
230
|
+
hashes = Hash.from_xml(response.body)['records'].to_a
|
231
|
+
hashes.each do |record|
|
232
|
+
row = data_by_id[record['legacy_id'].to_i]
|
233
|
+
if DEBUG
|
234
|
+
row['remote_hash'] = record['hash']
|
235
|
+
@update << row if force or row.values_for_hash(@attributes).join != record['hash'] or (resource.name == 'Person' and record['family_id'].nil?)
|
236
|
+
else
|
237
|
+
@update << row if force or row.values_hash(@attributes) != record['hash'] or (resource.name == 'Person' and record['family_id'].nil?)
|
238
|
+
end
|
239
|
+
end
|
240
|
+
@create += some_ids.reject { |id| hashes.map { |h| h['legacy_id'].to_i }.include?(id.to_i) }.map { |id| data_by_id[id.to_i] }
|
241
|
+
sleep SLEEP_PERIOD
|
242
|
+
end
|
243
|
+
puts
|
244
|
+
end
|
245
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class ExternalGroupUpdater < UpdateAgent
|
2
|
+
self.resource = ExternalGroup
|
3
|
+
|
4
|
+
def check_for_invalid_columns; end
|
5
|
+
|
6
|
+
def compare
|
7
|
+
groups = resource.find(:all)
|
8
|
+
@data.each do |record|
|
9
|
+
if !(group = groups.detect { |g| g.external_id == record['id'] })
|
10
|
+
@create << record
|
11
|
+
elsif group.name != record['name'] or group.category.to_s != record['category'].to_s
|
12
|
+
@update << record
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def push
|
18
|
+
puts 'Updating remote end...'
|
19
|
+
@create.each do |record|
|
20
|
+
group = resource.new(:external_id => record['id'], :name => record['name'], :category => record['category'])
|
21
|
+
group.save
|
22
|
+
end
|
23
|
+
@update.each do |record|
|
24
|
+
group = resource.find(record['id'], :params => {:external_id => true})
|
25
|
+
group.name = record['name']
|
26
|
+
group.category = record['category']
|
27
|
+
group.save
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# handles people.csv and splits out family data for FamilyUpdater
|
2
|
+
class PeopleUpdater < UpdateAgent
|
3
|
+
self.resource = Person
|
4
|
+
|
5
|
+
# split out family data and create a new FamilyUpdater
|
6
|
+
def initialize(filename, options={})
|
7
|
+
super(filename, options)
|
8
|
+
if options[:converter]
|
9
|
+
converter = Kernel.const_get(@options['converter']['name'] + 'Converter').new(@options['converter'])
|
10
|
+
@data = converter.convert(@data.clone)
|
11
|
+
@attributes = @data.first.keys
|
12
|
+
end
|
13
|
+
person_data = []
|
14
|
+
family_data = {}
|
15
|
+
@data.each_with_index do |row, index|
|
16
|
+
person, family = split_change_hash(row)
|
17
|
+
if existing_family = family_data[family['legacy_id']]
|
18
|
+
person['family'] = existing_family
|
19
|
+
person_data << person
|
20
|
+
else
|
21
|
+
person['family'] = family
|
22
|
+
person_data << person
|
23
|
+
family_data[family['legacy_id']] = family
|
24
|
+
end
|
25
|
+
print "splitting family record #{index+1}\r"
|
26
|
+
end
|
27
|
+
puts
|
28
|
+
@data = person_data
|
29
|
+
@attributes.reject! { |a| a =~ /^family_/ and a != 'family_id' }
|
30
|
+
@family_agent = FamilyUpdater.new(family_data.values)
|
31
|
+
end
|
32
|
+
|
33
|
+
def name_for(row)
|
34
|
+
"#{row['first_name']} #{row['last_name']}" rescue ''
|
35
|
+
end
|
36
|
+
|
37
|
+
def compare(force=false)
|
38
|
+
@family_agent.compare(force)
|
39
|
+
super(force)
|
40
|
+
end
|
41
|
+
|
42
|
+
def has_work?
|
43
|
+
@family_agent.has_work? or super
|
44
|
+
end
|
45
|
+
|
46
|
+
def present
|
47
|
+
@family_agent.present if @family_agent.has_work?
|
48
|
+
super
|
49
|
+
end
|
50
|
+
|
51
|
+
def push
|
52
|
+
@family_agent.push
|
53
|
+
super
|
54
|
+
end
|
55
|
+
|
56
|
+
def finish_sync(create_items=true, mark_complete=true)
|
57
|
+
if create_items
|
58
|
+
@family_agent.sync = @sync
|
59
|
+
@family_agent.finish_sync(true, false)
|
60
|
+
super(true, false)
|
61
|
+
end
|
62
|
+
if mark_complete
|
63
|
+
@sync.complete = true
|
64
|
+
@sync.error_count = errors.length + @family_agent.errors.length
|
65
|
+
@sync.success_count = (@create + @update).length + (@family_agent.create + @family_agent.update).length - @sync.error_count
|
66
|
+
@sync.save
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
protected
|
71
|
+
|
72
|
+
# split hash of values into person and family values based on keys
|
73
|
+
def split_change_hash(vals)
|
74
|
+
person_vals = {}
|
75
|
+
family_vals = {}
|
76
|
+
vals.each do |key, val|
|
77
|
+
if key =~ /^family_/
|
78
|
+
family_vals[key.sub(/^family_/, '')] = val
|
79
|
+
else
|
80
|
+
person_vals[key] = val
|
81
|
+
end
|
82
|
+
end
|
83
|
+
family_vals['legacy_id'] ||= person_vals['legacy_family_id']
|
84
|
+
[person_vals, family_vals]
|
85
|
+
end
|
86
|
+
end
|
metadata
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: onebody-updateagent
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.4.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tim Morgan
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-11-15 00:00:00 -06:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: fastercsv
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: highline
|
27
|
+
type: :runtime
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: "0"
|
34
|
+
version:
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: activeresource
|
37
|
+
type: :runtime
|
38
|
+
version_requirement:
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: "0"
|
44
|
+
version:
|
45
|
+
description:
|
46
|
+
email: tim@timmorgan.org
|
47
|
+
executables:
|
48
|
+
- update_onebody
|
49
|
+
extensions: []
|
50
|
+
|
51
|
+
extra_rdoc_files: []
|
52
|
+
|
53
|
+
files:
|
54
|
+
- example.yml
|
55
|
+
- README.markdown
|
56
|
+
- bin/update_onebody
|
57
|
+
- lib/onebody-updateagent.rb
|
58
|
+
- lib/updateagent/hash_extensions.rb
|
59
|
+
- lib/updateagent/resources.rb
|
60
|
+
- lib/updateagent/schema.rb
|
61
|
+
- lib/updateagent/updateagent.rb
|
62
|
+
- lib/updateagent/converters/acs_converter.rb
|
63
|
+
- lib/updateagent/updaters/family_updater.rb
|
64
|
+
- lib/updateagent/updaters/people_updater.rb
|
65
|
+
- lib/updateagent/updaters/external_group_updater.rb
|
66
|
+
has_rdoc: false
|
67
|
+
homepage: http://github.com/seven1m/onebody-updateagent
|
68
|
+
post_install_message:
|
69
|
+
rdoc_options: []
|
70
|
+
|
71
|
+
require_paths:
|
72
|
+
- lib
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: "0"
|
78
|
+
version:
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: "0"
|
84
|
+
version:
|
85
|
+
requirements: []
|
86
|
+
|
87
|
+
rubyforge_project:
|
88
|
+
rubygems_version: 1.3.1
|
89
|
+
signing_key:
|
90
|
+
specification_version: 2
|
91
|
+
summary: Companion to OneBody that handles sync with external data source.
|
92
|
+
test_files: []
|
93
|
+
|