onebody-updateagent 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+