blue_state_digital 0.6.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +1 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +3 -0
  7. data/Gemfile +2 -0
  8. data/Guardfile +5 -0
  9. data/LICENSE +24 -0
  10. data/README.md +123 -0
  11. data/Rakefile +11 -0
  12. data/blue_state_digital.gemspec +37 -0
  13. data/lib/blue_state_digital.rb +32 -0
  14. data/lib/blue_state_digital/address.rb +28 -0
  15. data/lib/blue_state_digital/api_data_model.rb +31 -0
  16. data/lib/blue_state_digital/collection_resource.rb +14 -0
  17. data/lib/blue_state_digital/connection.rb +119 -0
  18. data/lib/blue_state_digital/constituent.rb +178 -0
  19. data/lib/blue_state_digital/constituent_group.rb +151 -0
  20. data/lib/blue_state_digital/contribution.rb +73 -0
  21. data/lib/blue_state_digital/dataset.rb +139 -0
  22. data/lib/blue_state_digital/dataset_map.rb +138 -0
  23. data/lib/blue_state_digital/email.rb +22 -0
  24. data/lib/blue_state_digital/email_unsubscribe.rb +11 -0
  25. data/lib/blue_state_digital/error_middleware.rb +29 -0
  26. data/lib/blue_state_digital/event.rb +46 -0
  27. data/lib/blue_state_digital/event_rsvp.rb +17 -0
  28. data/lib/blue_state_digital/event_type.rb +19 -0
  29. data/lib/blue_state_digital/phone.rb +20 -0
  30. data/lib/blue_state_digital/version.rb +3 -0
  31. data/spec/blue_state_digital/address_spec.rb +25 -0
  32. data/spec/blue_state_digital/api_data_model_spec.rb +13 -0
  33. data/spec/blue_state_digital/connection_spec.rb +153 -0
  34. data/spec/blue_state_digital/constituent_group_spec.rb +269 -0
  35. data/spec/blue_state_digital/constituent_spec.rb +422 -0
  36. data/spec/blue_state_digital/contribution_spec.rb +132 -0
  37. data/spec/blue_state_digital/dataset_map_spec.rb +137 -0
  38. data/spec/blue_state_digital/dataset_spec.rb +177 -0
  39. data/spec/blue_state_digital/email_spec.rb +16 -0
  40. data/spec/blue_state_digital/error_middleware_spec.rb +15 -0
  41. data/spec/blue_state_digital/event_rsvp_spec.rb +17 -0
  42. data/spec/blue_state_digital/event_spec.rb +70 -0
  43. data/spec/blue_state_digital/event_type_spec.rb +51 -0
  44. data/spec/blue_state_digital/phone_spec.rb +16 -0
  45. data/spec/fixtures/multiple_event_types.json +234 -0
  46. data/spec/fixtures/single_event_type.json +117 -0
  47. data/spec/spec_helper.rb +21 -0
  48. data/spec/support/matchers/fields.rb +23 -0
  49. metadata +334 -0
@@ -0,0 +1,178 @@
1
+ module BlueStateDigital
2
+ class Constituent < ApiDataModel
3
+ FIELDS = [:id, :firstname, :lastname, :is_banned, :create_dt, :ext_id, :birth_dt, :gender,
4
+ :emails, :addresses, :phones, :groups, :is_new]
5
+ attr_accessor *FIELDS
6
+ attr_accessor :group_ids
7
+
8
+ def initialize(attrs = {})
9
+ super(attrs)
10
+ self.group_ids = []
11
+ end
12
+
13
+ def save
14
+ xml = connection.perform_request '/cons/set_constituent_data', {}, "POST", self.to_xml
15
+ doc = Nokogiri::XML(xml)
16
+ record = doc.xpath('//cons').first
17
+ if record
18
+ self.id = record[:id]
19
+ self.is_new = record[:is_new]
20
+ else
21
+ raise "Set constituent data failed with message: #{xml}"
22
+ end
23
+ self
24
+ end
25
+
26
+ def is_new?
27
+ is_new == "1"
28
+ end
29
+
30
+ def to_xml
31
+ builder = Builder::XmlMarkup.new
32
+ builder.instruct! :xml, version: '1.0', encoding: 'utf-8'
33
+ builder.api do |api|
34
+ cons_attrs = {}
35
+ cons_attrs[:id] = self.id unless self.id.blank?
36
+ unless self.ext_id.blank?
37
+ cons_attrs[:ext_id] = self.ext_id.id unless self.ext_id.id.blank?
38
+ cons_attrs[:ext_type] = self.ext_id.type unless self.ext_id.type.blank?
39
+ end
40
+
41
+ api.cons(cons_attrs) do |cons|
42
+ cons.firstname(self.firstname) unless self.firstname.blank?
43
+ cons.lastname(self.lastname) unless self.lastname.blank?
44
+ cons.is_banned(self.is_banned) unless self.is_banned.blank?
45
+ cons.create_dt(self.create_dt) unless self.create_dt.blank?
46
+ cons.birth_dt(self.birth_dt) unless self.birth_dt.blank?
47
+ cons.gender(self.gender) unless self.gender.blank?
48
+
49
+ unless self.emails.blank?
50
+ self.emails.each {|email| build_constituent_email(email, cons) }
51
+ end
52
+ unless self.addresses.blank?
53
+ self.addresses.each {|address| build_constituent_address(address, cons) }
54
+ end
55
+ unless self.phones.blank?
56
+ self.phones.each {|phone| build_constituent_phone(phone, cons) }
57
+ end
58
+ unless self.groups.blank?
59
+ self.groups.each {|group| build_constituent_group(group, cons) }
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+
68
+ def build_constituent_group(group, cons)
69
+ cons.cons_group({ id: group })
70
+ end
71
+
72
+ def build_constituent_email(email, cons)
73
+ cons.cons_email do |cons_email|
74
+ email.to_hash.each do |key, value|
75
+ cons_email.__send__(key, value) unless value.blank?
76
+ end
77
+ end
78
+ end
79
+
80
+ def build_constituent_phone(phone, cons)
81
+ cons.cons_phone do |cons_phone|
82
+ phone.to_hash.each do |key, value|
83
+ cons_phone.__send__(key, value) unless value.blank?
84
+ end
85
+ end
86
+ end
87
+
88
+ def build_constituent_address(address, cons)
89
+ cons.cons_addr do |cons_addr|
90
+ address.to_hash.each do |key, value|
91
+ cons_addr.__send__(key, value) unless value.blank?
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ class Constituents < CollectionResource
98
+ def get_constituents_by_email email, bundles= [ 'cons_group' ]
99
+ get_constituents "email=#{email}", bundles
100
+ end
101
+
102
+ def get_constituents_by_id(cons_ids, bundles = ['cons_group'])
103
+ cons_ids_concat = cons_ids.is_a?(Array) ? cons_ids.join(',') : cons_ids.to_s
104
+
105
+ from_response(connection.perform_request('/cons/get_constituents_by_id', {:cons_ids => cons_ids_concat, :bundles=> bundles.join(',')}, "GET"))
106
+ end
107
+
108
+ def get_constituents(filter, bundles = [ 'cons_group' ])
109
+ result = connection.wait_for_deferred_result( connection.perform_request('/cons/get_constituents', {:filter => filter, :bundles=> bundles.join(',')}, "GET") )
110
+
111
+ from_response(result)
112
+ end
113
+
114
+ def delete_constituents_by_id(cons_ids)
115
+ cons_ids_concat = cons_ids.is_a?(Array) ? cons_ids.join(',') : cons_ids.to_s
116
+ connection.perform_request('/cons/delete_constituents_by_id', {:cons_ids => cons_ids_concat}, "POST")
117
+ end
118
+
119
+ def from_response(string)
120
+ parsed_result = Crack::XML.parse(string)
121
+ if parsed_result["api"].present?
122
+ result = []
123
+ if parsed_result["api"]["cons"].is_a?(Array)
124
+ parsed_result["api"]["cons"].each do |cons_group|
125
+ result << from_hash(cons_group)
126
+ end
127
+ else
128
+ result << from_hash(parsed_result["api"]["cons"])
129
+ end
130
+ return result
131
+ else
132
+ nil
133
+ end
134
+ end
135
+
136
+ def from_hash(hash)
137
+ attrs = {}
138
+ Constituent::FIELDS.each do | field |
139
+ attrs[field] = hash[field.to_s] if hash[field.to_s].present?
140
+ end
141
+ cons = Constituent.new(attrs)
142
+ cons.connection = connection
143
+ if hash['cons_group'].present?
144
+ if hash['cons_group'].is_a?(Array)
145
+ cons.group_ids = hash['cons_group'].collect{|g| g["id"]}
146
+ else
147
+ cons.group_ids << hash['cons_group']["id"]
148
+ end
149
+ end
150
+
151
+ if hash['cons_addr'].present?
152
+ if hash['cons_addr'].is_a?(Array)
153
+ cons.addresses = hash['cons_addr'].collect {|addr_hash| BlueStateDigital::Address.new addr_hash}
154
+ else
155
+ cons.addresses = [BlueStateDigital::Address.new(hash['cons_addr'])]
156
+ end
157
+ end
158
+
159
+ if hash['cons_email'].present?
160
+ if hash['cons_email'].is_a?(Array)
161
+ cons.emails = hash['cons_email'].collect {|email_hash| BlueStateDigital::Email.new email_hash}
162
+ else
163
+ cons.emails = [BlueStateDigital::Email.new(hash['cons_email'])]
164
+ end
165
+ end
166
+
167
+ if hash['cons_phone'].present?
168
+ if hash['cons_phone'].is_a?(Array)
169
+ cons.phones = hash['cons_phone'].collect {|phone_hash| BlueStateDigital::Phone.new phone_hash}
170
+ else
171
+ cons.phones = [BlueStateDigital::Phone.new(hash['cons_phone'])]
172
+ end
173
+ end
174
+
175
+ cons
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,151 @@
1
+ module BlueStateDigital
2
+ class ConstituentGroup < ApiDataModel
3
+ FIELDS = [:id, :name, :slug, :description, :group_type, :create_dt]
4
+ attr_accessor *FIELDS
5
+
6
+ def to_xml
7
+ builder = Builder::XmlMarkup.new
8
+ builder.instruct! :xml, version: '1.0', encoding: 'utf-8'
9
+ builder.api do |api|
10
+ api.cons_group do |cons_group|
11
+ cons_group.name(@name) unless @name.nil?
12
+ cons_group.slug(@slug) unless @slug.nil?
13
+ cons_group.description(@description) unless @description.nil?
14
+ cons_group.group_type(@group_type) unless @group_type.nil?
15
+ cons_group.create_dt(@create_dt) unless @create_dt.nil?
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ class ConstituentGroups < CollectionResource
22
+ CONSTITUENTS_BATCH_SIZE = 100
23
+
24
+ def add_cons_ids_to_group(cons_group_id, cons_ids, options = {wait_for_result: true})
25
+ add_or_remove_cons_ids_from_group(:add, cons_group_id, cons_ids, options)
26
+ end
27
+
28
+ def remove_cons_ids_from_group(cons_group_id, cons_ids, options = {wait_for_result: true})
29
+ add_or_remove_cons_ids_from_group(:remove, cons_group_id, cons_ids, options)
30
+ end
31
+
32
+ def create(attrs = {})
33
+ cons_group = ConstituentGroup.new(attrs)
34
+
35
+ xml = connection.perform_request '/cons_group/add_constituent_groups', {}, "POST", cons_group.to_xml
36
+ doc = Nokogiri::XML(xml)
37
+ group = doc.xpath('//cons_group')
38
+
39
+ cons_group.id = group.first[:id]
40
+ cons_group
41
+ end
42
+
43
+ def find_or_create(attr = {})
44
+ group = get_constituent_group_by_slug(attr[:slug])
45
+ if group
46
+ return group
47
+ else
48
+ return create(attr)
49
+ end
50
+ end
51
+
52
+ def list_constituent_groups
53
+ from_response(connection.perform_request '/cons_group/list_constituent_groups', {}, "GET")
54
+ end
55
+
56
+ def get_constituent_group(id)
57
+ from_response(connection.perform_request '/cons_group/get_constituent_group', {cons_group_id: id}, "GET")
58
+ end
59
+
60
+ def find_by_id(id)
61
+ get_constituent_group(id)
62
+ end
63
+
64
+ def delete_constituent_groups(group_ids)
65
+ group_ids_concat = group_ids.is_a?(Array) ? group_ids.join(',') : group_ids.to_s
66
+ connection.wait_for_deferred_result( connection.perform_request( '/cons_group/delete_constituent_groups', {cons_group_ids: group_ids_concat}, "POST") )
67
+ end
68
+
69
+ def get_constituent_group_by_name( name )
70
+ name = name.slice(0..254)
71
+ from_response( connection.perform_request '/cons_group/get_constituent_group_by_name', {name: name}, "GET" )
72
+ end
73
+
74
+ def get_constituent_group_by_slug( slug )
75
+ slug = slug.slice(0..31)
76
+ from_response( connection.perform_request '/cons_group/get_constituent_group_by_slug', {slug: slug}, "GET" )
77
+ end
78
+
79
+ def rename_group(id, new_name)
80
+ connection.perform_request '/cons_group/rename_group', {cons_group_id: id, new_name: new_name}, "POST"
81
+ end
82
+
83
+ # Warning: this is an expensive, potentially dangerous operation! You should almost always use rename group instead.
84
+ def replace_constituent_group!(old_group_id, new_group_attrs)
85
+ # first, check to see if this group exists.
86
+ group = get_constituent_group(old_group_id)
87
+ raise "Group being renamed does not exist!" if group.nil?
88
+
89
+ cons_ids = get_cons_ids_for_group(old_group_id)
90
+ delete_constituent_groups(old_group_id)
91
+
92
+ new_group = find_or_create(new_group_attrs)
93
+ add_cons_ids_to_group(new_group.id, cons_ids)
94
+
95
+ new_group
96
+ end
97
+
98
+ def get_cons_ids_for_group(cons_group_id)
99
+ response = connection.wait_for_deferred_result( connection.perform_request '/cons_group/get_cons_ids_for_group', {cons_group_id: cons_group_id}, "GET" )
100
+ response.split("\n")
101
+ end
102
+
103
+
104
+ private
105
+
106
+ def from_response(string)
107
+ parsed_result = Crack::XML.parse(string)
108
+ if parsed_result["api"].present?
109
+ if parsed_result["api"]["cons_group"].is_a?(Array)
110
+ results = []
111
+ parsed_result["api"]["cons_group"].each do |cons_group|
112
+ results << from_hash(cons_group)
113
+ end
114
+ return results
115
+ else
116
+ return from_hash(parsed_result["api"]["cons_group"])
117
+ end
118
+ else
119
+ nil
120
+ end
121
+ end
122
+
123
+ def from_hash(hash)
124
+ attrs = {}
125
+ ConstituentGroup::FIELDS.each do | field |
126
+ attrs[field] = hash[field.to_s] if hash[field.to_s].present?
127
+ end
128
+ ConstituentGroup.new(attrs)
129
+ end
130
+
131
+ def add_or_remove_cons_ids_from_group(operation, cons_group_id, cons_ids, options)
132
+ method = case operation
133
+ when :add
134
+ 'add_cons_ids_to_group'
135
+ when :remove
136
+ 'remove_cons_ids_from_group'
137
+ end
138
+ cons_ids = cons_ids.is_a?(Array) ? cons_ids : [cons_ids]
139
+
140
+ cons_ids.in_groups_of(CONSTITUENTS_BATCH_SIZE, false) do |batched_cons_ids|
141
+ cons_ids_concat = batched_cons_ids.join(',')
142
+ post_params = { cons_group_id: cons_group_id, cons_ids: cons_ids_concat }
143
+ if options[:wait_for_result]
144
+ connection.wait_for_deferred_result( connection.perform_request "/cons_group/#{method}", post_params, "POST" )
145
+ else
146
+ connection.perform_request "/cons_group/#{method}", post_params, "POST"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,73 @@
1
+ module BlueStateDigital
2
+ class Contribution < ApiDataModel
3
+ #TODO are we sure we want to raise an exception on save?
4
+ class ContributionExternalIdMissingException < StandardError
5
+ def initialize
6
+ super("Missing GUID for ID")
7
+ end
8
+ end
9
+ class ContributionSaveFailureException < StandardError
10
+ def initialize(msg)
11
+ super
12
+ end
13
+ end
14
+ class ContributionSaveValidationException < StandardError
15
+ def initialize(validation_errors)
16
+ error_messages = validation_errors.map do |id,msgs|
17
+ "Error for Contribution(ID: #{id}): #{msgs.join(', ')}. "
18
+ end
19
+ super(error_messages.join(', '))
20
+ end
21
+ end
22
+
23
+ FIELDS = [
24
+ :external_id,
25
+ :prefix,:firstname,:middlename,:lastname,:suffix,
26
+ :transaction_dt,:transaction_amt,:cc_type_cd,:gateway_transaction_id,
27
+ :contribution_page_id,:stg_contribution_recurring_id,:contribution_page_slug,
28
+ :outreach_page_id,:source,:opt_compliance,
29
+ :addr1,:addr2,:city,:state_cd,:zip,:country,
30
+ :phone,:email,
31
+ :employer,:occupation,
32
+ :custom_fields
33
+ ]
34
+ attr_accessor *FIELDS
35
+
36
+ def as_json(options={})
37
+ fields_to_exclude = []
38
+ fields_to_exclude << :contribution_page_id if contribution_page_id.nil?
39
+ fields_to_exclude << :contribution_page_slug if contribution_page_slug.nil?
40
+ super(options.merge({except: fields_to_exclude}))
41
+ end
42
+
43
+ def save
44
+ begin
45
+ if connection
46
+ response = connection.perform_request(
47
+ '/contribution/add_external_contribution',
48
+ {accept: 'application/json'},
49
+ 'POST',
50
+ [self].to_json
51
+ )
52
+ begin
53
+ response = JSON.parse(response)
54
+ rescue
55
+ raise ContributionSaveFailureException.new(response)
56
+ end
57
+ if(response['summary']['missing_ids']>0)
58
+ raise ContributionExternalIdMissingException.new
59
+ elsif(response['summary']['failures']>0)
60
+ raise ContributionSaveValidationException.new(response['errors'])
61
+ end
62
+ else
63
+ raise NoConnectionException.new
64
+ end
65
+ #TODO shouldn't we be returning true or false and set the errors as in ActiveModel?
66
+ true
67
+ rescue => e
68
+ raise e
69
+ end
70
+ end
71
+ end
72
+
73
+ end
@@ -0,0 +1,139 @@
1
+ module BlueStateDigital
2
+ class Dataset < ApiDataModel
3
+
4
+ extend ActiveModel::Naming
5
+ include ActiveModel::Validations
6
+
7
+ UPLOAD_ENDPOINT = '/cons/upload_dataset'
8
+ DELETE_ENDPOINT = '/cons/delete_dataset'
9
+
10
+ FIELDS = [
11
+ :dataset_id,
12
+ :map_type,
13
+ :slug,
14
+ :rows
15
+ ]
16
+ attr_accessor *FIELDS
17
+ attr_reader :data,:data_header,:errors
18
+
19
+ validates_presence_of :map_type, :slug
20
+ validate :data_must_have_header
21
+
22
+ def initialize(args={})
23
+ super
24
+ @data = []
25
+ @data_header = nil
26
+ @errors = ActiveModel::Errors.new(self)
27
+ end
28
+ def data
29
+ @data
30
+ end
31
+ def add_data_row(row)
32
+ @data.push row
33
+ end
34
+ def add_data_header(header_row)
35
+ @data_header = header_row
36
+ end
37
+ def save
38
+ #errors.add(:data, "cannot be blank") if @data.blank?
39
+ if valid?
40
+ if connection
41
+ response = connection.perform_request_raw(
42
+ UPLOAD_ENDPOINT,
43
+ {api_ver: 2, slug: slug, map_type: map_type, content_type: 'text/csv', accept: 'application/json'},
44
+ "PUT",
45
+ csv_payload
46
+ )
47
+ if(response.status == 202)
48
+ true
49
+ else
50
+ errors.add(:web_service,"#{response.body}")
51
+ false
52
+ end
53
+ else
54
+ errors.add(:connection,"is missing")
55
+ false
56
+ end
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+
63
+ def delete
64
+ if dataset_id.nil?
65
+ errors.add(:dataset_id,"is missing")
66
+ return false
67
+ end
68
+ if connection
69
+ response = connection.perform_request_raw(
70
+ DELETE_ENDPOINT,
71
+ {api_ver: 2},
72
+ "POST",
73
+ {dataset_id: dataset_id}.to_json
74
+ )
75
+ if(response.status==200)
76
+ true
77
+ else
78
+ errors.add(:web_service,"#{response.body}")
79
+ false
80
+ end
81
+ else
82
+ errors.add(:connection,"is missing")
83
+ false
84
+ end
85
+ end
86
+
87
+ def read_attribute_for_validation(attr)
88
+ send(attr)
89
+ end
90
+ def self.human_attribute_name(attr, options = {})
91
+ attr
92
+ end
93
+ def self.lookup_ancestors
94
+ [self]
95
+ end
96
+
97
+ private
98
+
99
+ def csv_payload
100
+ csv_string = CSV.generate do |csv|
101
+ csv << (@data_header||[])
102
+ @data.each do |row|
103
+ csv << row
104
+ end
105
+ end
106
+ end
107
+
108
+ def data_must_have_header
109
+ errors.add(:data_header, "is missing") if !@data.blank? && @data_header.nil?
110
+ end
111
+ end
112
+
113
+ class Datasets < CollectionResource
114
+
115
+ FETCH_ENDPOINT = '/cons/list_datasets'
116
+
117
+ def get_datasets
118
+ if connection
119
+ response = connection.perform_request FETCH_ENDPOINT, { api_ver: 2 }, "GET"
120
+
121
+ # TODO: Should check response's status code
122
+ begin
123
+ parsed_response = JSON.parse(response)
124
+
125
+ data = parsed_response['data']
126
+ if(data)
127
+ data.map {|dataset| Dataset.new(dataset) }
128
+ else
129
+ nil
130
+ end
131
+ rescue Exception => e
132
+ raise FetchFailureException.new(e)
133
+ end
134
+ else
135
+ raise NoConnectionException.new
136
+ end
137
+ end
138
+ end
139
+ end