harvested 0.3.3 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (109) hide show
  1. data/Gemfile +15 -0
  2. data/HISTORY +11 -0
  3. data/README.md +14 -6
  4. data/Rakefile +13 -31
  5. data/TODO +2 -0
  6. data/VERSION +1 -1
  7. data/examples/user_assignments.rb +1 -1
  8. data/harvested.gemspec +114 -126
  9. data/lib/ext/array.rb +52 -0
  10. data/lib/ext/date.rb +9 -0
  11. data/lib/ext/hash.rb +17 -0
  12. data/lib/ext/time.rb +5 -0
  13. data/lib/harvest/api/account.rb +8 -1
  14. data/lib/harvest/api/base.rb +32 -10
  15. data/lib/harvest/api/contacts.rb +1 -1
  16. data/lib/harvest/api/expenses.rb +3 -4
  17. data/lib/harvest/api/invoice_categories.rb +26 -0
  18. data/lib/harvest/api/invoices.rb +16 -0
  19. data/lib/harvest/api/projects.rb +2 -10
  20. data/lib/harvest/api/reports.rb +13 -16
  21. data/lib/harvest/api/task_assignments.rb +8 -6
  22. data/lib/harvest/api/tasks.rb +1 -1
  23. data/lib/harvest/api/time.rb +13 -13
  24. data/lib/harvest/api/user_assignments.rb +7 -5
  25. data/lib/harvest/base.rb +9 -1
  26. data/lib/harvest/behavior/activatable.rb +2 -2
  27. data/lib/harvest/behavior/crud.rb +15 -13
  28. data/lib/harvest/client.rb +18 -13
  29. data/lib/harvest/contact.rb +13 -11
  30. data/lib/harvest/errors.rb +6 -4
  31. data/lib/harvest/expense.rb +40 -14
  32. data/lib/harvest/expense_category.rb +10 -9
  33. data/lib/harvest/hardy_client.rb +1 -1
  34. data/lib/harvest/invoice.rb +103 -0
  35. data/lib/harvest/invoice_category.rb +18 -0
  36. data/lib/harvest/line_item.rb +12 -0
  37. data/lib/harvest/model.rb +120 -0
  38. data/lib/harvest/project.rb +55 -26
  39. data/lib/harvest/rate_limit_status.rb +9 -8
  40. data/lib/harvest/task.rb +17 -14
  41. data/lib/harvest/task_assignment.rb +27 -22
  42. data/lib/harvest/time_entry.rb +32 -30
  43. data/lib/harvest/user.rb +46 -22
  44. data/lib/harvest/user_assignment.rb +24 -17
  45. data/lib/harvested.rb +12 -5
  46. data/spec/functional/account_spec.rb +17 -0
  47. data/spec/functional/clients_spec.rb +58 -0
  48. data/spec/functional/errors_spec.rb +22 -0
  49. data/spec/functional/expenses_spec.rb +84 -0
  50. data/spec/functional/hardy_client_spec.rb +33 -0
  51. data/spec/functional/invoice_spec.rb +67 -0
  52. data/spec/functional/project_spec.rb +50 -0
  53. data/spec/functional/reporting_spec.rb +80 -0
  54. data/spec/functional/tasks_spec.rb +88 -0
  55. data/spec/functional/time_tracking_spec.rb +53 -0
  56. data/spec/functional/users_spec.rb +102 -0
  57. data/spec/harvest/base_spec.rb +1 -1
  58. data/spec/harvest/credentials_spec.rb +1 -1
  59. data/spec/harvest/expense_category_spec.rb +5 -0
  60. data/spec/harvest/expense_spec.rb +8 -5
  61. data/spec/harvest/invoice_spec.rb +47 -0
  62. data/spec/harvest/project_spec.rb +11 -0
  63. data/spec/harvest/task_assignment_spec.rb +4 -4
  64. data/spec/harvest/task_spec.rb +7 -0
  65. data/spec/harvest/time_entry_spec.rb +11 -10
  66. data/spec/harvest/user_assignment_spec.rb +3 -3
  67. data/spec/harvest/user_spec.rb +3 -1
  68. data/spec/spec_helper.rb +37 -6
  69. data/{features → spec}/support/harvest_credentials.example.yml +0 -1
  70. data/spec/support/harvested_helpers.rb +44 -0
  71. data/spec/support/json_examples.rb +11 -0
  72. data/spec/test_rubies +5 -0
  73. metadata +109 -85
  74. data/.gitignore +0 -28
  75. data/features/account.feature +0 -7
  76. data/features/client_contacts.feature +0 -23
  77. data/features/clients.feature +0 -29
  78. data/features/errors.feature +0 -25
  79. data/features/expense_categories.feature +0 -21
  80. data/features/expenses.feature +0 -55
  81. data/features/hardy_client.feature +0 -40
  82. data/features/projects.feature +0 -39
  83. data/features/reporting.feature +0 -72
  84. data/features/step_definitions/account_steps.rb +0 -7
  85. data/features/step_definitions/assignment_steps.rb +0 -100
  86. data/features/step_definitions/contact_steps.rb +0 -11
  87. data/features/step_definitions/debug_steps.rb +0 -3
  88. data/features/step_definitions/error_steps.rb +0 -113
  89. data/features/step_definitions/expenses_steps.rb +0 -46
  90. data/features/step_definitions/harvest_steps.rb +0 -8
  91. data/features/step_definitions/model_steps.rb +0 -90
  92. data/features/step_definitions/people_steps.rb +0 -4
  93. data/features/step_definitions/report_steps.rb +0 -91
  94. data/features/step_definitions/time_entry_steps.rb +0 -40
  95. data/features/support/env.rb +0 -37
  96. data/features/support/error_helpers.rb +0 -18
  97. data/features/support/fixtures/empty_clients.xml +0 -2
  98. data/features/support/fixtures/over_limit.xml +0 -8
  99. data/features/support/fixtures/receipt.png +0 -0
  100. data/features/support/fixtures/under_limit.xml +0 -8
  101. data/features/support/harvest_helpers.rb +0 -11
  102. data/features/support/inflections.rb +0 -9
  103. data/features/task_assignment.feature +0 -69
  104. data/features/tasks.feature +0 -25
  105. data/features/time_tracking.feature +0 -29
  106. data/features/user_assignments.feature +0 -33
  107. data/features/users.feature +0 -55
  108. data/lib/harvest/base_model.rb +0 -73
  109. data/spec/spec.default.opts +0 -1
@@ -10,21 +10,26 @@ module Harvest
10
10
  # [+active?+] true|false on whether the client is active
11
11
  # [+highrise_id+] (READONLY) the highrise id associated with this client
12
12
  # [+update_at+] (READONLY) the last modification timestamp
13
- class Client < BaseModel
14
- include HappyMapper
15
-
13
+ class Client < Hashie::Dash
14
+ include Harvest::Model
15
+
16
16
  api_path '/clients'
17
-
18
- element :id, Integer
19
- element :active, Boolean
20
- element :name, String
21
- element :details, String
22
- element :currency, String
23
- element :currency_symbol, String, :tag => "currency-symbol"
24
- element :cache_version, Integer, :tag => "cache-version"
25
- element :updated_at, Time, :tag => "updated-at"
26
- element :highrise_id, Integer, :tag => 'highrise-id'
17
+
18
+
19
+ property :id
20
+ property :active
21
+ property :name
22
+ property :details
23
+ property :currency
24
+ property :currency_symbol
25
+ property :cache_version
26
+ property :created_at
27
+ property :updated_at
28
+ property :highrise_id
29
+ property :default_invoice_timeframe
30
+ property :last_invoice_kind
27
31
 
28
32
  alias_method :active?, :active
33
+ alias_method :is_active=, :active=
29
34
  end
30
35
  end
@@ -11,19 +11,21 @@ module Harvest
11
11
  # [+phone_office+] the office phone number of the contact
12
12
  # [+phone_moble+] the moble phone number of the contact
13
13
  # [+fax+] the fax number of the contact
14
- class Contact < BaseModel
15
- include HappyMapper
14
+ class Contact < Hashie::Dash
15
+ include Harvest::Model
16
16
 
17
17
  api_path '/contacts'
18
18
 
19
- element :id, Integer
20
- element :client_id, Integer, :tag => "client-id"
21
- element :email, String
22
- element :first_name, String, :tag => "first-name"
23
- element :last_name, String, :tag => "last-name"
24
- element :phone_office, String, :tag => "phone-office"
25
- element :phone_mobile, String, :tag => "phone-mobile"
26
- element :fax, String
27
- element :title, String
19
+ property :id
20
+ property :client_id
21
+ property :email
22
+ property :first_name
23
+ property :last_name
24
+ property :phone_office
25
+ property :phone_mobile
26
+ property :fax
27
+ property :title
28
+ property :created_at
29
+ property :updated_at
28
30
  end
29
31
  end
@@ -3,14 +3,16 @@ module Harvest
3
3
 
4
4
  class HTTPError < StandardError
5
5
  attr_reader :response
6
- def initialize(response)
6
+ attr_reader :params
7
+
8
+ def initialize(response, params = {})
7
9
  @response = response
8
- super
10
+ @params = params
11
+ super(response)
9
12
  end
10
13
 
11
14
  def to_s
12
- hint = response.headers["hint"].nil? ? nil : response.headers["hint"].first
13
- "#{self.class.to_s} : #{response.code}#{" - #{hint}" if hint}"
15
+ "#{self.class.to_s} : #{response.code} #{response.body}"
14
16
  end
15
17
  end
16
18
 
@@ -1,19 +1,45 @@
1
1
  module Harvest
2
- class Expense < BaseModel
3
- include HappyMapper
4
-
2
+ class Expense < Hashie::Dash
3
+ include Harvest::Model
4
+
5
5
  api_path '/expenses'
6
-
7
- element :id, Integer
8
- element :notes, String
9
- element :units, Integer
10
- element :total_cost, Float, :tag => 'total-cost'
11
- element :project_id, Integer, :tag => 'project-id'
12
- element :expense_category_id, Integer, :tag => 'expense-category-id'
13
- element :spent_at, Time, :tag => 'spent-at'
14
-
6
+
7
+ property :id
8
+ property :notes
9
+ property :units
10
+ property :total_cost
11
+ property :project_id
12
+ property :expense_category_id
13
+ property :user_id
14
+ property :spent_at
15
+ property :created_at
16
+ property :updated_at
17
+ property :is_billed
18
+ property :is_closed
19
+ property :invoice_id
20
+ property :has_receipt
21
+ property :receipt_url
22
+
23
+ def initialize(args = {})
24
+ args = args.stringify_keys
25
+ self.spent_at = args.delete("spent_at") if args["spent_at"]
26
+ super
27
+ end
28
+
15
29
  def spent_at=(date)
16
- @spent_at = (String === date ? Time.parse(date) : date)
30
+ self["spent_at"] = (String === date ? Time.parse(date) : date)
31
+ end
32
+
33
+ def as_json(args = {})
34
+ super(args).stringify_keys.tap do |hash|
35
+ hash[json_root].update("spent_at" => (spent_at.nil? ? nil : spent_at.to_time.xmlschema))
36
+ hash[json_root].delete("has_receipt")
37
+ hash[json_root].delete("receipt_url")
38
+ end
17
39
  end
40
+
41
+ alias_method :billed?, :is_billed
42
+ alias_method :closed?, :is_closed
43
+ alias_method :has_receipt?, :has_receipt
18
44
  end
19
- end
45
+ end
@@ -1,15 +1,16 @@
1
1
  module Harvest
2
- class ExpenseCategory < BaseModel
3
- include HappyMapper
4
-
5
- tag 'expense-category'
2
+ class ExpenseCategory < Hashie::Dash
3
+ include Harvest::Model
6
4
  api_path '/expense_categories'
7
5
 
8
- element :id, Integer
9
- element :name, String
10
- element :unit_name, String, :tag => 'unit-name'
11
- element :unit_price, Float, :tag => 'unit-price'
12
- element :deactivated, Boolean
6
+ property :id
7
+ property :name
8
+ property :unit_name
9
+ property :unit_price
10
+ property :deactivated
11
+ property :created_at
12
+ property :updated_at
13
+ property :cache_version
13
14
 
14
15
  def active?
15
16
  !deactivated
@@ -59,7 +59,7 @@ module Harvest
59
59
  yield
60
60
  rescue Harvest::RateLimited => e
61
61
  seconds = if e.response.headers["retry-after"]
62
- e.response.headers["retry-after"].first.to_i
62
+ e.response.headers["retry-after"].to_i
63
63
  else
64
64
  16
65
65
  end
@@ -0,0 +1,103 @@
1
+ module Harvest
2
+ class Invoice < Hashie::Dash
3
+ include Harvest::Model
4
+
5
+ api_path '/invoices'
6
+
7
+ attr_reader :line_items
8
+
9
+ property :id
10
+ property :subject
11
+ property :number
12
+ property :created_at
13
+ property :updated_at
14
+ property :issued_at
15
+ property :due_at
16
+ property :due_at_human_format
17
+ property :due_amount
18
+ property :notes
19
+ property :recurring_invoice_id
20
+ property :period_start
21
+ property :period_end
22
+ property :discount
23
+ property :discount_amount
24
+ property :client_key
25
+ property :amount
26
+ property :tax
27
+ property :tax2
28
+ property :tax_amount
29
+ property :tax2_amount
30
+ property :csv_line_items
31
+ property :client_id
32
+ property :estimate_id
33
+ property :purchase_order
34
+ property :retainer_id
35
+ property :currency
36
+ property :state
37
+ property :kind
38
+ property :import_hours
39
+ property :import_expenses
40
+
41
+ def self.json_root; "doc"; end
42
+ # skip_json_root true
43
+
44
+ def initialize(args = {})
45
+ @line_items = []
46
+ args = args.stringify_keys
47
+ self.line_items = args.delete("csv_line_items")
48
+ self.line_items = args.delete("line_items")
49
+ super
50
+ end
51
+
52
+ def line_items=(raw_or_rich)
53
+ unless raw_or_rich.nil?
54
+ @line_items = case raw_or_rich
55
+ when String
56
+ @line_items = decode_csv(raw_or_rich).map {|row| Harvest::LineItem.new(row) }
57
+ else
58
+ raw_or_rich
59
+ end
60
+ end
61
+ end
62
+
63
+ def as_json(*options)
64
+ json = super(*options)
65
+ json[json_root]["csv_line_items"] = encode_csv(@line_items)
66
+ json
67
+ end
68
+
69
+ private
70
+ def decode_csv(string)
71
+ csv = CSV.parse(string)
72
+ headers = csv.shift
73
+ csv.map! {|row| headers.zip(row) }
74
+ csv.map {|row| row.inject({}) {|h, tuple| h.update(tuple[0] => tuple[1]) } }
75
+ end
76
+
77
+ def encode_csv(line_items)
78
+ if line_items.empty?
79
+ ""
80
+ else
81
+ header = %w(kind description quantity unit_price amount taxed taxed2 project_id)
82
+
83
+ # writing this in stdlib so we don't force 1.8 users to install FasterCSV and make gem dependencies wierd
84
+ if RUBY_VERSION =~ /1.8/
85
+ csv_data = ""
86
+ CSV.generate_row(header, header.size, csv_data)
87
+ line_items.each do |item|
88
+ row_data = header.inject([]) {|row, attr| row << item[attr] }
89
+ CSV.generate_row(row_data, row_data.size, csv_data)
90
+ end
91
+ csv_data
92
+ else
93
+ CSV.generate do |csv|
94
+ csv << header
95
+ line_items.each do |item|
96
+ csv << header.inject([]) {|row, attr| row << item[attr] }
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,18 @@
1
+ module Harvest
2
+ class InvoiceCategory < Hashie::Dash
3
+ include Harvest::Model
4
+
5
+ api_path '/invoice_item_categories'
6
+ def self.json_root; "category"; end
7
+
8
+ property :id
9
+ property :name
10
+ property :created_at
11
+ property :updated_at
12
+ property :use_as_expense
13
+ property :use_as_service
14
+
15
+ alias_method :use_as_expense?, :use_as_expense
16
+ alias_method :use_as_service?, :use_as_service
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ module Harvest
2
+ class LineItem < Hashie::Dash
3
+ property :kind
4
+ property :description
5
+ property :quantity
6
+ property :unit_price
7
+ property :amount
8
+ property :taxed
9
+ property :taxed2
10
+ property :project_id
11
+ end
12
+ end
@@ -0,0 +1,120 @@
1
+ module Harvest
2
+ module Model
3
+ def self.included(base)
4
+ base.send :include, InstanceMethods
5
+ base.send :extend, ClassMethods
6
+ end
7
+
8
+ module InstanceMethods
9
+ def to_json(*args)
10
+ as_json(*args).to_json(*args)
11
+ end
12
+
13
+ def as_json(args = {})
14
+ inner_json = self.to_hash.stringify_keys
15
+ inner_json.delete("cache_version")
16
+ if self.class.skip_json_root?
17
+ inner_json
18
+ else
19
+ { self.class.json_root => inner_json }
20
+ end
21
+ end
22
+
23
+ def to_i; id; end
24
+
25
+ def ==(other)
26
+ id == other.id
27
+ end
28
+
29
+ def impersonated_user_id
30
+ if respond_to?(:of_user) && respond_to?(:user_id)
31
+ of_user || user_id
32
+ elsif !respond_to?(:of_user) && respond_to?(:user_id)
33
+ user_id
34
+ elsif respond_to?(:of_user)
35
+ of_user
36
+ end
37
+ end
38
+
39
+ def json_root
40
+ self.class.json_root
41
+ end
42
+ end
43
+
44
+ module ClassMethods
45
+ # This sets the API path so the API collections can use them in an agnostic way
46
+ # @return [void]
47
+ def api_path(path = nil)
48
+ @_api_path ||= path
49
+ end
50
+
51
+ def skip_json_root(skip = nil)
52
+ @_skip_json_root ||= skip
53
+ end
54
+
55
+ def skip_json_root?
56
+ @_skip_json_root == true
57
+ end
58
+
59
+ def parse(json)
60
+ parsed = String === json ? JSON.parse(json) : json
61
+ Array.wrap(parsed).map {|attrs| skip_json_root? ? new(attrs) : new(attrs[json_root])}
62
+ end
63
+
64
+ def json_root
65
+ Harvest::Model::Utility.underscore(
66
+ Harvest::Model::Utility.demodulize(to_s)
67
+ )
68
+ end
69
+
70
+ def wrap(model_or_attrs)
71
+ case model_or_attrs
72
+ when Hashie::Dash
73
+ model_or_attrs
74
+ when Hash
75
+ new(model_or_attrs)
76
+ else
77
+ model_or_attrs
78
+ end
79
+ end
80
+ end
81
+
82
+ module Utility
83
+ class << self
84
+
85
+ # Both methods are shamelessly ripped from https://github.com/rails/rails/blob/master/activesupport/lib/active_support/inflector/inflections.rb
86
+
87
+ # Removes the module part from the expression in the string.
88
+ #
89
+ # Examples:
90
+ # "ActiveRecord::CoreExtensions::String::Inflections".demodulize # => "Inflections"
91
+ # "Inflections".demodulize
92
+ def demodulize(class_name_in_module)
93
+ class_name_in_module.to_s.gsub(/^.*::/, '')
94
+ end
95
+
96
+ # Makes an underscored, lowercase form from the expression in the string.
97
+ #
98
+ # Changes '::' to '/' to convert namespaces to paths.
99
+ #
100
+ # Examples:
101
+ # "ActiveRecord".underscore # => "active_record"
102
+ # "ActiveRecord::Errors".underscore # => active_record/errors
103
+ #
104
+ # As a rule of thumb you can think of +underscore+ as the inverse of +camelize+,
105
+ # though there are cases where that does not hold:
106
+ #
107
+ # "SSLError".underscore.camelize # => "SslError"
108
+ def underscore(camel_cased_word)
109
+ word = camel_cased_word.to_s.dup
110
+ word.gsub!(/::/, '/')
111
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
112
+ word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
113
+ word.tr!("-", "_")
114
+ word.downcase!
115
+ word
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end