yardi 4.0.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +29 -0
  3. data/.gitignore +5 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +20 -0
  6. data/CODEOWNERS +1 -0
  7. data/CODE_OF_CONDUCT.md +13 -0
  8. data/Gemfile +4 -0
  9. data/README.md +212 -0
  10. data/Rakefile +7 -0
  11. data/bin/console +15 -0
  12. data/config/multi_xml.rb +4 -0
  13. data/docs/contributing.md +24 -0
  14. data/docs/getting_started.md +14 -0
  15. data/lib/yardi.rb +54 -0
  16. data/lib/yardi/document_parser.rb +6 -0
  17. data/lib/yardi/document_parser/base.rb +85 -0
  18. data/lib/yardi/document_parser/guest_card_import_response_object.rb +72 -0
  19. data/lib/yardi/document_parser/prospects.rb +79 -0
  20. data/lib/yardi/document_parser/residents.rb +59 -0
  21. data/lib/yardi/error/base.rb +7 -0
  22. data/lib/yardi/error/connection_error.rb +12 -0
  23. data/lib/yardi/error/empty_response.rb +11 -0
  24. data/lib/yardi/error/error_response.rb +11 -0
  25. data/lib/yardi/error/fault_response.rb +14 -0
  26. data/lib/yardi/error/guests_not_found.rb +10 -0
  27. data/lib/yardi/error/invalid_configuration.rb +11 -0
  28. data/lib/yardi/error/missing_property.rb +10 -0
  29. data/lib/yardi/error/no_results.rb +10 -0
  30. data/lib/yardi/error/resource_not_found.rb +9 -0
  31. data/lib/yardi/error/service_unavailable.rb +11 -0
  32. data/lib/yardi/error/unparsable_response.rb +11 -0
  33. data/lib/yardi/model/event.rb +18 -0
  34. data/lib/yardi/model/guest_card_response.rb +12 -0
  35. data/lib/yardi/model/prospect.rb +49 -0
  36. data/lib/yardi/model/resident.rb +36 -0
  37. data/lib/yardi/parameter/agent.rb +13 -0
  38. data/lib/yardi/parameter/contact_info.rb +13 -0
  39. data/lib/yardi/parameter/credential.rb +16 -0
  40. data/lib/yardi/parameter/property.rb +35 -0
  41. data/lib/yardi/parameter/prospect.rb +25 -0
  42. data/lib/yardi/parameter/user.rb +64 -0
  43. data/lib/yardi/request/base.rb +99 -0
  44. data/lib/yardi/request/get_residents.rb +39 -0
  45. data/lib/yardi/request/get_yardi_guest_activity.rb +85 -0
  46. data/lib/yardi/request/import_yardi_guest.rb +73 -0
  47. data/lib/yardi/request_section.rb +24 -0
  48. data/lib/yardi/request_section/authentication.rb +24 -0
  49. data/lib/yardi/request_section/lead_management.rb +148 -0
  50. data/lib/yardi/request_section/prospect.rb +27 -0
  51. data/lib/yardi/request_section/residents.rb +18 -0
  52. data/lib/yardi/utils.rb +6 -0
  53. data/lib/yardi/utils/configuration_validator.rb +17 -0
  54. data/lib/yardi/utils/phone_parser.rb +23 -0
  55. data/lib/yardi/utils/request_fetcher.rb +47 -0
  56. data/lib/yardi/utils/request_generator.rb +88 -0
  57. data/lib/yardi/validator.rb +6 -0
  58. data/lib/yardi/validator/empty_response.rb +43 -0
  59. data/lib/yardi/validator/error_response.rb +87 -0
  60. data/lib/yardi/validator/fault_response.rb +40 -0
  61. data/lib/yardi/validator/missing_property.rb +60 -0
  62. data/lib/yardi/version.rb +5 -0
  63. data/yardi.gemspec +31 -0
  64. metadata +246 -0
@@ -0,0 +1,24 @@
1
+ module Yardi
2
+ # ALL REQUEST SECTIONS* must define a #generate method which builds their
3
+ # corresponding XML
4
+ # #generate returns a Nokogiri::DocumentFragment.
5
+ # Example:
6
+ # class TestSectionFoo
7
+ # def generate
8
+ # foo_fragment = Nokogiri::XML::DocumentFragment.parse('')
9
+ #
10
+ # Nokogiri::XML::Builder.with(foo_fragment) do |foo_xml|
11
+ # foo_xml.Foo 'foo_value'
12
+ # end
13
+ #
14
+ # foo_fragment
15
+ # end
16
+ # end
17
+ #
18
+ # * the auth section has a different signature for #generate because it is
19
+ # the only section that is namespaced.
20
+ module RequestSection
21
+ end
22
+
23
+ private_constant :RequestSection
24
+ end
@@ -0,0 +1,24 @@
1
+ require 'yardi/request_section'
2
+
3
+ module Yardi
4
+ module RequestSection
5
+ # Generate the auth section of a Yardi request
6
+ class Authentication
7
+ attr_reader :credential
8
+
9
+ def initialize(credential)
10
+ @credential = credential
11
+ end
12
+
13
+ def generate(xml_builder)
14
+ xml_builder['itf'].UserName credential.username
15
+ xml_builder['itf'].Password credential.password
16
+ xml_builder['itf'].ServerName credential.server
17
+ xml_builder['itf'].Database credential.database
18
+ xml_builder['itf'].Platform Yardi.config.platform
19
+ xml_builder['itf'].InterfaceEntity Yardi.config.entity
20
+ xml_builder['itf'].InterfaceLicense Yardi.config.license_key
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,148 @@
1
+ require 'yardi/request_section'
2
+
3
+ module Yardi
4
+ module RequestSection
5
+ # Generate the LeadManagement section of a Yardi request
6
+ class LeadManagement
7
+ def initialize(agent:, lead_source:, property:, reason:, user:)
8
+ @agent = agent
9
+ @lead_source = lead_source
10
+ @property = property
11
+ @reason = reason
12
+ @user = user
13
+ end
14
+
15
+ def generate(xml_builder)
16
+ xml_builder.LeadManagement do |lead_management_xml|
17
+ lead_management_xml.Prospects do |prospects_xml|
18
+ prospect_xml(prospects_xml)
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :agent, :lead_source, :property, :reason, :user
26
+
27
+ def prospect_xml(xml_builder)
28
+ xml_builder.Prospect do |prospect_xml|
29
+ customers_xml(prospect_xml)
30
+ preferences_xml(prospect_xml)
31
+
32
+ prospect_xml.Events do |events_xml|
33
+ first_contact_event_xml(events_xml)
34
+ tour_xml(events_xml) unless property.tour_time.nil?
35
+ end
36
+ end
37
+ end
38
+
39
+ def customers_xml(xml_builder)
40
+ xml_builder.Customers do |customers_xml|
41
+ customers_xml.Customer('Type' => 'prospect') do |customer_xml|
42
+ # ID nodes are empty nodes with attributes
43
+ customer_xml.Identification(apartmentlist_id_attributes) {}
44
+ customer_xml.Identification(yardi_id_attributes) {}
45
+ name_xml(customer_xml)
46
+ contact_information_xml(customer_xml)
47
+ end
48
+ end
49
+ end
50
+
51
+ def name_xml(xml_builder)
52
+ xml_builder.Name do |name_xml|
53
+ name_xml.FirstName user.first_name
54
+ name_xml.LastName user.last_name
55
+ end
56
+ end
57
+
58
+ def contact_information_xml(xml_builder)
59
+ phone_xml(user.contact_info.home_phone, 'home', xml_builder)
60
+ phone_xml(user.contact_info.cell_phone, 'cell', xml_builder)
61
+ xml_builder.Email user.contact_info.email
62
+ end
63
+
64
+ def phone_xml(number, type, xml_builder)
65
+ if number
66
+ xml_builder.Phone('PhoneType' => type) do |phone_xml|
67
+ phone_xml.PhoneNumber number
68
+ end
69
+ end
70
+ end
71
+
72
+ def apartmentlist_id_attributes
73
+ {
74
+ 'IDType' => 'ThirdPartyID',
75
+ 'IDValue' => user.id, # AL User ID
76
+ 'OrganizationName' => 'Apartment List'
77
+ }
78
+ end
79
+
80
+ def yardi_id_attributes
81
+ {
82
+ 'IDType' => 'PropertyID',
83
+ 'IDValue' => property.remote_id,
84
+ 'OrganizationName' => 'Yardi'
85
+ }
86
+ end
87
+
88
+ def preferences_xml(xml_builder)
89
+ xml_builder.CustomerPreferences do |preferences_xml|
90
+ preferences_xml.TargetMoveInDate user.move_in_date.to_s
91
+ preferences_xml.DesiredFloorplan user.preferred_floorplan_id
92
+ # Rent and bedrooms are empty nodes with attributes for values.
93
+ # Yardi doesn't like having an empty string for either of these
94
+ # attributes, so we leave the node out if we don't have any data.
95
+ unless user.price.nil?
96
+ preferences_xml.DesiredRent('Exact' => user.price) {}
97
+ end
98
+
99
+ unless user.beds.nil?
100
+ preferences_xml.DesiredNumBedrooms('Exact' => user.beds) {}
101
+ end
102
+
103
+ preferences_xml.Comment user.preference_notes
104
+ end
105
+ end
106
+
107
+ def first_contact_event_xml(xml_builder)
108
+ event_attrs = {
109
+ 'EventDate' => property.first_contact_time.iso8601,
110
+ 'EventType' => 'Email'
111
+ }
112
+ xml_builder.Event(event_attrs) do |event_xml|
113
+ agent_xml(event_xml)
114
+ event_xml.EventReasons reason
115
+ event_xml.FirstContact true
116
+ event_xml.Comments user.message
117
+ event_xml.TransactionSource lead_source
118
+ end
119
+ end
120
+
121
+ def agent_xml(xml_builder)
122
+ xml_builder.Agent do |agent_xml|
123
+ agent_xml.AgentName do |agent_name_xml|
124
+ agent_name_xml.FirstName agent.first_name
125
+ agent_name_xml.LastName agent.last_name
126
+ end
127
+ end
128
+ end
129
+
130
+ def tour_xml(xml_builder)
131
+ event_attrs = {
132
+ 'EventDate' => property.tour_time.iso8601,
133
+ 'EventType' => 'Appointment'
134
+ }
135
+ tour_id_attrs = {
136
+ 'IDValue' => property.tour_remote_id || 0,
137
+ 'IDType' => user.preferred_unit_id
138
+ }
139
+ xml_builder.Event(event_attrs) do |event_xml|
140
+ event_xml.EventID(tour_id_attrs)
141
+ agent_xml(event_xml)
142
+ event_xml.EventReasons reason
143
+ event_xml.Comments property.tour_notes
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,27 @@
1
+ require 'yardi/request_section'
2
+
3
+ module Yardi
4
+ module RequestSection
5
+ # Generate the data needed for a ProspectSearch
6
+ class Prospect
7
+ attr_reader :property_id, :prospect
8
+
9
+ def initialize(property_id:, prospect:)
10
+ @property_id = property_id
11
+ @prospect = prospect
12
+ end
13
+
14
+ # Even though we may not send data for some fields, Yardi needs empty
15
+ # nodes or the request fails.
16
+ def generate(xml_builder)
17
+ xml_builder['itf'].YardiPropertyId property_id
18
+ xml_builder['itf'].FirstName prospect.first_name
19
+ xml_builder['itf'].LastName prospect.last_name
20
+ xml_builder['itf'].EmailAddress prospect.email
21
+ xml_builder['itf'].PhoneNumber prospect.phone
22
+ xml_builder['itf'].ThirdPartyId prospect.yardi_prospect_id
23
+ xml_builder['itf'].FederalId
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ require 'yardi/request_section'
2
+
3
+ module Yardi
4
+ module RequestSection
5
+ # Generate the data needed for a GetResidents call
6
+ class Residents
7
+ attr_reader :property_id
8
+
9
+ def initialize(property_id:)
10
+ @property_id = property_id
11
+ end
12
+
13
+ def generate(xml_builder)
14
+ xml_builder['itf'].YardiPropertyId property_id
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,6 @@
1
+ module Yardi
2
+ module Utils
3
+ end
4
+
5
+ private_constant :Utils
6
+ end
@@ -0,0 +1,17 @@
1
+ require 'yardi/utils'
2
+
3
+ module Yardi
4
+ module Utils
5
+ class ConfigurationValidator
6
+ def validate!
7
+ missing_keys = Yardi::CONFIG_KEYS.select do |key|
8
+ Yardi.config.send(key).nil?
9
+ end
10
+ unless missing_keys.empty?
11
+ raise Error::InvalidConfiguration,
12
+ "Missing configuration for #{missing_keys.join(', ')}"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ require 'yardi/utils'
2
+
3
+ module Yardi
4
+ module Utils
5
+ class PhoneParser
6
+ # A single prospect/resident can have have 0 to many <Phone> nodes, so
7
+ # prospect/resident['Phone'] can have three different types:
8
+ # nil if there's no phone included
9
+ # a Hash representing a single phone number if there's one phone
10
+ # an Array if there are multiple phones
11
+ # @param phone [nil|Hash|Array]
12
+ # @return [Array<String>] if at least one `PhoneNumber` exists, or nil
13
+ # otherwise.
14
+ def self.parse(phone)
15
+ if phone.is_a?(Array)
16
+ phone.map { |ph| ph['PhoneNumber'] }.compact
17
+ elsif phone && phone['PhoneNumber']
18
+ [phone['PhoneNumber']]
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,47 @@
1
+ require 'faraday'
2
+
3
+ require 'yardi/utils'
4
+ require 'yardi/utils/configuration_validator'
5
+
6
+ module Yardi
7
+ module Utils
8
+ # Send a SOAP request to Yardi
9
+ class RequestFetcher
10
+ # @param generator [RequestGenerator] an instance of a RequestGenerator,
11
+ # which responds to #generate.
12
+ # @param connection [Faraday::Connection] The connection we'll use to
13
+ # make the request
14
+ def initialize(connection:, endpoint:, generator:)
15
+ @connection = connection
16
+ @endpoint = endpoint
17
+ @generator = generator
18
+ end
19
+
20
+ # @return [String] the XML response from Yardi
21
+ def fetch
22
+ ConfigurationValidator.new.validate!
23
+
24
+ response = perform!
25
+
26
+ if response.status == 404
27
+ raise Yardi::Error::ResourceNotFound, response.body
28
+ end
29
+
30
+ response.body
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :connection, :endpoint, :generator
36
+
37
+ def perform!
38
+ connection.post(endpoint) do |request|
39
+ request.body = generator.body
40
+ request.headers = generator.headers
41
+ end
42
+ rescue Errno::EADDRNOTAVAIL => error
43
+ raise Yardi::Error::ConnectionError, error.message
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,88 @@
1
+ require 'nokogiri'
2
+ require 'yardi/utils'
3
+
4
+ module Yardi
5
+ module Utils
6
+ # Generate a SOAP request for a specific action and sections
7
+ class RequestGenerator
8
+ URL_BASE = 'http://tempuri.org/YSI.Interfaces.WebServices'.freeze
9
+
10
+ # @param soap_action [String] the action to request from Yardi.
11
+ # @param sections [Array<RequestSection>] the section generators that will
12
+ # be used to generate the body of the XML request
13
+ def initialize(soap_action, sections, interface)
14
+ @soap_action = soap_action
15
+ @sections = sections
16
+ @interface = interface
17
+ end
18
+
19
+ # @return [String] the XML request for the specified action and sections
20
+ def body
21
+ xml_env = build_envelope do |xml_builder|
22
+ # This section is the only one inside of the itf namespace
23
+ xml_builder['itf'].send(soap_action) do
24
+ sections[:soap_body].each do |section|
25
+ section.generate(xml_builder)
26
+ end
27
+
28
+ xml_builder['itf'].XmlDoc 'REPLACE_ME' if xml_doc_sections?
29
+ end
30
+ end
31
+
32
+ # This is a hack to handle Nokogiri's behavior with namespaces and still
33
+ # build XML that adheres to Yardi's spec.
34
+ # https://github.com/sparklemotion/nokogiri/issues/425
35
+ # When inserting a child node, it will automatically inherit its
36
+ # parent's namespace. Unfortunately, Yardi only wants a select bunch of
37
+ # the parent nodes to be namespaced and the bulk of the child nodes
38
+ # (everything inside of the namespaced <itf:XmlDoc> node) to be without
39
+ # a namespace. In order to make this happen, we build the bulk of the
40
+ # xml inside #xml_doc_body, then do a string replace to plop it into
41
+ # the properly namespaced parent node.
42
+ xml_doc_sections? ? xml_env.sub('REPLACE_ME', xml_doc_body) : xml_env
43
+ end
44
+
45
+ def xml_doc_body
46
+ return nil if sections[:xml_doc].nil?
47
+ body_fragment = Nokogiri::XML::DocumentFragment.parse('')
48
+ Nokogiri::XML::Builder.with(body_fragment) do |xml_builder|
49
+ sections[:xml_doc].each do |section|
50
+ section.generate(xml_builder)
51
+ end
52
+ end
53
+ body_fragment.to_xml
54
+ end
55
+
56
+ def build_envelope(&block)
57
+ Nokogiri::XML::Builder.new do |xml|
58
+ xml.Envelope(envelope) do
59
+ xml.parent.namespace = xml.parent.namespace_definitions.first
60
+ xml['soapenv'].Body(&block)
61
+ end
62
+ end.to_xml
63
+ end
64
+
65
+ def headers
66
+ {
67
+ 'Content-Type' => 'text/xml; charset=utf-8',
68
+ 'SOAPAction' => "#{URL_BASE}/#{interface}/#{soap_action}"
69
+ }
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :soap_action, :sections, :interface
75
+
76
+ def xml_doc_sections?
77
+ !sections[:xml_doc].empty?
78
+ end
79
+
80
+ def envelope
81
+ {
82
+ 'xmlns:soapenv' => 'http://schemas.xmlsoap.org/soap/envelope/',
83
+ 'xmlns:itf' => "#{URL_BASE}/#{interface}"
84
+ }.freeze
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,6 @@
1
+ module Yardi
2
+ module Validator
3
+ end
4
+
5
+ private_constant :Validator
6
+ end
@@ -0,0 +1,43 @@
1
+ require 'yardi/validator'
2
+
3
+ module Yardi
4
+ module Validator
5
+ # Ensure that the response contains data. Sometimes Yardi will respond with
6
+ # just the outer shell of XML. For an example, @see empty_response.xml.
7
+ # We will also raise an error if Yardi returns a completely empty response.
8
+ class EmptyResponse
9
+ # @param parsed_response [Hash<String, Object>] the XML response parsed
10
+ # into a Hash
11
+ # @param action [String] The SOAP action this response is for. Yardi's
12
+ # responses have nodes whose names include the SOAP action for the
13
+ # request that was made.
14
+ def initialize(action:, parsed_response:)
15
+ @action = action
16
+ @response = parsed_response
17
+ end
18
+
19
+ # @raise [Yardi::Error::EmptyResponse] if the response is effectively
20
+ # empty
21
+ def validate!
22
+ return unless error?
23
+ raise Error::EmptyResponse, 'Yardi response contains no Result node.'
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :action, :response
29
+
30
+ def error?
31
+ return true if response.empty?
32
+
33
+ envelope = response['soap:Envelope']
34
+ return true if envelope.nil?
35
+
36
+ body = envelope['soap:Body']
37
+ # Fault responses will be handled by the FaultResponse validator
38
+ return false unless body['soap:Fault'].nil?
39
+ body["#{action}Response"]["#{action}Result"].nil?
40
+ end
41
+ end
42
+ end
43
+ end