forecasted 0.0.1
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.
- 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
|