wuputah / basecamp-timereport

Generates summary reports from Basecamp data

basecamp-timereport / basecamp.rb
cb8afd26 » jdance 2008-11-24 hurrah, time reporting done... 1 require 'net/https'
2 require 'yaml'
3 require 'date'
4 require 'time'
5
6 begin
7 require 'xmlsimple'
8 rescue LoadError
9 begin
10 require 'rubygems'
11 require 'xmlsimple'
12 rescue LoadError
13 abort <<-ERROR
14 The 'xml-simple' library could not be loaded. If you have RubyGems installed
15 you can install xml-simple by doing "gem install xml-simple".
16 ERROR
17 end
18 end
19
20 begin
21 require 'activeresource'
22 rescue LoadError
23 begin
24 require 'rubygems'
25 require 'activeresource'
26 rescue LoadError
27 abort <<-ERROR
28 The 'activeresource' library could not be loaded. If you have RubyGems
29 installed you can install ActiveResource by doing "gem install activeresource".
30 ERROR
31 end
32 end
33
34 # = A Ruby library for working with the Basecamp web-services API.
35 #
36 # For more information about the Basecamp web-services API, visit:
37 #
38 # http://developer.37signals.com/basecamp
39 #
40 # NOTE: not all of Basecamp's web-services are accessible via REST. This
41 # library provides access to RESTful services via ActiveResource. Services not
42 # yet upgraded to REST are accessed via the Basecamp class. Continue reading
43 # for more details.
44 #
45 #
46 # == Establishing a Connection
47 #
48 # The first thing you need to do is establish a connection to Basecamp. This
49 # requires your Basecamp site address and your login credentials. Example:
50 #
51 # Basecamp.establish_connection!('you.grouphub.com', 'username', 'password')
52 #
53 # This is the same whether you're accessing using the ActiveResource interface,
54 # or the legacy interface.
55 #
56 #
57 # == Using the REST interface via ActiveResource
58 #
59 # The REST interface is accessed via ActiveResource, a popular Ruby library
60 # that implements object-relational mapping for REST web-services. For more
61 # information on working with ActiveResource, see:
62 #
63 # * http://api.rubyonrails.org/files/activeresource/README.html
64 # * http://api.rubyonrails.org/classes/ActiveResource/Base.html
65 #
66 # === Finding a Resource
67 #
68 # Find a specific resource using the +find+ method. Attributes of the resource
69 # are available as instance methods on the resulting object. For example, to
70 # find a message with the ID of 8675309 and access its title attribute, you
71 # would do the following:
72 #
73 # m = Basecamp::Message.find(8675309)
74 # m.title # => 'Jenny'
75 #
76 # === Creating a Resource
77 #
78 # Create a resource by making a new instance of that resource, setting its
79 # attributes, and saving it. If the resource requires a prefix to identify
80 # it (as is the case with resources that belong to a sub-resource, such as a
81 # project), it should be specified when instantiating the object. Examples:
82 #
83 # m = Basecamp::Message.new(:project_id => 1037)
84 # m.category_id = 7301
85 # m.title = 'Message in a bottle'
86 # m.body = 'Another lonely day, with no one here but me'
87 # m.save # => true
88 #
89 # c = Basecamp::Comment.new(:post_id => 25874)
90 # c.body = 'Did you get those TPS reports?'
91 # c.save # => true
92 #
93 # You can also create a resource using the +create+ method, which will create
94 # and save it in one step. Example:
95 #
96 # Basecamp::TodoItem.create(:todo_list_id => 3422, :contents => 'Do it')
97 #
98 # === Updating a Resource
99 #
100 # To update a resource, first find it by its id, change its attributes, and
101 # save it. Example:
102 #
103 # m = Basecamp::Message.find(8675309)
104 # m.body = 'Changed'
105 # m.save # => true
106 #
107 # === Deleting a Resource
108 #
109 # To delete a resource, use the +delete+ method with the ID of the resource
110 # you want to delete. Example:
111 #
112 # Basecamp::Message.delete(1037)
113 #
114 # === Attaching Files to a Resource
115 #
116 # If the resource accepts file attachments, the +attachments+ parameter should
117 # be an array of Basecamp::Attachment objects. Example:
118 #
119 # a1 = Basecamp::Atachment.create('primary', File.read('primary.doc'))
120 # a2 = Basecamp::Atachment.create('another', File.read('another.doc'))
121 #
122 # m = Basecamp::Message.new(:project_id => 1037)
123 # ...
124 # m.attachments = [a1, a2]
125 # m.save # => true
126 #
127 #
128 # = Using the non-REST inteface
129 #
130 # The non-REST interface is accessed via instance methods on the Basecamp
131 # class. Ensure you've established a connection, then create a new Basecamp
132 # instance and call methods on it. Examples:
133 #
134 # basecamp = Basecamp.new
135 #
136 # basecamp.projects.length # => 5
137 # basecamp.person(93832) # => #<Record(person)..>
138 # basecamp.file_categories(123) # => [#<Record(file-category)>,#<Record..>]
139 #
140 ## Object attributes are accessible as methods. Example:
141 #
142 # person = basecamp.person(93832)
143 # person.first_name # => "Jason"
144 #
145 class Basecamp
146 class Connection #:nodoc:
147 def initialize(master)
148 @master = master
149 @connection = Net::HTTP.new(master.site, master.use_ssl ? 443 : 80)
150 @connection.use_ssl = master.use_ssl
151 @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if master.use_ssl
152 end
153
154 def post(path, body, headers = {})
155 request = Net::HTTP::Post.new(path, headers.merge('Accept' => 'application/xml'))
156 request.basic_auth(@master.user, @master.password)
157 @connection.request(request, body)
158 end
159 end
160
161 class Resource < ActiveResource::Base #:nodoc:
162 class << self
163 def parent_resources(*parents)
164 @parent_resources = parents
165 end
166
167 def element_name
168 name.split(/::/).last.underscore
169 end
170
171 def prefix_source
172 @parent_resources.map { |resource| "/#{resource.to_s.pluralize}/:#{resource}_id/" }.join
173 end
174
175 def prefix(options={})
176 if options.any?
177 options.map { |name, value| "/#{name.to_s.chomp('_id').pluralize}/#{value}/" }.join
178 else
179 super
180 end
181 end
182 end
183
184 def prefix_options
185 id ? {} : super
186 end
187 end
188
189 class Project < Resource
190 end
191
192 class Message < Resource
193 parent_resources :project
194 self.element_name = 'post'
195
196 # Returns the most recent 25 messages in the given project (and category,
197 # if specified). If you need to retrieve older messages, use the archive
198 # method instead. Example:
199 #
200 # Basecamp::Message.list(1037)
201 # Basecamp::Message.list(1037, :category_id => 7301)
202 #
203 def self.list(project_id, options = {})
204 find(:all, :params => options.merge(:project_id => project_id))
205 end
206
207 # Returns a summary of all messages in the given project (and category, if
208 # specified). The summary is simply the title and category of the message,
209 # as well as the number of attachments (if any). Example:
210 #
211 # Basecamp::Message.archive(1037)
212 # Basecamp::Message.archive(1037, :category_id => 7301)
213 #
214 def self.archive(project_id, options = {})
215 find(:all, :params => options.merge(:project_id => project_id), :from => :archive)
216 end
217
218 def comments(options = {})
219 @comments ||= Comment.find(:all, :params => options.merge(:post_id => id))
220 end
221 end
222
223 # == Creating Comments for Multiple Resources
224 #
225 # Comments can be created for messages, milestones, and to-dos, identified
226 # by the <tt>post_id</tt>, <tt>milestone_id</tt>, and <tt>todo_item_id</tt>
227 # params respectively.
228 #
229 # For example, to create a comment on the message with id #8675309:
230 #
231 # c = Basecamp::Comment.new(:post_id => 8675309)
232 # c.body = 'Great tune'
233 # c.save # => true
234 #
235 # Similarly, to create a comment on a milestone:
236 #
237 # c = Basecamp::Comment.new(:milestone_id => 8473647)
238 # c.body = 'Is this done yet?'
239 # c.save # => true
240 #
241 class Comment < Resource
242 parent_resources :post, :milestone, :todo_item
243 end
244
245 class TodoList < Resource
246 parent_resources :project
247
248 # Returns all lists for a project. If complete is true, only completed lists
249 # are returned. If complete is false, only uncompleted lists are returned.
250 def self.all(project_id, complete=nil)
251 filter = case complete
252 when nil then "all"
253 when true then "finished"
254 when false then "pending"
255 else raise ArgumentError, "invalid value for `complete'"
256 end
257
258 find(:all, :params => { :project_id => project_id, :filter => filter })
259 end
260
261 def todo_items(options={})
262 @todo_items ||= TodoItem.find(:all, :params => options.merge(:todo_list_id => id))
263 end
264 end
265
266 class TodoItem < Resource
267 parent_resources :todo_list
268
269 def todo_list(options={})
270 @todo_list ||= TodoList.find(todo_list_id, options)
271 end
272
273 def time_entries(options={})
274 @time_entries ||= TimeEntry.find(:all, :params => options.merge(:todo_item_id => id))
275 end
276
277 def comments(options = {})
278 @comments ||= Comment.find(:all, :params => options.merge(:todo_item_id => id))
279 end
280
281 def complete!
282 put(:complete)
283 end
284
285 def uncomplete!
286 put(:uncomplete)
287 end
288 end
289
290 class TimeEntry < Resource
291 parent_resources :project, :todo_item
292
293 def self.all(project_id, page=0)
294 find(:all, :params => { :project_id => project_id, :page => page })
295 end
296
297 def self.report(options={})
298 find(:all, :from => :report, :params => options)
299 end
300
301 def todo_item(options={})
302 @todo_item ||= todo_item_id && TodoItem.find(todo_item_id, options)
303 end
304 end
305
306 class Attachment
307 attr_accessor :id, :filename, :content, :content_type
308
309 def self.create(filename, content)
310 returning new(filename, content) do |attachment|
311 attachment.save
312 end
313 end
314
315 def initialize(filename, content, content_type = 'application/octet-stream')
316 @filename, @content, @content_type = filename, content, content_type
317 end
318
319 def attributes
320 { :file => id, :original_filename => filename, :content_type => content_type }
321 end
322
323 def to_xml(options = {})
324 { :file => attributes }.to_xml(options)
325 end
326
327 def inspect
328 to_s
329 end
330
331 def save
332 response = Basecamp.connection.post('/upload', content, 'Content-Type' => content_type)
333
334 if response.code == '200'
335 self.id = Hash.from_xml(response.body)['upload']['id']
336 true
337 else
338 raise "Could not save attachment: #{response.message} (#{response.code})"
339 end
340 end
341 end
342
343 class Record #:nodoc:
344 attr_reader :type
345
346 def initialize(type, hash)
347 @type, @hash = type, hash
348 end
349
350 def [](name)
351 name = dashify(name)
352
353 case @hash[name]
354 when Hash then
355 @hash[name] = if (@hash[name].keys.length == 1 && @hash[name].values.first.is_a?(Array))
356 @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) }
357 else
358 Record.new(name, @hash[name])
359 end
360 else
361 @hash[name]
362 end
363 end
364
365 def id
366 @hash['id']
367 end
368
369 def attributes
370 @hash.keys
371 end
372
373 def respond_to?(sym)
374 super || @hash.has_key?(dashify(sym))
375 end
376
377 def method_missing(sym, *args)
378 if args.empty? && !block_given? && respond_to?(sym)
379 self[sym]
380 else
381 super
382 end
383 end
384
385 def to_s
386 "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
387 end
388
389 def inspect
390 to_s
391 end
392
393 private
394
395 def dashify(name)
396 name.to_s.tr("_", "-")
397 end
398 end
399
400 attr_accessor :use_xml
401
402 class << self
403 attr_reader :site, :user, :password, :use_ssl
404
405 def establish_connection!(site, user, password, use_ssl = false)
406 @site = site
407 @user = user
408 @password = password
409 @use_ssl = use_ssl
410
411 Resource.user = user
412 Resource.password = password
413 Resource.site = (use_ssl ? "https" : "http") + "://" + site
414
415 @connection = Connection.new(self)
416 end
417
418 def connection
419 @connection || raise('No connection established')
420 end
421 end
422
423 def initialize
424 @use_xml = false
425 end
426
427 # ==========================================================================
428 # GENERAL
429 # ==========================================================================
430
431 # Return the list of all accessible projects
432 def projects
433 records "project", "/project/list"
434 end
435
436 # Returns the list of message categories for the given project
437 def message_categories(project_id)
438 records "post-category", "/projects/#{project_id}/post_categories"
439 end
440
441 # Returns the list of file categories for the given project
442 def file_categories(project_id)
443 records "attachment-category", "/projects/#{project_id}/attachment_categories"
444 end
445
446 # ==========================================================================
447 # CONTACT MANAGEMENT
448 # ==========================================================================
449
450 # Return information for the company with the given id
451 def company(id)
452 record "/contacts/company/#{id}"
453 end
454
455 # Return an array of the people in the given company. If the project-id is
456 # given, only people who have access to the given project will be returned.
457 def people(company_id, project_id=nil)
458 url = project_id ? "/projects/#{project_id}" : ""
459 url << "/contacts/people/#{company_id}"
460 records "person", url
461 end
462
463 # Return information about the person with the given id
464 def person(id)
465 record "/contacts/person/#{id}"
466 end
467
468 # ==========================================================================
469 # MILESTONES
470 # ==========================================================================
471
472 # Complete the milestone with the given id
473 def complete_milestone(id)
474 record "/milestones/complete/#{id}"
475 end
476
477 # Create a new milestone for the given project. +data+ must be hash of the
478 # values to set, including +title+, +deadline+, +responsible_party+, and
479 # +notify+.
480 def create_milestone(project_id, data)
481 create_milestones(project_id, [data]).first
482 end
483
484 # As #create_milestone, but can create multiple milestones in a single
485 # request. The +milestones+ parameter must be an array of milestone values as
486 # descrbed in #create_milestone.
487 def create_milestones(project_id, milestones)
488 records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
489 end
490
491 # Destroys the milestone with the given id.
492 def delete_milestone(id)
493 record "/milestones/delete/#{id}"
494 end
495
496 # Returns a list of all milestones for the given project, optionally filtered
497 # by whether they are completed, late, or upcoming.
498 def milestones(project_id, find="all")
499 records "milestone", "/projects/#{project_id}/milestones/list", :find => find
500 end
501
502 # Uncomplete the milestone with the given id
503 def uncomplete_milestone(id)
504 record "/milestones/uncomplete/#{id}"
505 end
506
507 # Updates an existing milestone.
508 def update_milestone(id, data, move=false, move_off_weekends=false)
509 record "/milestones/update/#{id}", :milestone => data,
510 :move_upcoming_milestones => move,
511 :move_upcoming_milestones_off_weekends => move_off_weekends
512 end
513
514 private
515
516 # Make a raw web-service request to Basecamp. This will return a Hash of
517 # Arrays of the response, and may seem a little odd to the uninitiated.
518 def request(path, parameters = {})
519 response = Basecamp.connection.post(path, convert_body(parameters), "Content-Type" => content_type)
520
521 if response.code.to_i / 100 == 2
522 result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'contentkey' => '__content__', 'forcecontent' => true)
523 typecast_value(result)
524 else
525 raise "#{response.message} (#{response.code})"
526 end
527 end
528
529 # A convenience method for wrapping the result of a query in a Record
530 # object. This assumes that the result is a singleton, not a collection.
531 def record(path, parameters={})
532 result = request(path, parameters)
533 (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
534 end
535
536 # A convenience method for wrapping the result of a query in Record
537 # objects. This assumes that the result is a collection--any singleton
538 # result will be wrapped in an array.
539 def records(node, path, parameters={})
540 result = request(path, parameters).values.first or return []
541 result = result[node] or return []
542 result = [result] unless Array === result
543 result.map { |row| Record.new(node, row) }
544 end
545
546 def convert_body(body)
547 body = use_xml ? body.to_legacy_xml : body.to_yaml
548 end
549
550 def content_type
551 use_xml ? "application/xml" : "application/x-yaml"
552 end
553
554 def typecast_value(value)
555 case value
556 when Hash
557 if value.has_key?("__content__")
558 content = translate_entities(value["__content__"]).strip
559 case value["type"]
560 when "integer" then content.to_i
561 when "boolean" then content == "true"
562 when "datetime" then Time.parse(content)
563 when "date" then Date.parse(content)
564 else content
565 end
566 # a special case to work-around a bug in XmlSimple. When you have an empty
567 # tag that has an attribute, XmlSimple will not add the __content__ key
568 # to the returned hash. Thus, we check for the presense of the 'type'
569 # attribute to look for empty, typed tags, and simply return nil for
570 # their value.
571 elsif value.keys == %w(type)
572 nil
573 elsif value["nil"] == "true"
574 nil
575 # another special case, introduced by the latest rails, where an array
576 # type now exists. This is parsed by XmlSimple as a two-key hash, where
577 # one key is 'type' and the other is the actual array value.
578 elsif value.keys.length == 2 && value["type"] == "array"
579 value.delete("type")
580 typecast_value(value)
581 else
582 value.empty? ? nil : value.inject({}) do |h,(k,v)|
583 h[k] = typecast_value(v)
584 h
585 end
586 end
587 when Array
588 value.map! { |i| typecast_value(i) }
589 case value.length
590 when 0 then nil
591 when 1 then value.first
592 else value
593 end
594 else
595 raise "can't typecast #{value.inspect}"
596 end
597 end
598
599 def translate_entities(value)
600 value.gsub(/&lt;/, "<").
601 gsub(/&gt;/, ">").
602 gsub(/&quot;/, '"').
603 gsub(/&apos;/, "'").
604 gsub(/&amp;/, "&")
605 end
606 end
607
608 # A minor hack to let Xml-Simple serialize symbolic keys in hashes
609 class Symbol
610 def [](*args)
611 to_s[*args]
612 end
613 end
614
615 class Hash
616 def to_legacy_xml
617 XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
618 end
619 end