harvested 0.3.3 → 0.4.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.
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