ticketmaster-basecamp 0.1.0

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