forecasted 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.document +3 -0
- data/.gitignore +39 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/.travis.yml +16 -0
- data/Gemfile +19 -0
- data/HISTORY.md +19 -0
- data/MIT-LICENSE +20 -0
- data/README.md +109 -0
- data/Rakefile +22 -0
- data/examples/basics.rb +35 -0
- data/examples/clear_account.rb +28 -0
- data/examples/project_create_script.rb +93 -0
- data/examples/task_assignments.rb +27 -0
- data/examples/user_assignments.rb +24 -0
- data/forecasted.gemspec +25 -0
- data/lib/ext/array.rb +52 -0
- data/lib/ext/date.rb +9 -0
- data/lib/ext/hash.rb +17 -0
- data/lib/ext/time.rb +5 -0
- data/lib/forecast/aggregate.rb +8 -0
- data/lib/forecast/api/account.rb +22 -0
- data/lib/forecast/api/aggregates.rb +20 -0
- data/lib/forecast/api/assignments.rb +63 -0
- data/lib/forecast/api/base.rb +68 -0
- data/lib/forecast/api/clients.rb +10 -0
- data/lib/forecast/api/expense_categories.rb +9 -0
- data/lib/forecast/api/expenses.rb +27 -0
- data/lib/forecast/api/invoice_categories.rb +26 -0
- data/lib/forecast/api/invoice_messages.rb +75 -0
- data/lib/forecast/api/invoice_payments.rb +31 -0
- data/lib/forecast/api/invoices.rb +35 -0
- data/lib/forecast/api/milestones.rb +21 -0
- data/lib/forecast/api/projects.rb +23 -0
- data/lib/forecast/api/reports.rb +53 -0
- data/lib/forecast/api/tasks.rb +36 -0
- data/lib/forecast/api/time.rb +48 -0
- data/lib/forecast/api/user_assignments.rb +34 -0
- data/lib/forecast/api/users.rb +21 -0
- data/lib/forecast/assignment.rb +7 -0
- data/lib/forecast/base.rb +111 -0
- data/lib/forecast/behavior/activatable.rb +31 -0
- data/lib/forecast/behavior/crud.rb +75 -0
- data/lib/forecast/client.rb +22 -0
- data/lib/forecast/credentials.rb +42 -0
- data/lib/forecast/errors.rb +26 -0
- data/lib/forecast/expense.rb +27 -0
- data/lib/forecast/expense_category.rb +10 -0
- data/lib/forecast/hardy_client.rb +80 -0
- data/lib/forecast/invoice.rb +107 -0
- data/lib/forecast/invoice_category.rb +9 -0
- data/lib/forecast/invoice_message.rb +8 -0
- data/lib/forecast/invoice_payment.rb +8 -0
- data/lib/forecast/line_item.rb +4 -0
- data/lib/forecast/model.rb +154 -0
- data/lib/forecast/project.rb +37 -0
- data/lib/forecast/rate_limit_status.rb +23 -0
- data/lib/forecast/task.rb +22 -0
- data/lib/forecast/time_entry.rb +25 -0
- data/lib/forecast/timezones.rb +130 -0
- data/lib/forecast/trackable_project.rb +38 -0
- data/lib/forecast/user_assignment.rb +30 -0
- data/lib/forecast/version.rb +3 -0
- data/lib/forecasted.rb +87 -0
- data/spec/factories.rb +17 -0
- data/spec/forecast/base_spec.rb +11 -0
- data/spec/functional/aggregates_spec.rb +64 -0
- data/spec/functional/assignments_spec.rb +131 -0
- data/spec/functional/errors_spec.rb +22 -0
- data/spec/functional/hardy_client_spec.rb +33 -0
- data/spec/functional/milestones_spec.rb +82 -0
- data/spec/functional/people_spec.rb +85 -0
- data/spec/functional/project_spec.rb +41 -0
- data/spec/spec_helper.rb +41 -0
- data/spec/support/forecast_credentials.example.yml +6 -0
- data/spec/support/forecasted_helpers.rb +66 -0
- data/spec/support/json_examples.rb +9 -0
- metadata +189 -0
@@ -0,0 +1,154 @@
|
|
1
|
+
module Forecast
|
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
|
+
other.kind_of?(self.class) && 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
|
+
#
|
61
|
+
# old gem
|
62
|
+
# parsed = String === json ? JSON.parse(json) : json
|
63
|
+
# Array.wrap(parsed).map {|attrs| skip_json_root? ? new(attrs) : new(attrs[json_root])}
|
64
|
+
#
|
65
|
+
|
66
|
+
# parses json into a ruby hash
|
67
|
+
# {'projects' => [{id: 123}, {id: 456}]}
|
68
|
+
parsed = String === json ? JSON.parse(json) : json
|
69
|
+
|
70
|
+
|
71
|
+
# @todo @weston
|
72
|
+
# total hack
|
73
|
+
if parsed.keys == [json_root]
|
74
|
+
# single object
|
75
|
+
# before # {"project"=>{"id"=>123"}}
|
76
|
+
# after # {"id"=>123}
|
77
|
+
object_hash = parsed[json_root]
|
78
|
+
new(object_hash)
|
79
|
+
else
|
80
|
+
# list of objects
|
81
|
+
# before # {'projects' => [{id: 123}, {id: 456}]}
|
82
|
+
# after # [{id: 123}, {id: 456}]
|
83
|
+
object_hashes = parsed.to_a[0][1]
|
84
|
+
return object_hashes.map {|attrs| new(attrs)}
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def json_root
|
89
|
+
Forecast::Model::Utility.underscore(
|
90
|
+
Forecast::Model::Utility.demodulize(to_s))
|
91
|
+
end
|
92
|
+
|
93
|
+
def wrap(model_or_attrs)
|
94
|
+
case model_or_attrs
|
95
|
+
when Hashie::Mash
|
96
|
+
model_or_attrs
|
97
|
+
when Hash
|
98
|
+
new(model_or_attrs)
|
99
|
+
else
|
100
|
+
model_or_attrs
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def delegate_methods(options)
|
105
|
+
raise "no methods given" if options.empty?
|
106
|
+
options.each do |source, dest|
|
107
|
+
class_eval <<-EOV
|
108
|
+
def #{source}
|
109
|
+
#{dest}
|
110
|
+
end
|
111
|
+
EOV
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
module Utility
|
117
|
+
class << self
|
118
|
+
|
119
|
+
# Both methods are shamelessly ripped from https://github.com/rails/rails/blob/master/activesupport/lib/active_support/inflector/inflections.rb
|
120
|
+
|
121
|
+
# Removes the module part from the expression in the string.
|
122
|
+
#
|
123
|
+
# Examples:
|
124
|
+
# "ActiveRecord::CoreExtensions::String::Inflections".demodulize # => "Inflections"
|
125
|
+
# "Inflections".demodulize
|
126
|
+
def demodulize(class_name_in_module)
|
127
|
+
class_name_in_module.to_s.gsub(/^.*::/, '')
|
128
|
+
end
|
129
|
+
|
130
|
+
# Makes an underscored, lowercase form from the expression in the string.
|
131
|
+
#
|
132
|
+
# Changes '::' to '/' to convert namespaces to paths.
|
133
|
+
#
|
134
|
+
# Examples:
|
135
|
+
# "ActiveRecord".underscore # => "active_record"
|
136
|
+
# "ActiveRecord::Errors".underscore # => active_record/errors
|
137
|
+
#
|
138
|
+
# As a rule of thumb you can think of +underscore+ as the inverse of +camelize+,
|
139
|
+
# though there are cases where that does not hold:
|
140
|
+
#
|
141
|
+
# "SSLError".underscore.camelize # => "SslError"
|
142
|
+
def underscore(camel_cased_word)
|
143
|
+
word = camel_cased_word.to_s.dup
|
144
|
+
word.gsub!(/::/, '/')
|
145
|
+
word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
|
146
|
+
word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
|
147
|
+
word.tr!("-", "_")
|
148
|
+
word.downcase!
|
149
|
+
word
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Forecast
|
2
|
+
|
3
|
+
# The model that contains information about a project
|
4
|
+
#
|
5
|
+
# == Fields
|
6
|
+
# [+id+] (READONLY) the id of the project
|
7
|
+
# [+name+] (REQUIRED) the name of the project
|
8
|
+
# [+client_id+] (REQUIRED) the client id of the project
|
9
|
+
# [+code+] the project code
|
10
|
+
# [+notes+] the project notes
|
11
|
+
# [+active?+] true|false whether the project is active
|
12
|
+
# [+billable?+] true|false where the project is billable
|
13
|
+
# [+budget_by+] how the budget is calculated for the project +project|project_cost|task|person|nil+
|
14
|
+
# [+budget+] what the budget is for the project (based on budget_by)
|
15
|
+
# [+bill_by+] how to bill the project +Tasks|People|Project|nil+
|
16
|
+
# [+hourly_rate+] what the hourly rate for the project is based on +bill_by+
|
17
|
+
# [+notify_when_over_budget?+] whether the project will send notifications when it goes over budget
|
18
|
+
# [+over_budget_notification_percentage+] what percentage of the budget the project has to be before it sends a notification. Based on +notify_when_over_budget?+
|
19
|
+
# [+show_budget_to_all?+] whether the project's budget is shown to employees and contractors
|
20
|
+
# [+basecamp_id+] (READONLY) the id of the basecamp project associated to the project
|
21
|
+
# [+highrise_deal_id+] (READONLY) the id of the highrise deal associated to the project
|
22
|
+
# [+active_task_assignments_count+] (READONLY) the number of active task assignments
|
23
|
+
# [+created_at+] (READONLY) when the project was created
|
24
|
+
# [+updated_at+] (READONLY) when the project was updated
|
25
|
+
class Project < Hashie::Mash
|
26
|
+
include Forecast::Model
|
27
|
+
|
28
|
+
api_path '/projects'
|
29
|
+
|
30
|
+
def as_json(args = {})
|
31
|
+
super(args).tap do |json|
|
32
|
+
# json[json_root].delete("hint_earliest_record_at")
|
33
|
+
# json[json_root].delete("hint_latest_record_at")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# module Harvest
|
2
|
+
|
3
|
+
# # The model that contains the information about the user's rate limit
|
4
|
+
# #
|
5
|
+
# # == Fields
|
6
|
+
# # [+last_access_at+] The last registered request
|
7
|
+
# # [+count+] The current number of requests registered
|
8
|
+
# # [+timeframe_limit+] The amount of seconds before a rate limit refresh occurs
|
9
|
+
# # [+max_calls+] The number of requests you can make within the +timeframe_limit+
|
10
|
+
# # [+lockout_seconds+] If you exceed the rate limit, how long you will be locked out from Harvest
|
11
|
+
# class RateLimitStatus < Hashie::Mash
|
12
|
+
# include Harvest::Model
|
13
|
+
|
14
|
+
# skip_json_root true
|
15
|
+
|
16
|
+
# # Returns true if the user is over their rate limit
|
17
|
+
# # @return [Boolean]
|
18
|
+
# # @see http://www.getharvest.com/api
|
19
|
+
# def over_limit?
|
20
|
+
# count > max_calls
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
# end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# module Harvest
|
2
|
+
|
3
|
+
# # The model that contains information about a task
|
4
|
+
# #
|
5
|
+
# # == Fields
|
6
|
+
# # [+id+] (READONLY) the id of the task
|
7
|
+
# # [+name+] (REQUIRED) the name of the task
|
8
|
+
# # [+billable+] whether the task is billable by default
|
9
|
+
# # [+deactivated+] whether the task is deactivated
|
10
|
+
# # [+hourly_rate+] what the default hourly rate for the task is
|
11
|
+
# # [+default?+] whether to add this task to new projects by default
|
12
|
+
# class Task < Hashie::Mash
|
13
|
+
# include Harvest::Model
|
14
|
+
|
15
|
+
# api_path '/tasks'
|
16
|
+
# delegate_methods :default? => :is_default
|
17
|
+
|
18
|
+
# def active?
|
19
|
+
# !deactivated
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
# end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# module Harvest
|
2
|
+
# class TimeEntry < Hashie::Mash
|
3
|
+
# include Harvest::Model
|
4
|
+
|
5
|
+
# skip_json_root true
|
6
|
+
# delegate_methods(:closed? => :is_closed,
|
7
|
+
# :billed? => :is_billed)
|
8
|
+
|
9
|
+
# def initialize(args = {}, _ = nil)
|
10
|
+
# args = args.to_hash.stringify_keys
|
11
|
+
# self.spent_at = args.delete("spent_at") if args["spent_at"]
|
12
|
+
# super
|
13
|
+
# end
|
14
|
+
|
15
|
+
# def spent_at=(date)
|
16
|
+
# self["spent_at"] = Date.parse(date.to_s)
|
17
|
+
# end
|
18
|
+
|
19
|
+
# def as_json(args = {})
|
20
|
+
# super(args).to_hash.stringify_keys.tap do |hash|
|
21
|
+
# hash.update("spent_at" => (spent_at.nil? ? nil : spent_at.xmlschema))
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
# end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
module Forecast
|
2
|
+
# shamelessly ripped from Rails: http://github.com/rails/rails/blob/master/activesupport/lib/active_support/values/time_zone.rb
|
3
|
+
module Timezones
|
4
|
+
MAPPING = {
|
5
|
+
"pacific/midway" => "Midway Island",
|
6
|
+
"pacific/pago_pago" => "Samoa",
|
7
|
+
"pacific/honolulu" => "Hawaii",
|
8
|
+
"america/juneau" => "Alaska",
|
9
|
+
"america/los_angeles" => "Pacific Time (US & Canada)",
|
10
|
+
"america/tijuana" => "Tijuana",
|
11
|
+
"america/denver" => "Mountain Time (US & Canada)",
|
12
|
+
"america/phoenix" => "Arizona",
|
13
|
+
"america/chihuahua" => "Chihuahua",
|
14
|
+
"america/mazatlan" => "Mazatlan",
|
15
|
+
"america/chicago" => "Central Time (US & Canada)",
|
16
|
+
"america/regina" => "Saskatchewan",
|
17
|
+
"america/mexico_city" => "Mexico City",
|
18
|
+
"america/monterrey" => "Monterrey",
|
19
|
+
"america/guatemala" => "Central America",
|
20
|
+
"america/new_york" => "Eastern Time (US & Canada)",
|
21
|
+
"america/indiana/indianapolis" => "Indiana (East)",
|
22
|
+
"america/bogota" => "Bogota",
|
23
|
+
"america/lima" => "Lima",
|
24
|
+
"america/halifax" => "Atlantic Time (Canada)",
|
25
|
+
"america/caracas" => "Caracas",
|
26
|
+
"america/la_paz" => "La Paz",
|
27
|
+
"america/santiago" => "Santiago",
|
28
|
+
"america/st_johns" => "Newfoundland",
|
29
|
+
"america/sao_paulo" => "Brasilia",
|
30
|
+
"america/argentina/buenos_aires" => "Buenos Aires",
|
31
|
+
"america/argentina/san_juan" => "Georgetown",
|
32
|
+
"america/godthab" => "Greenland",
|
33
|
+
"atlantic/south_georgia" => "Mid-Atlantic",
|
34
|
+
"atlantic/azores" => "Azores",
|
35
|
+
"atlantic/cape_verde" => "Cape Verde Is.",
|
36
|
+
"europe/dublin" => "Dublin",
|
37
|
+
"europe/lisbon" => "Lisbon",
|
38
|
+
"europe/london" => "London",
|
39
|
+
"africa/casablanca" => "Casablanca",
|
40
|
+
"africa/monrovia" => "Monrovia",
|
41
|
+
"etc/utc" => "UTC",
|
42
|
+
"europe/belgrade" => "Belgrade",
|
43
|
+
"europe/bratislava" => "Bratislava",
|
44
|
+
"europe/budapest" => "Budapest",
|
45
|
+
"europe/ljubljana" => "Ljubljana",
|
46
|
+
"europe/prague" => "Prague",
|
47
|
+
"europe/sarajevo" => "Sarajevo",
|
48
|
+
"europe/skopje" => "Skopje",
|
49
|
+
"europe/warsaw" => "Warsaw",
|
50
|
+
"europe/zagreb" => "Zagreb",
|
51
|
+
"europe/brussels" => "Brussels",
|
52
|
+
"europe/copenhagen" => "Copenhagen",
|
53
|
+
"europe/madrid" => "Madrid",
|
54
|
+
"europe/paris" => "Paris",
|
55
|
+
"europe/amsterdam" => "Amsterdam",
|
56
|
+
"europe/berlin" => "Berlin",
|
57
|
+
"europe/rome" => "Rome",
|
58
|
+
"europe/stockholm" => "Stockholm",
|
59
|
+
"europe/vienna" => "Vienna",
|
60
|
+
"africa/algiers" => "West Central Africa",
|
61
|
+
"europe/bucharest" => "Bucharest",
|
62
|
+
"africa/cairo" => "Cairo",
|
63
|
+
"europe/helsinki" => "Helsinki",
|
64
|
+
"europe/kiev" => "Kyev",
|
65
|
+
"europe/riga" => "Riga",
|
66
|
+
"europe/sofia" => "Sofia",
|
67
|
+
"europe/tallinn" => "Tallinn",
|
68
|
+
"europe/vilnius" => "Vilnius",
|
69
|
+
"europe/athens" => "Athens",
|
70
|
+
"europe/istanbul" => "Istanbul",
|
71
|
+
"europe/minsk" => "Minsk",
|
72
|
+
"asia/jerusalem" => "Jerusalem",
|
73
|
+
"africa/harare" => "Harare",
|
74
|
+
"africa/johannesburg" => "Pretoria",
|
75
|
+
"europe/moscow" => "Moscow",
|
76
|
+
"asia/kuwait" => "Kuwait",
|
77
|
+
"asia/riyadh" => "Riyadh",
|
78
|
+
"africa/nairobi" => "Nairobi",
|
79
|
+
"asia/baghdad" => "Baghdad",
|
80
|
+
"asia/tehran" => "Tehran",
|
81
|
+
"asia/muscat" => "Muscat",
|
82
|
+
"asia/baku" => "Baku",
|
83
|
+
"asia/tbilisi" => "Tbilisi",
|
84
|
+
"asia/yerevan" => "Yerevan",
|
85
|
+
"asia/kabul" => "Kabul",
|
86
|
+
"asia/yekaterinburg" => "Ekaterinburg",
|
87
|
+
"asia/karachi" => "Karachi",
|
88
|
+
"asia/tashkent" => "Tashkent",
|
89
|
+
"asia/kolkata" => "Kolkata",
|
90
|
+
"asia/katmandu" => "Kathmandu",
|
91
|
+
"asia/dhaka" => "Dhaka",
|
92
|
+
"asia/colombo" => "Sri Jayawardenepura",
|
93
|
+
"asia/almaty" => "Almaty",
|
94
|
+
"asia/novosibirsk" => "Novosibirsk",
|
95
|
+
"asia/rangoon" => "Rangoon",
|
96
|
+
"asia/bangkok" => "Bangkok",
|
97
|
+
"asia/jakarta" => "Jakarta",
|
98
|
+
"asia/krasnoyarsk" => "Krasnoyarsk",
|
99
|
+
"asia/shanghai" => "Beijing",
|
100
|
+
"asia/chongqing" => "Chongqing",
|
101
|
+
"asia/hong_kong" => "Hong Kong",
|
102
|
+
"asia/urumqi" => "Urumqi",
|
103
|
+
"asia/kuala_lumpur" => "Kuala Lumpur",
|
104
|
+
"asia/singapore" => "Singapore",
|
105
|
+
"asia/taipei" => "Taipei",
|
106
|
+
"australia/perth" => "Perth",
|
107
|
+
"asia/irkutsk" => "Irkutsk",
|
108
|
+
"asia/ulaanbaatar" => "Ulaan Bataar",
|
109
|
+
"asia/seoul" => "Seoul",
|
110
|
+
"asia/tokyo" => "Tokyo",
|
111
|
+
"asia/yakutsk" => "Yakutsk",
|
112
|
+
"australia/darwin" => "Darwin",
|
113
|
+
"australia/adelaide" => "Adelaide",
|
114
|
+
"australia/melbourne" => "Melbourne",
|
115
|
+
"australia/sydney" => "Sydney",
|
116
|
+
"australia/brisbane" => "Brisbane",
|
117
|
+
"australia/hobart" => "Hobart",
|
118
|
+
"asia/vladivostok" => "Vladivostok",
|
119
|
+
"pacific/guam" => "Guam",
|
120
|
+
"pacific/port_moresby" => "Port Moresby",
|
121
|
+
"asia/magadan" => "Magadan",
|
122
|
+
"pacific/noumea" => "New Caledonia",
|
123
|
+
"pacific/fiji" => "Fiji",
|
124
|
+
"asia/kamchatka" => "Kamchatka",
|
125
|
+
"pacific/majuro" => "Marshall Is.",
|
126
|
+
"pacific/auckland" => "Auckland",
|
127
|
+
"pacific/tongatapu" => "Nuku'alofa"
|
128
|
+
}
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# module Harvest
|
2
|
+
|
3
|
+
# # The model for project-tasks combinations that can be added to the timesheet
|
4
|
+
# #
|
5
|
+
# # == Fields
|
6
|
+
# # [+id+] the id of the project
|
7
|
+
# # [+name+] the name of the project
|
8
|
+
# # [+client+] the name of the client of the project
|
9
|
+
# # [+client_id+] the client id of the project
|
10
|
+
# # [+tasks+] trackable tasks for the project
|
11
|
+
# class TrackableProject < Hashie::Mash
|
12
|
+
# include Harvest::Model
|
13
|
+
|
14
|
+
# skip_json_root true
|
15
|
+
|
16
|
+
# def initialize(args = {}, _ = nil)
|
17
|
+
# args = args.to_hash.stringify_keys
|
18
|
+
# self.tasks = args.delete("tasks") if args["tasks"]
|
19
|
+
# super
|
20
|
+
# end
|
21
|
+
|
22
|
+
# def tasks=(tasks)
|
23
|
+
# self["tasks"] = Task.parse(tasks)
|
24
|
+
# end
|
25
|
+
|
26
|
+
# # The model for trackable tasks
|
27
|
+
# #
|
28
|
+
# # == Fields
|
29
|
+
# # [+id+] the id of the task
|
30
|
+
# # [+name+] the name of the task
|
31
|
+
# # [+billable+] whether the task is billable by default
|
32
|
+
# class Task < Hashie::Mash
|
33
|
+
# include Harvest::Model
|
34
|
+
|
35
|
+
# skip_json_root true
|
36
|
+
# end
|
37
|
+
# end
|
38
|
+
# end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# module Harvest
|
2
|
+
# class UserAssignment < Hashie::Mash
|
3
|
+
# include Harvest::Model
|
4
|
+
|
5
|
+
# delegate_methods :project_manager? => :is_project_manager
|
6
|
+
|
7
|
+
# def initialize(args = {}, _ = nil)
|
8
|
+
# args = args.to_hash.stringify_keys
|
9
|
+
# self.user = args.delete("user") if args["user"]
|
10
|
+
# self.project = args.delete("project") if args["project"]
|
11
|
+
# super
|
12
|
+
# end
|
13
|
+
|
14
|
+
# def user=(user)
|
15
|
+
# self["user_id"] = user.to_i
|
16
|
+
# end
|
17
|
+
|
18
|
+
# def project=(project)
|
19
|
+
# self["project_id"] = project.to_i
|
20
|
+
# end
|
21
|
+
|
22
|
+
# def active?
|
23
|
+
# !deactivated
|
24
|
+
# end
|
25
|
+
|
26
|
+
# def user_as_json
|
27
|
+
# {"user" => {"id" => user_id}}
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
# end
|
data/lib/forecasted.rb
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'httparty'
|
2
|
+
require 'base64'
|
3
|
+
require 'delegate'
|
4
|
+
require 'hashie'
|
5
|
+
require 'json'
|
6
|
+
require 'time'
|
7
|
+
require 'csv'
|
8
|
+
|
9
|
+
require 'ext/array'
|
10
|
+
require 'ext/hash'
|
11
|
+
require 'ext/date'
|
12
|
+
require 'ext/time'
|
13
|
+
|
14
|
+
require 'forecast/version'
|
15
|
+
require 'forecast/credentials'
|
16
|
+
require 'forecast/errors'
|
17
|
+
require 'forecast/hardy_client'
|
18
|
+
require 'forecast/timezones'
|
19
|
+
|
20
|
+
require 'forecast/base'
|
21
|
+
|
22
|
+
%w(crud activatable).each {|a| require "forecast/behavior/#{a}"}
|
23
|
+
|
24
|
+
%w(model project assignment aggregate).each {|a| require "forecast/#{a}"}
|
25
|
+
%w(base projects assignments aggregates).each {|a| require "forecast/api/#{a}"}
|
26
|
+
|
27
|
+
module Forecast
|
28
|
+
class << self
|
29
|
+
|
30
|
+
# Creates a standard client that will raise all errors it encounters
|
31
|
+
#
|
32
|
+
# == Options
|
33
|
+
# * Basic Authentication
|
34
|
+
# * +:subdomain+ - Your HarvestForecast subdomain
|
35
|
+
# * +:username+ - Your HarvestForecast username
|
36
|
+
# * +:password+ - Your HarvestForecast password
|
37
|
+
# * OAuth
|
38
|
+
# * +:access_token+ - An OAuth 2.0 access token
|
39
|
+
#
|
40
|
+
# == Examples
|
41
|
+
# Forecast.client(subdomain: 'mysubdomain', username: 'myusername', password: 'mypassword')
|
42
|
+
# Forecast.client(access_token: 'myaccesstoken')
|
43
|
+
#
|
44
|
+
# @return [Forecast::Base]
|
45
|
+
# def client(subdomain: nil, username: nil, password: nil, access_token: nil)
|
46
|
+
def client(ops={})
|
47
|
+
# Forecast::Base.new(subdomain: subdomain, username: username, password: password, access_token: access_token)
|
48
|
+
Forecast::Base.new(ops)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Creates a hardy client that will retry common HTTP errors it encounters and sleep() if it determines it is over your rate limit
|
52
|
+
#
|
53
|
+
# == Options
|
54
|
+
# * Basic Authentication
|
55
|
+
# * +:subdomain+ - Your HarvestForecast subdomain
|
56
|
+
# * +:username+ - Your HarvestForecast username
|
57
|
+
# * +:password+ - Your HarvestForecast password
|
58
|
+
# * OAuth
|
59
|
+
# * +:access_token+ - An OAuth 2.0 access token
|
60
|
+
#
|
61
|
+
# * +:retry+ - How many times the hardy client should retry errors. Set to +5+ by default.
|
62
|
+
#
|
63
|
+
# == Examples
|
64
|
+
# Forecast.hardy_client(subdomain: 'mysubdomain', username: 'myusername', password: 'mypassword', retry: 3)
|
65
|
+
#
|
66
|
+
# Forecast.hardy_client(access_token: 'myaccesstoken', retries: 3)
|
67
|
+
#
|
68
|
+
# == Errors
|
69
|
+
# The hardy client will retry the following errors
|
70
|
+
# * Forecast::Unavailable
|
71
|
+
# * Forecast::InformHarvest
|
72
|
+
# * Net::HTTPError
|
73
|
+
# * Net::HTTPFatalError
|
74
|
+
# * Errno::ECONNRESET
|
75
|
+
#
|
76
|
+
# == Rate Limits
|
77
|
+
# The hardy client will make as many requests as it can until it detects it has gone over the rate limit. Then it will +sleep()+ for the how ever long it takes for the limit to reset. You can find more information about the Rate Limiting at http://www.getforecast.com/api
|
78
|
+
#
|
79
|
+
# @return [Forecast::HardyClient] a Harvest::Base wrapped in a Harvest::HardyClient
|
80
|
+
# @see Forecast::Base
|
81
|
+
# def hardy_client(subdomain: nil, username: nil, password: nil, access_token: nil, retries: 5)
|
82
|
+
def hardy_client(ops={}, retries=5)
|
83
|
+
# Forecast::HardyClient.new(client(subdomain: subdomain, username: username, password: password, access_token: access_token), retries)
|
84
|
+
Forecast::HardyClient.new(client(ops), retries)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/spec/factories.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
FactoryGirl.define do
|
2
|
+
sequence :name do |n|
|
3
|
+
"Joe's Steam Cleaning #{n}"
|
4
|
+
end
|
5
|
+
|
6
|
+
sequence :description do |n|
|
7
|
+
"Item #{n}"
|
8
|
+
end
|
9
|
+
|
10
|
+
sequence :project_name do |n|
|
11
|
+
"Joe's Steam Cleaning Project #{n}"
|
12
|
+
end
|
13
|
+
|
14
|
+
factory :project, class: Forecast::Project do
|
15
|
+
name { generate(:project_name) }
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Forecast::Base do
|
4
|
+
describe "forecast_account_id/access_token errors" do
|
5
|
+
it "should raise error if missing a credential" do
|
6
|
+
expect {
|
7
|
+
Forecast::Base.new({forecast_account_id: 123})
|
8
|
+
}.to raise_error(/must provide/i)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'forecast aggregate' do
|
4
|
+
|
5
|
+
describe 'future_scheduled_hours_after' do
|
6
|
+
it 'works' do
|
7
|
+
cassette('aggregate-future_scheduled_hours_after') do
|
8
|
+
x = forecast.aggregates.future_scheduled_hours_after("2016-11-29")
|
9
|
+
|
10
|
+
expect(x.class).to be(Array)
|
11
|
+
expect(x.first.class).to be(Forecast::Aggregate)
|
12
|
+
expect(x.size > 1).to eq(true)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'query by project_id' do
|
17
|
+
cassette('aggregate-future_scheduled_hours-query-project_id') do
|
18
|
+
x = forecast.aggregates.future_scheduled_hours_after("2016-11-29", {project_id: mm_forecast_project_id})
|
19
|
+
|
20
|
+
expect(x.class).to be(Array)
|
21
|
+
expect(x.first.class).to be(Forecast::Aggregate)
|
22
|
+
|
23
|
+
expect_belong_to_one_project(x)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe 'remaining_budgeted_hours' do
|
29
|
+
it 'works' do
|
30
|
+
cassette('aggregate-remaining_budgeted_hours') do
|
31
|
+
x = forecast.aggregates.remaining_budgeted_hours
|
32
|
+
|
33
|
+
expect(x.class).to be(Array)
|
34
|
+
expect(x.first.class).to be(Forecast::Aggregate)
|
35
|
+
|
36
|
+
expect_belong_to_many_projects(x)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'query by (forecast) project_id' do
|
41
|
+
cassette('aggregate-remaining_budgeted_hours') do
|
42
|
+
x = forecast.aggregates.remaining_budgeted_hours({project_id: mm_forecast_project_id})
|
43
|
+
|
44
|
+
expect(x.class).to be(Array)
|
45
|
+
expect(x.first.class).to be(Forecast::Aggregate)
|
46
|
+
|
47
|
+
expect_belong_to_one_project(x)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def expect_belong_to_one_project(x)
|
53
|
+
project_ids = x.collect(&:project_id)
|
54
|
+
uniq_project_ids = project_ids.uniq
|
55
|
+
expect(uniq_project_ids.size).to eq(1)
|
56
|
+
end
|
57
|
+
|
58
|
+
def expect_belong_to_many_projects(x)
|
59
|
+
project_ids = x.collect(&:project_id)
|
60
|
+
uniq_project_ids = project_ids.uniq
|
61
|
+
expect(uniq_project_ids.size > 1).to eq(true)
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|