zimbra-soap-api 0.0.7.9

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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.irbrc +6 -0
  4. data/Gemfile +6 -0
  5. data/README +21 -0
  6. data/Rakefile +1 -0
  7. data/lib/zimbra.rb +94 -0
  8. data/lib/zimbra/account.rb +94 -0
  9. data/lib/zimbra/acl.rb +63 -0
  10. data/lib/zimbra/alias.rb +4 -0
  11. data/lib/zimbra/appointment.rb +274 -0
  12. data/lib/zimbra/appointment/alarm.rb +101 -0
  13. data/lib/zimbra/appointment/attendee.rb +97 -0
  14. data/lib/zimbra/appointment/invite.rb +360 -0
  15. data/lib/zimbra/appointment/recur_exception.rb +83 -0
  16. data/lib/zimbra/appointment/recur_rule.rb +184 -0
  17. data/lib/zimbra/appointment/reply.rb +91 -0
  18. data/lib/zimbra/auth.rb +46 -0
  19. data/lib/zimbra/base.rb +217 -0
  20. data/lib/zimbra/calendar.rb +27 -0
  21. data/lib/zimbra/common_elements.rb +51 -0
  22. data/lib/zimbra/cos.rb +124 -0
  23. data/lib/zimbra/delegate_auth_token.rb +49 -0
  24. data/lib/zimbra/directory.rb +175 -0
  25. data/lib/zimbra/distribution_list.rb +147 -0
  26. data/lib/zimbra/domain.rb +63 -0
  27. data/lib/zimbra/ext/handsoap_curb_driver.rb +21 -0
  28. data/lib/zimbra/ext/hash.rb +72 -0
  29. data/lib/zimbra/ext/string.rb +10 -0
  30. data/lib/zimbra/extra/date_helpers.rb +111 -0
  31. data/lib/zimbra/folder.rb +100 -0
  32. data/lib/zimbra/handsoap_account_service.rb +44 -0
  33. data/lib/zimbra/handsoap_service.rb +75 -0
  34. data/lib/zimbra/version.rb +3 -0
  35. data/spec/fixtures/xml_api_requests/appointments/create.xml +84 -0
  36. data/spec/fixtures/xml_api_responses/alarms/15_minutes_before.xml +26 -0
  37. data/spec/fixtures/xml_api_responses/alarms/using_all_intervals.xml +26 -0
  38. data/spec/fixtures/xml_api_responses/appointments/appointment_response_1.xml +40 -0
  39. data/spec/fixtures/xml_api_responses/attendees/one_attendee_and_one_reply.xml +27 -0
  40. data/spec/fixtures/xml_api_responses/attendees/three_attendees_response_1.xml +30 -0
  41. data/spec/fixtures/xml_api_responses/multiple_invites/recurring_with_exceptions.xml +109 -0
  42. data/spec/fixtures/xml_api_responses/recur_rules/day_27_of_every_2_months.xml +27 -0
  43. data/spec/fixtures/xml_api_responses/recur_rules/every_2_days.xml +26 -0
  44. data/spec/fixtures/xml_api_responses/recur_rules/every_3_weeks_on_tuesday_and_friday.xml +30 -0
  45. data/spec/fixtures/xml_api_responses/recur_rules/every_day_50_instances.xml +27 -0
  46. data/spec/fixtures/xml_api_responses/recur_rules/every_monday_wednesday_friday.xml +31 -0
  47. data/spec/fixtures/xml_api_responses/recur_rules/every_tuesday.xml +29 -0
  48. data/spec/fixtures/xml_api_responses/recur_rules/every_weekday_with_end_date.xml +34 -0
  49. data/spec/fixtures/xml_api_responses/recur_rules/every_year_on_february_2.xml +28 -0
  50. data/spec/fixtures/xml_api_responses/recur_rules/first_day_of_every_month.xml +36 -0
  51. data/spec/fixtures/xml_api_responses/recur_rules/first_monday_of_every_february.xml +31 -0
  52. data/spec/fixtures/xml_api_responses/recur_rules/first_weekend_day_of_every_month.xml +31 -0
  53. data/spec/fixtures/xml_api_responses/recur_rules/last_day_of_every_month.xml +36 -0
  54. data/spec/fixtures/xml_api_responses/recur_rules/second_day_of_every_2_months.xml +36 -0
  55. data/spec/fixtures/xml_api_responses/recur_rules/second_wednesday_of_every_month.xml +29 -0
  56. data/spec/fixtures/xml_api_responses/recur_rules/weekly_with_an_exception.xml +44 -0
  57. data/spec/spec_helper.rb +32 -0
  58. data/spec/zimbra/acl_spec.rb +11 -0
  59. data/spec/zimbra/appointment/alarm_spec.rb +33 -0
  60. data/spec/zimbra/appointment/invite_spec.rb +62 -0
  61. data/spec/zimbra/appointment/recur_rule_spec.rb +307 -0
  62. data/spec/zimbra/appointment_spec.rb +175 -0
  63. data/spec/zimbra/common_elements_spec.rb +33 -0
  64. data/spec/zimbra/distribution_list_spec.rb +54 -0
  65. data/zimbra.gemspec +28 -0
  66. metadata +197 -0
@@ -0,0 +1,27 @@
1
+ module Zimbra
2
+ class Calendar < Folder
3
+ class << self
4
+ def all
5
+ CalendarService.find_all_by_view('appointment').reject { |c| c.view.nil? || c.view != 'appointment' }
6
+ end
7
+ end
8
+
9
+ def appointments
10
+ Zimbra::Appointment.find_all_by_calendar_id(id)
11
+ end
12
+ end
13
+
14
+ class CalendarService < FolderService
15
+ def parse_xml_responses(xml)
16
+ Parser.get_all_response(xml)
17
+ end
18
+
19
+ class Parser < Zimbra::FolderService::Parser
20
+ class << self
21
+ def initialize_from_attributes(folder_attributes)
22
+ Zimbra::Calendar.new(folder_attributes)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,51 @@
1
+ module Zimbra
2
+ class A
3
+ class << self
4
+ def inject(xmldoc, name, value, extra_attributes = {})
5
+ new(name, value, extra_attributes).inject(xmldoc)
6
+ end
7
+
8
+ def read(xmldoc, name)
9
+ nodes = (xmldoc/"//n2:a[@n='#{name}']")
10
+ return nil if nodes.nil?
11
+ if nodes.size > 1
12
+ nodes.map { |n| from_node(n, name).value }
13
+ else
14
+ from_node(nodes, name).value
15
+ end
16
+ end
17
+
18
+ def from_node(node, name)
19
+ new(name, node.to_s)
20
+ end
21
+ end
22
+
23
+ attr_accessor :name, :value, :extra_attributes
24
+
25
+ def initialize(name, value, extra_attributes = {})
26
+ self.name = name
27
+ self.value = value
28
+ self.extra_attributes = extra_attributes || {}
29
+ end
30
+
31
+ def inject(xmldoc)
32
+ xmldoc.add 'a', value do |a|
33
+ a.set_attr 'n', name
34
+ extra_attributes.each do |eaname, eavalue|
35
+ a.set_attr eaname, eavalue
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ class Boolean
42
+ def self.read(value)
43
+ case value
44
+ when 'TRUE' then true
45
+ when 'FALSE' then false
46
+ when true then true
47
+ else false
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,124 @@
1
+ module Zimbra
2
+ class Cos
3
+ class << self
4
+ def find_by_id(id)
5
+ CosService.get_by_id(id)
6
+ end
7
+
8
+ def find_by_name(name)
9
+ CosService.get_by_name(name)
10
+ end
11
+
12
+ def create(name)
13
+ CosService.create(name)
14
+ end
15
+
16
+ def acl_name
17
+ 'cos'
18
+ end
19
+ end
20
+
21
+ attr_accessor :id, :name, :acls
22
+
23
+ def initialize(id, name, acls = [])
24
+ self.id = id
25
+ self.name = name
26
+ self.acls = acls || []
27
+ end
28
+
29
+ def save
30
+ CosService.modify(self)
31
+ end
32
+
33
+ def delete
34
+ CosService.delete(self)
35
+ end
36
+ end
37
+
38
+ class CosService < HandsoapService
39
+ def get_by_id(id)
40
+ response = invoke("n2:GetCosRequest") do |message|
41
+ Builder.get_by_id(message, id)
42
+ end
43
+ return nil if soap_fault_not_found?
44
+ Parser.cos_response(response/"//n2:cos")
45
+ end
46
+
47
+ def get_by_name(name)
48
+ response = invoke("n2:GetCosRequest") do |message|
49
+ Builder.get_by_name(message, name)
50
+ end
51
+ return nil if soap_fault_not_found?
52
+ Parser.cos_response(response/"//n2:cos")
53
+ end
54
+
55
+ def create(name)
56
+ response = invoke("n2:CreateCosRequest") do |message|
57
+ Builder.create(message, name)
58
+ end
59
+ Parser.cos_response(response/"//n2:cos")
60
+ end
61
+
62
+ def modify(cos)
63
+ xml = invoke("n2:ModifyCosRequest") do |message|
64
+ Builder.modify(message, cos)
65
+ end
66
+ Parser.cos_response(xml/'//n2:cos')
67
+ end
68
+
69
+ def delete(cos)
70
+ xml = invoke("n2:DeleteCosRequest") do |message|
71
+ Builder.delete(message, cos)
72
+ end
73
+ end
74
+
75
+ class Builder
76
+ class << self
77
+ def get_by_id(message, id)
78
+ message.add 'cos', id do |c|
79
+ c.set_attr 'by', 'id'
80
+ end
81
+ end
82
+
83
+ def get_by_name(message, name)
84
+ message.add 'cos', name do |c|
85
+ c.set_attr "by", 'name'
86
+ end
87
+ end
88
+
89
+ def create(message, name)
90
+ message.add 'name', name
91
+ end
92
+
93
+ def modify(message, cos)
94
+ message.add 'id', cos.id
95
+ modify_attributes(message, cos)
96
+ end
97
+ def modify_attributes(message, cos)
98
+ if cos.acls.empty?
99
+ ACL.delete_all(message)
100
+ else
101
+ cos.acls.each do |acl|
102
+ acl.apply(message)
103
+ end
104
+ end
105
+ end
106
+
107
+ def delete(message, cos)
108
+ message.add 'id', cos.id
109
+ end
110
+ end
111
+ end
112
+
113
+ class Parser
114
+ class << self
115
+ def cos_response(node)
116
+ id = (node/'@id').to_s
117
+ name = (node/'@name').to_s
118
+ acls = Zimbra::ACL.read(node)
119
+ Zimbra::Cos.new(id, name, acls)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,49 @@
1
+ # http://files.zimbra.com/docs/soap_api/8.0.4/soap-docs-804/api-reference/zimbraAdmin/DelegateAuth.html
2
+
3
+ module Zimbra
4
+ class DelegateAuthToken
5
+ class << self
6
+ def for_account_name(account_name)
7
+ DelegateAuthTokenService.get_by_account_name(account_name)
8
+ end
9
+ end
10
+
11
+ attr_accessor :account_name, :token, :lifetime
12
+
13
+ def initialize(args = {})
14
+ self.account_name = args[:account_name]
15
+ self.token = args[:token]
16
+ self.lifetime = args[:lifetime]
17
+ end
18
+ end
19
+
20
+ class DelegateAuthTokenService < HandsoapService
21
+ def get_by_account_name(account_name)
22
+ xml = invoke("n2:DelegateAuthRequest") do |message|
23
+ Builder.get_by_account_name(message, account_name)
24
+ end
25
+ return nil unless xml
26
+ Parser.delegate_auth_token_response(account_name, xml)
27
+ end
28
+
29
+ class Builder
30
+ class << self
31
+ def get_by_account_name(message, account_name)
32
+ message.add 'account', account_name do |c|
33
+ c.set_attr 'by', 'name'
34
+ end
35
+ end
36
+ end
37
+ end
38
+ class Parser
39
+ class << self
40
+ def delegate_auth_token_response(account_name, response)
41
+ auth_token = (response/'//n2:authToken').to_s
42
+ lifetime = (response/'//n2:lifetime').to_i
43
+
44
+ Zimbra::DelegateAuthToken.new(account_name: account_name, token: auth_token, lifetime: lifetime)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,175 @@
1
+ module Zimbra
2
+ module Directory
3
+
4
+ TARGET_TYPES_MAPPING = {
5
+ Zimbra::Account => 'account',
6
+ Zimbra::DistributionList => 'dl',
7
+ Zimbra::Domain => 'domain'
8
+ }
9
+
10
+ class << self
11
+ # Run a search over the Ldap server of Zimbra
12
+ # query: is a valid LDAP search query
13
+ # types: a comma separated string of types of objects to look for
14
+ # domain: the email domain you want to limit the search to
15
+ # options[:limit]: max results of the search
16
+ # options[:offset]
17
+ # options[:sort_by]
18
+ # options[:sort_ascending]: 1=true , 0=false
19
+ def search(query = '', type: 'account', domain: nil, **options)
20
+ options[:limit] ||= 25
21
+ DirectoryService.search(query, type.to_sym, domain, options)
22
+ end
23
+
24
+ def add_grant(target, acl)
25
+ DirectoryService.add_grant(target.id, target.zimbra_type, acl)
26
+ end
27
+
28
+ def get_grants(target)
29
+ DirectoryService.get_grants(target.id, target.zimbra_type)
30
+ end
31
+
32
+ def revoke_grant(target, acl)
33
+ DirectoryService.revoke_grant(target.id, target.zimbra_type, acl)
34
+ end
35
+
36
+ end
37
+ end
38
+
39
+ class DirectoryService < HandsoapService
40
+ # This are the type off types (objects) that Zimbra has
41
+ # "distributionlists,aliases,accounts,dynamicgroups,resources,domains"
42
+ ZIMBRA_TYPES_HASH = {
43
+ distribution_list: { zimbra_type: 'distributionlists', node_name: 'dl', class: Zimbra::DistributionList },
44
+ distributionlist: { zimbra_type: 'distributionlists', node_name: 'dl', class: Zimbra::DistributionList },
45
+ #alias: { zimbra_type: 'aliases', node_name: 'alias', class: Zimbra::Alias },
46
+ account: { zimbra_type: 'accounts', node_name: 'account', class: Zimbra::Account },
47
+ domain: { zimbra_type: 'domains', node_name: 'domain', class: Zimbra::Domain }
48
+ }
49
+
50
+ def add_grant(id, type, acl)
51
+ xml = invoke("n2:GrantRightRequest") do |message|
52
+ Builder.add_grant(message, id, type, acl)
53
+ end
54
+ return nil if soap_fault_not_found?
55
+ true
56
+ end
57
+
58
+ def search(query, type, domain, options = {})
59
+ xml = invoke("n2:SearchDirectoryRequest") do |message|
60
+ Builder.search_directory(message, query, type, domain, options)
61
+ end
62
+ return nil if soap_fault_not_found?
63
+ Parser.search_directory_response(xml, type)
64
+ end
65
+
66
+ # method to get the grants on an object
67
+ # check https://files.zimbra.com/docs/soap_api/8.5.0/api-reference/zimbraAdmin/GetGrants.html
68
+ def get_grants(id, type)
69
+ xml = invoke('n2:GetGrantsRequest') do |message|
70
+ Builder.get_grants(message, id, type)
71
+ end
72
+ return nil if soap_fault_not_found?
73
+ Parser.get_grants_response(xml, type)
74
+ end
75
+
76
+ def revoke_grant(id, type, acl)
77
+ xml = invoke("n2:RevokeRightRequest") do |message|
78
+ Builder.revoke_grant(message, id, type, acl)
79
+ end
80
+ return nil if soap_fault_not_found?
81
+ true
82
+ end
83
+
84
+ module Builder
85
+ class << self
86
+ def search_directory(message, query, type, domain, options)
87
+ message.set_attr 'types', ZIMBRA_TYPES_HASH[type][:zimbra_type]
88
+ message.set_attr 'query', query
89
+ message.set_attr('domain', domain) if domain
90
+ message.set_attr('limit', options[:limit]) if options[:limit]
91
+ message.set_attr('offset', options[:offset]) if options[:offset]
92
+ message.set_attr('sort_by', options[:sort_by]) if options[:sort_by]
93
+ message.set_attr('sort_ascending', options[:sort_ascending]) if options[:sort_ascending]
94
+ end
95
+
96
+ def get_grants(message, id, type)
97
+ message.add 'target', id do |c|
98
+ c.set_attr 'by', 'id'
99
+ c.set_attr 'type', type
100
+ end
101
+ end
102
+
103
+ def add_grant(message, id, type, acl)
104
+ message.add 'target', id do |c|
105
+ c.set_attr 'by', 'id'
106
+ c.set_attr 'type', type
107
+ end
108
+ message.add 'grantee', acl.grantee_name do |grantee|
109
+ grantee.set_attr 'by', 'name'
110
+ grantee.set_attr 'type', acl.grantee_class.acl_name
111
+ end
112
+ message.add 'right', acl.name
113
+ end
114
+
115
+ def revoke_grant(message, id, type, acl)
116
+ message.add 'target', id do |c|
117
+ c.set_attr 'by', 'id'
118
+ c.set_attr 'type', type
119
+ end
120
+ message.add 'grantee', acl.grantee_name do |grantee|
121
+ grantee.set_attr 'by', 'name'
122
+ grantee.set_attr 'type', acl.grantee_class.acl_name
123
+ end
124
+ message.add 'right', acl.name
125
+ end
126
+
127
+ end
128
+ end
129
+
130
+ module Parser
131
+ class << self
132
+ def search_directory_response(response, type)
133
+ # look for the node given by the type
134
+ items = (response/"//n2:#{ZIMBRA_TYPES_HASH[type][:node_name]}")
135
+ items.map { |i| object_list_response(i, type) }
136
+ end
137
+
138
+ def get_grants_response(response, type)
139
+ result = []
140
+ grants = (response/"//n2:grant")
141
+ grants.each do |n|
142
+ hash = {}
143
+ hash[:grantee_id] = (n/'n2:grantee'/'@id').to_s
144
+ hash[:grantee_class] = Zimbra::ACL::TARGET_MAPPINGS[(n/'n2:grantee'/'@type').to_s]
145
+ hash[:grantee_name] = (n/'n2:grantee'/'@name').to_s
146
+ hash[:name] = (n/'n2:right').to_s
147
+ result << Zimbra::ACL.new(hash)
148
+ end
149
+ result
150
+ end
151
+
152
+ # This just call the parser_response method of the object
153
+ # in the case of the account type, it will call
154
+ # Zimbra::AccountService::Parser.account_response(node)
155
+ def object_list_response(node, type)
156
+ node = clean_node node
157
+ class_name = ZIMBRA_TYPES_HASH[type][:class].name.gsub(/Zimbra::/, '')
158
+ Zimbra::BaseService::Parser.response(class_name, node, true)
159
+ end
160
+
161
+ # This method is to erase all others nodes from document
162
+ # so the xpath search like (//xxxx) works, beacuse (//) always start
163
+ # at the beginning of the document, not the current element
164
+ def clean_node(node)
165
+ element = node.instance_variable_get("@element")
166
+ directory_response = element.document.css("n2|SearchDirectoryResponse", 'n2' => 'urn:zimbraAdmin').first
167
+ directory_response.children.each {|c| c.remove}
168
+ directory_response.add_child element
169
+ node
170
+ end
171
+
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,147 @@
1
+ module Zimbra
2
+ class DistributionList < Zimbra::Base
3
+ class << self
4
+ def acl_name
5
+ 'grp'
6
+ end
7
+ end
8
+
9
+ attr_accessor :id, :name, :admin_console_ui_components, :admin_group
10
+ attr_accessor :members, :restricted, :display_name, :cn, :mail
11
+
12
+ def initialize(id, name, zimbra_attrs = {}, node = nil)
13
+ super
14
+ @cn = zimbra_attrs['cn']
15
+ @display_name = zimbra_attrs['displayName']
16
+ self.admin_group = zimbra_attrs['zimbraIsAdminGroup']
17
+ @members = Zimbra::DistributionListService::Parser.get_members node
18
+ @restricted = !acls.nil?
19
+ @original_members = self.members.dup
20
+ end
21
+
22
+ def admin_console_ui_components
23
+ @admin_console_ui_components ||= []
24
+ end
25
+
26
+ def modify_members(members_group = [])
27
+ return unless members_group.any?
28
+ self.members = members_group
29
+ DistributionListService.modify_members(self)
30
+ end
31
+
32
+ def members
33
+ @members ||= []
34
+ end
35
+
36
+ def new_members
37
+ self.members - @original_members
38
+ end
39
+
40
+ def removed_members
41
+ @original_members - self.members
42
+ end
43
+
44
+ def admin_group=(val)
45
+ @admin_group = Zimbra::Boolean.read(val)
46
+ end
47
+ def admin_group?
48
+ @admin_group
49
+ end
50
+
51
+ def restricted?
52
+ @restricted
53
+ end
54
+
55
+ def add_alias(alias_name)
56
+ DistributionListService.add_alias(self,alias_name)
57
+ end
58
+
59
+ def save
60
+ DistributionListService.modify(self)
61
+ end
62
+ end
63
+
64
+ class DistributionListService < HandsoapService
65
+ def create(name)
66
+ xml = invoke("n2:CreateDistributionListRequest") do |message|
67
+ Builder.create(message, name)
68
+ end
69
+ Parser.distribution_list_response(xml/'//n2:dl')
70
+ end
71
+
72
+ def modify_members(distribution_list)
73
+ distribution_list.new_members.each do |member|
74
+ add_member(distribution_list, member)
75
+ end
76
+ distribution_list.removed_members.each do |member|
77
+ remove_member(distribution_list, member)
78
+ end
79
+ return true
80
+ end
81
+
82
+ def add_member(distribution_list, member)
83
+ xml = invoke("n2:AddDistributionListMemberRequest") do |message|
84
+ Builder.add_member(message, distribution_list.id, member)
85
+ end
86
+ end
87
+
88
+ def remove_member(distribution_list, member)
89
+ xml = invoke("n2:RemoveDistributionListMemberRequest") do |message|
90
+ Builder.remove_member(message, distribution_list.id, member)
91
+ end
92
+ end
93
+
94
+ def add_alias(distribution_list,alias_name)
95
+ xml = invoke('n2:AddDistributionListAliasRequest') do |message|
96
+ Builder.add_alias(message,distribution_list.id,alias_name)
97
+ end
98
+ end
99
+
100
+ module Builder
101
+ class << self
102
+ def modify_admin_console_ui_components(message, distribution_list)
103
+ if distribution_list.admin_console_ui_components.empty?
104
+ A.inject(message, 'zimbraAdminConsoleUIComponents', '')
105
+ else
106
+ distribution_list.admin_console_ui_components.each do |component|
107
+ A.inject(message, 'zimbraAdminConsoleUIComponents', component)
108
+ end
109
+ end
110
+ end
111
+
112
+ def modify_is_admin_group(message, distribution_list)
113
+ A.inject(message, 'zimbraIsAdminGroup', (distribution_list.admin_group? ? 'TRUE' : 'FALSE'))
114
+ end
115
+
116
+ def add_member(message, distribution_list_id, member)
117
+ message.add 'id', distribution_list_id
118
+ message.add 'dlm', member
119
+ end
120
+
121
+ def remove_member(message, distribution_list_id, member)
122
+ message.add 'id', distribution_list_id
123
+ message.add 'dlm', member
124
+ end
125
+
126
+ def add_alias(message,id,alias_name)
127
+ message.add 'id', id
128
+ message.add 'alias', alias_name
129
+ end
130
+ end
131
+ end
132
+
133
+ # Doc Placeholder
134
+ module Parser
135
+ class << self
136
+ def get_members(node)
137
+ # Return this if we are getting here by find_by_*
138
+ return (node/"//n2:dlm").map(&:to_s).compact if (node/"//n2:dlm").any?
139
+
140
+ # Return this if we get here by DirectorySearch
141
+ fwds = A.read(node, 'zimbraMailForwardingAddress')
142
+ fwds.is_a?(Array) ? fwds.map(&:to_s).compact : [fwds].compact
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end