onebody-updateagent 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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,7 @@
1
+ class FamilyUpdater < UpdateAgent
2
+ self.resource = Family
3
+
4
+ def name_for(row)
5
+ row['name'] rescue ''
6
+ end
7
+ 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
+