ruote-extras 0.9.18

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.
@@ -0,0 +1,146 @@
1
+ #
2
+ #--
3
+ # Copyright (c) 2007-2008, John Mettraux, OpenWFE.org
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted provided that the following conditions are met:
8
+ #
9
+ # . Redistributions of source code must retain the above copyright notice, this
10
+ # list of conditions and the following disclaimer.
11
+ #
12
+ # . Redistributions in binary form must reproduce the above copyright notice,
13
+ # this list of conditions and the following disclaimer in the documentation
14
+ # and/or other materials provided with the distribution.
15
+ #
16
+ # . Neither the name of the "OpenWFE" nor the names of its contributors may be
17
+ # used to endorse or promote products derived from this software without
18
+ # specific prior written permission.
19
+ #
20
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
24
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30
+ # POSSIBILITY OF SUCH DAMAGE.
31
+ #++
32
+ #
33
+
34
+ #
35
+ # "made in Japan"
36
+ #
37
+ # John Mettraux at openwfe.org
38
+ #
39
+
40
+ require 'yaml'
41
+ require 'base64'
42
+ require 'monitor'
43
+
44
+ require 'openwfe/service'
45
+ require 'openwfe/listeners/listener'
46
+
47
+ #require 'rubygems'
48
+ require 'rufus/sqs' # gem 'rufus-sqs'
49
+
50
+
51
+ module OpenWFE
52
+ module Extras
53
+
54
+ #
55
+ # Polls an Amazon SQS queue for workitems
56
+ #
57
+ # Workitems can be instances of InFlowWorkItem or LaunchItem.
58
+ #
59
+ # require 'openwfe/extras/listeners/sqslisteners'
60
+ #
61
+ # ql = OpenWFE::SqsListener("workqueue1", engine.application_context)
62
+ #
63
+ # engine.add_workitem_listener(ql, "2m30s")
64
+ # #
65
+ # # thus, the engine will poll our "workqueue1" SQS queue
66
+ # # every 2 minutes and 30 seconds
67
+ #
68
+ class SqsListener < Service
69
+
70
+ include MonitorMixin
71
+ include WorkItemListener
72
+ include Rufus::Schedulable
73
+
74
+ #
75
+ # The name of the Amazon SQS whom this listener cares for
76
+ #
77
+ attr_reader :queue_name
78
+
79
+ def initialize (queue_name, application_context)
80
+
81
+ @queue_name = queue_name.to_s
82
+
83
+ service_name = "#{self.class}::#{@queue_name}"
84
+
85
+ super service_name, application_context
86
+
87
+ linfo { "new() queue is '#{@queue_name}'" }
88
+ end
89
+
90
+ #
91
+ # polls the SQS for incoming messages
92
+ #
93
+ def trigger (params)
94
+ synchronize do
95
+
96
+ ldebug { "trigger()" }
97
+
98
+ qs = Rufus::SQS::QueueService.new
99
+
100
+ qs.create_queue @queue_name
101
+ # just to be sure it is there
102
+
103
+ while true
104
+
105
+ l = qs.get_messages(
106
+ @queue_name, :timeout => 0, :count => 255)
107
+
108
+ break if l.length < 1
109
+
110
+ l.each do |msg|
111
+
112
+ o = decode_object msg
113
+
114
+ handle_item o
115
+
116
+ msg.delete
117
+
118
+ ldebug do
119
+ "trigger() " +
120
+ "handled successfully msg #{msg.message_id}"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ #
128
+ # Extracts a workitem from the message's body.
129
+ #
130
+ # By default, this listeners assumes the workitem is stored in
131
+ # its "hash form" (not directly as a Ruby InFlowWorkItem instance).
132
+ #
133
+ # LaunchItem instances (as hash as well) are also accepted.
134
+ #
135
+ def decode_object (message)
136
+
137
+ o = Base64.decode64 message.message_body
138
+ o = YAML.load o
139
+ o = OpenWFE::workitem_from_h o
140
+ o
141
+ end
142
+ end
143
+
144
+ end
145
+ end
146
+
@@ -0,0 +1,264 @@
1
+ #
2
+ #--
3
+ # Copyright (c) 2007-2008, John Mettraux, OpenWFE.org
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted provided that the following conditions are met:
8
+ #
9
+ # . Redistributions of source code must retain the above copyright notice, this
10
+ # list of conditions and the following disclaimer.
11
+ #
12
+ # . Redistributions in binary form must reproduce the above copyright notice,
13
+ # this list of conditions and the following disclaimer in the documentation
14
+ # and/or other materials provided with the distribution.
15
+ #
16
+ # . Neither the name of the "OpenWFE" nor the names of its contributors may be
17
+ # used to endorse or promote products derived from this software without
18
+ # specific prior written permission.
19
+ #
20
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
24
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30
+ # POSSIBILITY OF SUCH DAMAGE.
31
+ #++
32
+ #
33
+
34
+ #
35
+ # "made in Japan"
36
+ #
37
+ # John Mettraux at openwfe.org
38
+ #
39
+
40
+ #
41
+ # this participant requires atom-tools from
42
+ #
43
+ # http://code.necronomicorp.com/trac/atom-tools
44
+ #
45
+ # atom-tools' license is X11/MIT
46
+ #
47
+
48
+ #require 'monitor'
49
+
50
+ #require 'rubygems'
51
+ require 'atom/collection' # gem 'atoom-tools'
52
+
53
+ require 'openwfe/service'
54
+
55
+
56
+
57
+ module OpenWFE::Extras
58
+
59
+ #
60
+ # A workitem event as kept
61
+ #
62
+ class Entry
63
+
64
+ attr_accessor :id
65
+ attr_accessor :updated
66
+
67
+ attr_accessor :participant_name
68
+ attr_accessor :upon
69
+ attr_accessor :workitem
70
+
71
+ def initialize
72
+
73
+ @update = Time.now
74
+ end
75
+
76
+ def as_atom_entry
77
+
78
+ e = Atom::Entry.new
79
+
80
+ e.id = @id
81
+ e.updated = @updated
82
+
83
+ e
84
+ end
85
+ end
86
+
87
+ #
88
+ # This feed registers as an observer to the ParticipantMap.
89
+ # Each time a workitem is delivered to a participant or comes back from/for
90
+ # it, an AtomEntry is generated.
91
+ # The get_feed() method produces an atom feed of participant activity.
92
+ #
93
+ class ActivityFeedService
94
+ #include MonitorMixin
95
+ include OpenWFE::ServiceMixin
96
+ include OpenWFE::OwfeServiceLocator
97
+
98
+
99
+ attr_accessor :max_item_count
100
+
101
+
102
+ #
103
+ # This service is generally tied to an engine by doing :
104
+ #
105
+ # engine.init_service 'activityFeed', ActivityFeedService
106
+ #
107
+ # The init_service() will take care of calling the constructor
108
+ # implemented here.
109
+ #
110
+ def initialize (service_name, application_context)
111
+
112
+ super()
113
+
114
+ service_init service_name, application_context
115
+
116
+ @entries = []
117
+ @max_item_count = 100
118
+
119
+ get_participant_map.add_observer ".*", self
120
+ end
121
+
122
+ #
123
+ # This is the method call by the expression pool each time a
124
+ # workitem reaches a participant.
125
+ #
126
+ def call (channel, *args)
127
+
128
+ #ldebug "call() c '#{channel}' entries count : #{@entries.size}"
129
+
130
+ e = Entry.new
131
+
132
+ e.participant_name = channel
133
+ e.upon = args[0]
134
+ e.workitem = args[1].dup
135
+ e.updated = Time.now
136
+
137
+ e.id = \
138
+ "#{e.workitem.participant_name} - #{e.upon} " +
139
+ "#{e.workitem.fei.workflow_instance_id}--" +
140
+ "#{e.workitem.fei.expression_id}"
141
+
142
+ @entries << e
143
+
144
+ while @entries.length > @max_item_count
145
+ @entries.delete_at 0
146
+ end
147
+ end
148
+
149
+ #
150
+ # Returns an Atom feed of all the workitem activity for the
151
+ # participants whose name matches the given regular expression.
152
+ #
153
+ # Options :
154
+ #
155
+ # [:upon] can be set to either nil, :apply, :reply. :apply states
156
+ # that the returned feed should only contain entries about
157
+ # participant getting applied, :reply only about
158
+ # participant replying.
159
+ # [:feed_uri] the URI for the feed. Defaults to
160
+ # 'http://localhost/feed'
161
+ # [:feed_title] the title for the feed. Defaults to
162
+ # 'OpenWFEru engine activity feed'
163
+ # [:format] yaml|text|json
164
+ #
165
+ #
166
+ def get_feed (participant_regex, options={})
167
+
168
+ participant_regex = Regexp.compile(participant_regex) \
169
+ if participant_regex.is_a?(String)
170
+
171
+ upon = options[:upon]
172
+ feed_uri = options[:feed_uri] || "http://localhost/feed"
173
+ title = options[:feed_title] || "OpenWFEru engine activity feed"
174
+
175
+ feed = Atom::Feed.new feed_uri
176
+ feed.title = title
177
+
178
+ format = options[:format]
179
+ format = validate_format(format)
180
+
181
+ @entries.each do |e|
182
+
183
+ next unless participant_regex.match(e.participant_name)
184
+ next if upon and upon != e.upon
185
+
186
+ feed.updated = e.updated \
187
+ if feed.updated == nil or e.updated > feed.updated
188
+
189
+ feed << as_atom_entry(format, e)
190
+ end
191
+
192
+ feed
193
+ end
194
+
195
+ protected
196
+
197
+ #
198
+ # Makes sure the required 'render' is valid. Returns it
199
+ # as a Symbol.
200
+ #
201
+ def validate_format (f)
202
+
203
+ f = "as_#{f}"
204
+ return :as_yaml unless methods.include?(f)
205
+ f.to_sym
206
+ end
207
+
208
+ def as_atom_entry (render, entry)
209
+
210
+ send(
211
+ render,
212
+ entry.as_atom_entry,
213
+ entry.participant_name,
214
+ entry.upon,
215
+ entry.workitem)
216
+ end
217
+
218
+ #
219
+ # A basic rendition of a workitem as a YAML string
220
+ #
221
+ def as_yaml (atom_entry, participant_name, upon, workitem)
222
+
223
+ atom_entry.title = "#{participant_name} - #{upon}"
224
+ atom_entry.content = workitem.to_yaml
225
+ atom_entry.content['type'] = "text/plain"
226
+
227
+ atom_entry
228
+ end
229
+
230
+ #class Atom::Content
231
+ # def convert_contents e
232
+ # REXML::CData.new(@content.to_s)
233
+ # end
234
+ #end
235
+
236
+ #
237
+ # A basic rendition of a workitem as text.
238
+ #
239
+ def as_text (atom_entry, participant_name, upon, workitem)
240
+
241
+ atom_entry.title = "#{participant_name} - #{upon}"
242
+
243
+ atom_entry.content = workitem.to_s
244
+ atom_entry.content['type'] = "text/plain"
245
+
246
+ atom_entry
247
+ end
248
+
249
+ #
250
+ # Renders the workitem as a JSON string.
251
+ #
252
+ def as_json (atom_entry, participant_name, upon, workitem)
253
+
254
+ atom_entry.title = "#{participant_name} - #{upon}"
255
+
256
+ atom_entry.content = workitem.to_json
257
+ atom_entry.content['type'] = "text/plain"
258
+
259
+ atom_entry
260
+ end
261
+ end
262
+
263
+ end
264
+
@@ -0,0 +1,485 @@
1
+
2
+ #
3
+ # This code was written by 37signals.com
4
+ #
5
+ # The original is at :
6
+ # http://developer.37signals.com/basecamp/basecamp.rb
7
+ #
8
+
9
+ require 'net/https'
10
+ require 'yaml'
11
+ require 'date'
12
+ require 'time'
13
+
14
+ #require 'rubygems'
15
+ gem 'xml-simple'; require 'xmlsimple'
16
+
17
+ # An interface to the Basecamp web-services API. Usage is straightforward:
18
+ #
19
+ # session = Basecamp.new('your.basecamp.com', 'username', 'password')
20
+ # puts "projects: #{session.projects.length}"
21
+ class Basecamp #:nodoc:
22
+
23
+ # A wrapper to encapsulate the data returned by Basecamp, for easier access.
24
+ class Record #:nodoc:
25
+ attr_reader :type
26
+
27
+ def initialize(type, hash)
28
+ @type = type
29
+ @hash = hash
30
+ end
31
+
32
+ def [](name)
33
+ name = dashify(name)
34
+ case @hash[name]
35
+ when Hash then
36
+ @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
37
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
38
+ Record.new(name, @hash[name])
39
+ else @hash[name]
40
+ end
41
+ end
42
+
43
+ def id
44
+ @hash["id"]
45
+ end
46
+
47
+ def attributes
48
+ @hash.keys
49
+ end
50
+
51
+ def respond_to?(sym)
52
+ super || @hash.has_key?(dashify(sym))
53
+ end
54
+
55
+ def method_missing(sym, *args)
56
+ if args.empty? && !block_given? && respond_to?(sym)
57
+ self[sym]
58
+ else
59
+ super
60
+ end
61
+ end
62
+
63
+ def to_s
64
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
65
+ end
66
+
67
+ def inspect
68
+ to_s
69
+ end
70
+
71
+ private
72
+
73
+ def dashify(name)
74
+ name.to_s.tr("_", "-")
75
+ end
76
+ end
77
+
78
+ # A wrapper to represent a file that should be uploaded. This is used so that
79
+ # the form/multi-part encoder knows when to encode a field as a file, versus
80
+ # when to encode it as a simple field.
81
+ class FileUpload #:nodoc:
82
+ attr_reader :filename, :content
83
+
84
+ def initialize(filename, content)
85
+ @filename = filename
86
+ @content = content
87
+ end
88
+ end
89
+
90
+ attr_accessor :use_xml
91
+
92
+ # Connects
93
+ def initialize(url, user_name, password, use_ssl = false)
94
+ @use_xml = false
95
+ @user_name, @password = user_name, password
96
+ connect!(url, use_ssl)
97
+ end
98
+
99
+ # Return the list of all accessible projects.
100
+ def projects
101
+ records "project", "/project/list"
102
+ end
103
+
104
+ # Returns the list of message categories for the given project
105
+ def message_categories(project_id)
106
+ records "post-category", "/projects/#{project_id}/post_categories"
107
+ end
108
+
109
+ # Returns the list of file categories for the given project
110
+ def file_categories(project_id)
111
+ records "attachment-category", "/projects/#{project_id}/attachment_categories"
112
+ end
113
+
114
+ # Return information for the company with the given id
115
+ def company(id)
116
+ record "/contacts/company/#{id}"
117
+ end
118
+
119
+ # Return an array of the people in the given company. If the project-id is
120
+ # given, only people who have access to the given project will be returned.
121
+ def people(company_id, project_id=nil)
122
+ url = project_id ? "/projects/#{project_id}" : ""
123
+ url << "/contacts/people/#{company_id}"
124
+ records "person", url
125
+ end
126
+
127
+ # Return information about the person with the given id
128
+ def person(id)
129
+ record "/contacts/person/#{id}"
130
+ end
131
+
132
+ # Return information about the message(s) with the given id(s). The API
133
+ # limits you to requesting 25 messages at a time, so if you need to get more
134
+ # than that, you'll need to do it in multiple requests.
135
+ def message(*ids)
136
+ result = records("post", "/msg/get/#{ids.join(",")}")
137
+ result.length == 1 ? result.first : result
138
+ end
139
+
140
+ # Returns a summary of all messages in the given project (and category, if
141
+ # specified). The summary is simply the title and category of the message,
142
+ # as well as the number of attachments (if any).
143
+ def message_list(project_id, category_id=nil)
144
+ url = "/projects/#{project_id}/msg"
145
+ url << "/cat/#{category_id}" if category_id
146
+ url << "/archive"
147
+
148
+ records "post", url
149
+ end
150
+
151
+ # Create a new message in the given project. The +message+ parameter should
152
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
153
+ # should be notified of the post.
154
+ #
155
+ # If you want to add attachments to the message, the +attachments+ parameter
156
+ # should be an array of hashes, where each has has a :name key (optional),
157
+ # and a :file key (required). The :file key must refer to a Basecamp::FileUpload
158
+ # instance.
159
+ #
160
+ # msg = session.post_message(158141,
161
+ # { :title => "Requirements",
162
+ # :body => "Here are the requirements documents you asked for.",
163
+ # :category_id => 2301121 },
164
+ # [john.id, martha.id],
165
+ # [ { :name => "Primary Requirements",
166
+ # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
167
+ # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
168
+ def post_message(project_id, message, notify=[], attachments=[])
169
+ prepare_attachments(attachments)
170
+ record "/projects/#{project_id}/msg/create",
171
+ :post => message,
172
+ :notify => notify,
173
+ :attachments => attachments
174
+ end
175
+
176
+ # Edit the message with the given id. The +message+ parameter should
177
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
178
+ # should be notified of the post.
179
+ #
180
+ # The +attachments+ parameter, if used, should be the same as described for
181
+ # #post_message.
182
+ def update_message(id, message, notify=[], attachments=[])
183
+ prepare_attachments(attachments)
184
+ record "/msg/update/#{id}",
185
+ :post => message,
186
+ :notify => notify,
187
+ :attachments => attachments
188
+ end
189
+
190
+ # Deletes the message with the given id, and returns it.
191
+ def delete_message(id)
192
+ record "/msg/delete/#{id}"
193
+ end
194
+
195
+ # Return a list of the comments for the specified message.
196
+ def comments(post_id)
197
+ records "comment", "/msg/comments/#{post_id}"
198
+ end
199
+
200
+ # Retrieve a specific comment
201
+ def comment(id)
202
+ record "/msg/comment/#{id}"
203
+ end
204
+
205
+ # Add a new comment to a message. +comment+ must be a hash describing the
206
+ # comment. You can add attachments to the comment, too, by giving them in
207
+ # an array. See the #post_message method for a description of how to do that.
208
+ def create_comment(post_id, comment, attachments=[])
209
+ prepare_attachments(attachments)
210
+ record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
211
+ :attachments => attachments
212
+ end
213
+
214
+ # Update the given comment. Attachments follow the same format as #post_message.
215
+ def update_comment(id, comment, attachments=[])
216
+ prepare_attachments(attachments)
217
+ record "/msg/update_comment", :comment_id => id,
218
+ :comment => comment, :attachments => attachments
219
+ end
220
+
221
+ # Deletes (and returns) the given comment.
222
+ def delete_comment(id)
223
+ record "/msg/delete_comment/#{id}"
224
+ end
225
+
226
+ # =========================================================================
227
+ # TODO LISTS AND ITEMS
228
+ # =========================================================================
229
+
230
+ # Marks the given item completed.
231
+ def complete_item(id)
232
+ record "/todos/complete_item/#{id}"
233
+ end
234
+
235
+ # Marks the given item uncompleted.
236
+ def uncomplete_item(id)
237
+ record "/todos/uncomplete_item/#{id}"
238
+ end
239
+
240
+ # Creates a new to-do item.
241
+ def create_item(list_id, content, responsible_party=nil, notify=true)
242
+ record "/todos/create_item/#{list_id}",
243
+ :content => content, :responsible_party => responsible_party,
244
+ :notify => notify
245
+ end
246
+
247
+ # Creates a new list using the given hash of list metadata.
248
+ def create_list(project_id, list)
249
+ record "/projects/#{project_id}/todos/create_list", list
250
+ end
251
+
252
+ # Deletes the given item from it's parent list.
253
+ def delete_item(id)
254
+ record "/todos/delete_item/#{id}"
255
+ end
256
+
257
+ # Deletes the given list and all of its items.
258
+ def delete_list(id)
259
+ record "/todos/delete_list/#{id}"
260
+ end
261
+
262
+ # Retrieves the specified list, and all of its items.
263
+ def get_list(id)
264
+ record "/todos/list/#{id}"
265
+ end
266
+
267
+ # Return all lists for a project. If complete is true, only completed lists
268
+ # are returned. If complete is false, only uncompleted lists are returned.
269
+ def lists(project_id, complete=nil)
270
+ records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
271
+ end
272
+
273
+ # Repositions an item to be at the given position in its list
274
+ def move_item(id, to)
275
+ record "/todos/move_item/#{id}", :to => to
276
+ end
277
+
278
+ # Repositions a list to be at the given position in its project
279
+ def move_list(id, to)
280
+ record "/todos/move_list/#{id}", :to => to
281
+ end
282
+
283
+ # Updates the given item
284
+ def update_item(id, content, responsible_party=nil, notify=true)
285
+ record "/todos/update_item/#{id}",
286
+ :item => { :content => content }, :responsible_party => responsible_party,
287
+ :notify => notify
288
+ end
289
+
290
+ # Updates the given list's metadata
291
+ def update_list(id, list)
292
+ record "/todos/update_list/#{id}", :list => list
293
+ end
294
+
295
+ # =========================================================================
296
+ # MILESTONES
297
+ # =========================================================================
298
+
299
+ # Complete the milestone with the given id
300
+ def complete_milestone(id)
301
+ record "/milestones/complete/#{id}"
302
+ end
303
+
304
+ # Create a new milestone for the given project. +data+ must be hash of the
305
+ # values to set, including +title+, +deadline+, +responsible_party+, and
306
+ # +notify+.
307
+ def create_milestone(project_id, data)
308
+ create_milestones(project_id, [data]).first
309
+ end
310
+
311
+ # As #create_milestone, but can create multiple milestones in a single
312
+ # request. The +milestones+ parameter must be an array of milestone values as
313
+ # descrbed in #create_milestone.
314
+ def create_milestones(project_id, milestones)
315
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
316
+ end
317
+
318
+ # Destroys the milestone with the given id.
319
+ def delete_milestone(id)
320
+ record "/milestones/delete/#{id}"
321
+ end
322
+
323
+ # Returns a list of all milestones for the given project, optionally filtered
324
+ # by whether they are completed, late, or upcoming.
325
+ def milestones(project_id, find="all")
326
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
327
+ end
328
+
329
+ # Uncomplete the milestone with the given id
330
+ def uncomplete_milestone(id)
331
+ record "/milestones/uncomplete/#{id}"
332
+ end
333
+
334
+ # Updates an existing milestone.
335
+ def update_milestone(id, data, move=false, move_off_weekends=false)
336
+ record "/milestones/update/#{id}", :milestone => data,
337
+ :move_upcoming_milestones => move,
338
+ :move_upcoming_milestones_off_weekends => move_off_weekends
339
+ end
340
+
341
+ # Make a raw web-service request to Basecamp. This will return a Hash of
342
+ # Arrays of the response, and may seem a little odd to the uninitiated.
343
+ def request(path, parameters = {}, second_try = false)
344
+ response = post(path, convert_body(parameters), "Content-Type" => content_type)
345
+
346
+ if response.code.to_i / 100 == 2
347
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true,
348
+ 'contentkey' => '__content__', 'forcecontent' => true)
349
+ typecast_value(result)
350
+ elsif response.code == "302" && !second_try
351
+ connect!(@url, !@use_ssl)
352
+ request(path, parameters, true)
353
+ else
354
+ raise "#{response.message} (#{response.code})"
355
+ end
356
+ end
357
+
358
+ # A convenience method for wrapping the result of a query in a Record
359
+ # object. This assumes that the result is a singleton, not a collection.
360
+ def record(path, parameters={})
361
+ result = request(path, parameters)
362
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
363
+ end
364
+
365
+ # A convenience method for wrapping the result of a query in Record
366
+ # objects. This assumes that the result is a collection--any singleton
367
+ # result will be wrapped in an array.
368
+ def records(node, path, parameters={})
369
+ result = request(path, parameters).values.first or return []
370
+ result = result[node] or return []
371
+ result = [result] unless Array === result
372
+ result.map { |row| Record.new(node, row) }
373
+ end
374
+
375
+ private
376
+
377
+ def connect!(url, use_ssl)
378
+ @use_ssl = use_ssl
379
+ @url = url
380
+ @connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
381
+ @connection.use_ssl = @use_ssl
382
+ @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
383
+ end
384
+
385
+ def convert_body(body)
386
+ body = use_xml ? body.to_xml : body.to_yaml
387
+ end
388
+
389
+ def content_type
390
+ use_xml ? "application/xml" : "application/x-yaml"
391
+ end
392
+
393
+ def post(path, body, header={})
394
+ request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
395
+ request.basic_auth(@user_name, @password)
396
+ @connection.request(request, body)
397
+ end
398
+
399
+ def store_file(contents)
400
+ response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
401
+ 'Accept' => 'application/xml')
402
+
403
+ if response.code == "200"
404
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
405
+ return result["upload"]["id"]
406
+ else
407
+ raise "Could not store file: #{response.message} (#{response.code})"
408
+ end
409
+ end
410
+
411
+ def typecast_value(value)
412
+ case value
413
+ when Hash
414
+ if value.has_key?("__content__")
415
+ content = translate_entities(value["__content__"]).strip
416
+ case value["type"]
417
+ when "integer" then content.to_i
418
+ when "boolean" then content == "true"
419
+ when "datetime" then Time.parse(content)
420
+ when "date" then Date.parse(content)
421
+ else content
422
+ end
423
+ # a special case to work-around a bug in XmlSimple. When you have an empty
424
+ # tag that has an attribute, XmlSimple will not add the __content__ key
425
+ # to the returned hash. Thus, we check for the presense of the 'type'
426
+ # attribute to look for empty, typed tags, and simply return nil for
427
+ # their value.
428
+ elsif value.keys == %w(type)
429
+ nil
430
+ elsif value["nil"] == "true"
431
+ nil
432
+ # another special case, introduced by the latest rails, where an array
433
+ # type now exists. This is parsed by XmlSimple as a two-key hash, where
434
+ # one key is 'type' and the other is the actual array value.
435
+ elsif value.keys.length == 2 && value["type"] == "array"
436
+ value.delete("type")
437
+ typecast_value(value)
438
+ else
439
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
440
+ h[k] = typecast_value(v)
441
+ h
442
+ end
443
+ end
444
+ when Array
445
+ value.map! { |i| typecast_value(i) }
446
+ case value.length
447
+ when 0 then nil
448
+ when 1 then value.first
449
+ else value
450
+ end
451
+ else
452
+ raise "can't typecast #{value.inspect}"
453
+ end
454
+ end
455
+
456
+ def translate_entities(value)
457
+ value.gsub(/&lt;/, "<").
458
+ gsub(/&gt;/, ">").
459
+ gsub(/&quot;/, '"').
460
+ gsub(/&apos;/, "'").
461
+ gsub(/&amp;/, "&")
462
+ end
463
+
464
+ def prepare_attachments(list)
465
+ (list || []).each do |data|
466
+ upload = data[:file]
467
+ id = store_file(upload.content)
468
+ data[:file] = { :file => id,
469
+ :content_type => "application/octet-stream",
470
+ :original_filename => upload.filename }
471
+ end
472
+ end
473
+ end
474
+
475
+ # A minor hack to let Xml-Simple serialize symbolic keys in hashes
476
+ #class Symbol
477
+ # def [](*args)
478
+ # to_s[*args]
479
+ # end
480
+ #end
481
+ #class Hash
482
+ # def to_xml
483
+ # XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
484
+ # end
485
+ #end