yardi 4.0.8
Sign up to get free protection for your applications and to get access to all the features.
- 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
|