ticketmaster-basecamp 0.1.0

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,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