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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +29 -0
- data/.gitignore +5 -0
- data/.rspec +3 -0
- data/.rubocop.yml +20 -0
- data/CODEOWNERS +1 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/README.md +212 -0
- data/Rakefile +7 -0
- data/bin/console +15 -0
- data/config/multi_xml.rb +4 -0
- data/docs/contributing.md +24 -0
- data/docs/getting_started.md +14 -0
- data/lib/yardi.rb +54 -0
- data/lib/yardi/document_parser.rb +6 -0
- data/lib/yardi/document_parser/base.rb +85 -0
- data/lib/yardi/document_parser/guest_card_import_response_object.rb +72 -0
- data/lib/yardi/document_parser/prospects.rb +79 -0
- data/lib/yardi/document_parser/residents.rb +59 -0
- data/lib/yardi/error/base.rb +7 -0
- data/lib/yardi/error/connection_error.rb +12 -0
- data/lib/yardi/error/empty_response.rb +11 -0
- data/lib/yardi/error/error_response.rb +11 -0
- data/lib/yardi/error/fault_response.rb +14 -0
- data/lib/yardi/error/guests_not_found.rb +10 -0
- data/lib/yardi/error/invalid_configuration.rb +11 -0
- data/lib/yardi/error/missing_property.rb +10 -0
- data/lib/yardi/error/no_results.rb +10 -0
- data/lib/yardi/error/resource_not_found.rb +9 -0
- data/lib/yardi/error/service_unavailable.rb +11 -0
- data/lib/yardi/error/unparsable_response.rb +11 -0
- data/lib/yardi/model/event.rb +18 -0
- data/lib/yardi/model/guest_card_response.rb +12 -0
- data/lib/yardi/model/prospect.rb +49 -0
- data/lib/yardi/model/resident.rb +36 -0
- data/lib/yardi/parameter/agent.rb +13 -0
- data/lib/yardi/parameter/contact_info.rb +13 -0
- data/lib/yardi/parameter/credential.rb +16 -0
- data/lib/yardi/parameter/property.rb +35 -0
- data/lib/yardi/parameter/prospect.rb +25 -0
- data/lib/yardi/parameter/user.rb +64 -0
- data/lib/yardi/request/base.rb +99 -0
- data/lib/yardi/request/get_residents.rb +39 -0
- data/lib/yardi/request/get_yardi_guest_activity.rb +85 -0
- data/lib/yardi/request/import_yardi_guest.rb +73 -0
- data/lib/yardi/request_section.rb +24 -0
- data/lib/yardi/request_section/authentication.rb +24 -0
- data/lib/yardi/request_section/lead_management.rb +148 -0
- data/lib/yardi/request_section/prospect.rb +27 -0
- data/lib/yardi/request_section/residents.rb +18 -0
- data/lib/yardi/utils.rb +6 -0
- data/lib/yardi/utils/configuration_validator.rb +17 -0
- data/lib/yardi/utils/phone_parser.rb +23 -0
- data/lib/yardi/utils/request_fetcher.rb +47 -0
- data/lib/yardi/utils/request_generator.rb +88 -0
- data/lib/yardi/validator.rb +6 -0
- data/lib/yardi/validator/empty_response.rb +43 -0
- data/lib/yardi/validator/error_response.rb +87 -0
- data/lib/yardi/validator/fault_response.rb +40 -0
- data/lib/yardi/validator/missing_property.rb +60 -0
- data/lib/yardi/version.rb +5 -0
- data/yardi.gemspec +31 -0
- 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
|
data/lib/yardi/utils.rb
ADDED
@@ -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,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
|