yardi 4.0.8

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 (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