quilted-harvested 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +3 -0
- data/.gitignore +25 -0
- data/HISTORY +19 -0
- data/MIT-LICENSE +20 -0
- data/README.md +68 -0
- data/Rakefile +25 -0
- data/VERSION +1 -0
- data/examples/basics.rb +35 -0
- data/examples/clear_account.rb +28 -0
- data/examples/task_assignments.rb +27 -0
- data/examples/user_assignments.rb +24 -0
- data/features/account.feature +7 -0
- data/features/client_contacts.feature +23 -0
- data/features/clients.feature +29 -0
- data/features/errors.feature +25 -0
- data/features/expense_categories.feature +21 -0
- data/features/expenses.feature +55 -0
- data/features/hardy_client.feature +40 -0
- data/features/projects.feature +39 -0
- data/features/reporting.feature +72 -0
- data/features/step_definitions/account_steps.rb +7 -0
- data/features/step_definitions/assignment_steps.rb +100 -0
- data/features/step_definitions/contact_steps.rb +11 -0
- data/features/step_definitions/debug_steps.rb +3 -0
- data/features/step_definitions/error_steps.rb +113 -0
- data/features/step_definitions/expenses_steps.rb +46 -0
- data/features/step_definitions/harvest_steps.rb +8 -0
- data/features/step_definitions/model_steps.rb +90 -0
- data/features/step_definitions/people_steps.rb +4 -0
- data/features/step_definitions/report_steps.rb +91 -0
- data/features/step_definitions/time_entry_steps.rb +40 -0
- data/features/support/env.rb +37 -0
- data/features/support/error_helpers.rb +18 -0
- data/features/support/fixtures/empty_clients.xml +2 -0
- data/features/support/fixtures/over_limit.xml +8 -0
- data/features/support/fixtures/receipt.png +0 -0
- data/features/support/fixtures/under_limit.xml +8 -0
- data/features/support/harvest_credentials.example.yml +4 -0
- data/features/support/harvest_helpers.rb +11 -0
- data/features/support/inflections.rb +9 -0
- data/features/task_assignment.feature +69 -0
- data/features/tasks.feature +25 -0
- data/features/time_tracking.feature +29 -0
- data/features/user_assignments.feature +33 -0
- data/features/users.feature +55 -0
- data/harvested.gemspec +159 -0
- data/lib/harvest/api/account.rb +15 -0
- data/lib/harvest/api/base.rb +42 -0
- data/lib/harvest/api/clients.rb +10 -0
- data/lib/harvest/api/contacts.rb +19 -0
- data/lib/harvest/api/expense_categories.rb +9 -0
- data/lib/harvest/api/expenses.rb +28 -0
- data/lib/harvest/api/projects.rb +54 -0
- data/lib/harvest/api/reports.rb +39 -0
- data/lib/harvest/api/task_assignments.rb +32 -0
- data/lib/harvest/api/tasks.rb +9 -0
- data/lib/harvest/api/time.rb +32 -0
- data/lib/harvest/api/user_assignments.rb +32 -0
- data/lib/harvest/api/users.rb +21 -0
- data/lib/harvest/base.rb +258 -0
- data/lib/harvest/base_model.rb +73 -0
- data/lib/harvest/behavior/activatable.rb +31 -0
- data/lib/harvest/behavior/crud.rb +57 -0
- data/lib/harvest/client.rb +30 -0
- data/lib/harvest/contact.rb +29 -0
- data/lib/harvest/credentials.rb +21 -0
- data/lib/harvest/errors.rb +23 -0
- data/lib/harvest/expense.rb +19 -0
- data/lib/harvest/expense_category.rb +18 -0
- data/lib/harvest/hardy_client.rb +80 -0
- data/lib/harvest/project.rb +56 -0
- data/lib/harvest/rate_limit_status.rb +28 -0
- data/lib/harvest/task.rb +30 -0
- data/lib/harvest/task_assignment.rb +34 -0
- data/lib/harvest/time_entry.rb +42 -0
- data/lib/harvest/timezones.rb +149 -0
- data/lib/harvest/user.rb +66 -0
- data/lib/harvest/user_assignment.rb +34 -0
- data/lib/harvested.rb +62 -0
- data/spec/harvest/base_spec.rb +9 -0
- data/spec/harvest/credentials_spec.rb +22 -0
- data/spec/harvest/expense_spec.rb +15 -0
- data/spec/harvest/task_assignment_spec.rb +10 -0
- data/spec/harvest/time_entry_spec.rb +22 -0
- data/spec/harvest/user_assignment_spec.rb +10 -0
- data/spec/harvest/user_spec.rb +32 -0
- data/spec/spec.default.opts +1 -0
- data/spec/spec_helper.rb +10 -0
- metadata +264 -0
@@ -0,0 +1,80 @@
|
|
1
|
+
module Harvest
|
2
|
+
class HardyClient < Delegator
|
3
|
+
def initialize(client, max_retries)
|
4
|
+
super(client)
|
5
|
+
@_sd_obj = @client = client
|
6
|
+
@max_retries = max_retries
|
7
|
+
(@client.public_methods - Object.public_instance_methods).each do |name|
|
8
|
+
instance_eval <<-END
|
9
|
+
def #{name}(*args)
|
10
|
+
wrap_collection do
|
11
|
+
@client.send('#{name}', *args)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
END
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def __getobj__; @_sd_obj; end
|
19
|
+
def __setobj__(obj); @_sd_obj = obj; end
|
20
|
+
|
21
|
+
def wrap_collection
|
22
|
+
collection = yield
|
23
|
+
HardyCollection.new(collection, self, @max_retries)
|
24
|
+
end
|
25
|
+
|
26
|
+
class HardyCollection < Delegator
|
27
|
+
def initialize(collection, client, max_retries)
|
28
|
+
super(collection)
|
29
|
+
@_sd_obj = @collection = collection
|
30
|
+
@client = client
|
31
|
+
@max_retries = max_retries
|
32
|
+
(@collection.public_methods - Object.public_instance_methods).each do |name|
|
33
|
+
instance_eval <<-END
|
34
|
+
def #{name}(*args)
|
35
|
+
retry_rate_limits do
|
36
|
+
@collection.send('#{name}', *args)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
END
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def __getobj__; @_sd_obj; end
|
44
|
+
def __setobj__(obj); @_sd_obj = obj; end
|
45
|
+
|
46
|
+
def retry_rate_limits
|
47
|
+
retries = 0
|
48
|
+
|
49
|
+
retry_func = lambda do |e|
|
50
|
+
if retries < @max_retries
|
51
|
+
retries += 1
|
52
|
+
true
|
53
|
+
else
|
54
|
+
raise e
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
begin
|
59
|
+
yield
|
60
|
+
rescue Harvest::RateLimited => e
|
61
|
+
seconds = if e.response.headers["retry-after"]
|
62
|
+
e.response.headers["retry-after"].first.to_i
|
63
|
+
else
|
64
|
+
16
|
65
|
+
end
|
66
|
+
sleep(seconds)
|
67
|
+
retry
|
68
|
+
rescue Harvest::Unavailable, Harvest::InformHarvest => e
|
69
|
+
would_retry = retry_func.call(e)
|
70
|
+
sleep(16) if @client.account.rate_limit_status.over_limit?
|
71
|
+
retry if would_retry
|
72
|
+
rescue Net::HTTPError, Net::HTTPFatalError => e
|
73
|
+
retry if retry_func.call(e)
|
74
|
+
rescue SystemCallError => e
|
75
|
+
retry if e.is_a?(Errno::ECONNRESET) && retry_func.call(e)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Harvest
|
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 < BaseModel
|
26
|
+
include HappyMapper
|
27
|
+
|
28
|
+
api_path '/projects'
|
29
|
+
|
30
|
+
element :id, Integer
|
31
|
+
element :client_id, Integer, :tag => 'client-id'
|
32
|
+
element :name, String
|
33
|
+
element :code, String
|
34
|
+
element :notes, String
|
35
|
+
element :fees, String
|
36
|
+
element :active, Boolean
|
37
|
+
element :billable, Boolean
|
38
|
+
element :budget, Float
|
39
|
+
element :budget_by, String, :tag => 'budget-by'
|
40
|
+
element :hourly_rate, Float, :tag => 'hourly-rate'
|
41
|
+
element :bill_by, String, :tag => 'bill-by'
|
42
|
+
element :created_at, Time, :tag => 'created-at'
|
43
|
+
element :updated_at, Time, :tag => 'updated-at'
|
44
|
+
element :notify_when_over_budget, Boolean, :tag => 'notify-when-over-budget'
|
45
|
+
element :over_budget_notification_percentage, Float, :tag => 'over-budget-notification-percentage'
|
46
|
+
element :show_budget_to_all, Boolean, :tag => 'show-budget-to-all'
|
47
|
+
element :basecamp_id, Integer, :tag => 'basecamp-id'
|
48
|
+
element :highrise_deal_id, Integer, :tag => 'highrise-deal-id'
|
49
|
+
element :active_task_assignments_count, Integer, :tag => 'active-task-assignments-count'
|
50
|
+
|
51
|
+
alias_method :active?, :active
|
52
|
+
alias_method :billable?, :billable
|
53
|
+
alias_method :notify_when_over_budget?, :notify_when_over_budget
|
54
|
+
alias_method :show_budget_to_all?, :show_budget_to_all
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,28 @@
|
|
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 < BaseModel
|
12
|
+
include HappyMapper
|
13
|
+
|
14
|
+
tag 'hash'
|
15
|
+
element :last_access_at, Time, :tag => 'last-access-at'
|
16
|
+
element :count, Integer
|
17
|
+
element :timeframe_limit, Integer, :tag => 'timeframe-limit'
|
18
|
+
element :max_calls, Integer, :tag => 'max-calls'
|
19
|
+
element :lockout_seconds, Integer, :tag => 'lockout-seconds'
|
20
|
+
|
21
|
+
# Returns true if the user is over their rate limit
|
22
|
+
# @return [Boolean]
|
23
|
+
# @see http://www.getharvest.com/api
|
24
|
+
def over_limit?
|
25
|
+
count > max_calls
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/harvest/task.rb
ADDED
@@ -0,0 +1,30 @@
|
|
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 < BaseModel
|
13
|
+
include HappyMapper
|
14
|
+
|
15
|
+
api_path '/tasks'
|
16
|
+
|
17
|
+
element :id, Integer
|
18
|
+
element :name, String
|
19
|
+
element :billable, Boolean, :tag => 'billable-by-default'
|
20
|
+
element :deactivated, Boolean, :tag => 'deactivated'
|
21
|
+
element :hourly_rate, Float, :tag => 'default-hourly-rate'
|
22
|
+
element :default, Boolean, :tag => 'is-default'
|
23
|
+
|
24
|
+
def active?
|
25
|
+
!deactivated
|
26
|
+
end
|
27
|
+
|
28
|
+
alias_method :default?, :default
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Harvest
|
2
|
+
class TaskAssignment < BaseModel
|
3
|
+
include HappyMapper
|
4
|
+
|
5
|
+
tag 'task-assignment'
|
6
|
+
element :id, Integer
|
7
|
+
element :task_id, Integer, :tag => 'task-id'
|
8
|
+
element :project_id, Integer, :tag => 'project-id'
|
9
|
+
element :billable, Boolean
|
10
|
+
element :deactivated, Boolean
|
11
|
+
element :hourly_rate, Float, :tag => 'hourly-rate'
|
12
|
+
|
13
|
+
def task=(task)
|
14
|
+
@task_id = task.to_i
|
15
|
+
end
|
16
|
+
|
17
|
+
def project=(project)
|
18
|
+
@project_id = project.to_i
|
19
|
+
end
|
20
|
+
|
21
|
+
def active?
|
22
|
+
!deactivated
|
23
|
+
end
|
24
|
+
|
25
|
+
def task_xml
|
26
|
+
builder = Builder::XmlMarkup.new
|
27
|
+
builder.task do |t|
|
28
|
+
t.id(task_id)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
alias_method :billable?, :billable
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Harvest
|
2
|
+
class TimeEntry < BaseModel
|
3
|
+
include HappyMapper
|
4
|
+
|
5
|
+
tag 'day_entry'
|
6
|
+
|
7
|
+
element :id, Integer
|
8
|
+
element :client, String
|
9
|
+
element :project, String
|
10
|
+
element :task, String
|
11
|
+
element :hours, Float
|
12
|
+
element :notes, String
|
13
|
+
|
14
|
+
element :project_id, Integer
|
15
|
+
element :task_id, Integer
|
16
|
+
element :spent_at, Time
|
17
|
+
element :created_at, Time
|
18
|
+
element :updated_at, Time
|
19
|
+
element :user_id, Integer
|
20
|
+
element :closed, Boolean, :tag => 'is-closed'
|
21
|
+
element :billed, Boolean, :tag => 'is-billed'
|
22
|
+
|
23
|
+
def spent_at=(date)
|
24
|
+
@spent_at = (String === date ? Time.parse(date) : date)
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_xml
|
28
|
+
builder = Builder::XmlMarkup.new
|
29
|
+
builder.request do |r|
|
30
|
+
r.tag!('notes', notes) if notes
|
31
|
+
r.tag!('hours', hours) if hours
|
32
|
+
r.tag!('project_id', project_id) if project_id
|
33
|
+
r.tag!('task_id', task_id) if task_id
|
34
|
+
r.tag!('spent_at', spent_at) if spent_at
|
35
|
+
r.tag!('of_user', user_id) if user_id
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
alias_method :closed?, :closed
|
40
|
+
alias_method :billed?, :billed
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
module Harvest
|
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" => "International Date Line West",
|
6
|
+
"pacific/midway" => "Midway Island",
|
7
|
+
"pacific/pago_pago" => "Samoa",
|
8
|
+
"pacific/honolulu" => "Hawaii",
|
9
|
+
"america/juneau" => "Alaska",
|
10
|
+
"america/los_angeles" => "Pacific Time (US & Canada)",
|
11
|
+
"america/tijuana" => "Tijuana",
|
12
|
+
"america/denver" => "Mountain Time (US & Canada)",
|
13
|
+
"america/phoenix" => "Arizona",
|
14
|
+
"america/chihuahua" => "Chihuahua",
|
15
|
+
"america/mazatlan" => "Mazatlan",
|
16
|
+
"america/chicago" => "Central Time (US & Canada)",
|
17
|
+
"america/regina" => "Saskatchewan",
|
18
|
+
"america/mexico_city" => "Guadalajara",
|
19
|
+
"america/mexico_city" => "Mexico City",
|
20
|
+
"america/monterrey" => "Monterrey",
|
21
|
+
"america/guatemala" => "Central America",
|
22
|
+
"america/new_york" => "Eastern Time (US & Canada)",
|
23
|
+
"america/indiana/indianapolis" => "Indiana (East)",
|
24
|
+
"america/bogota" => "Bogota",
|
25
|
+
"america/lima" => "Lima",
|
26
|
+
"america/lima" => "Quito",
|
27
|
+
"america/halifax" => "Atlantic Time (Canada)",
|
28
|
+
"america/caracas" => "Caracas",
|
29
|
+
"america/la_paz" => "La Paz",
|
30
|
+
"america/santiago" => "Santiago",
|
31
|
+
"america/st_johns" => "Newfoundland",
|
32
|
+
"america/sao_paulo" => "Brasilia",
|
33
|
+
"america/argentina/buenos_aires" => "Buenos Aires",
|
34
|
+
"america/argentina/san_juan" => "Georgetown",
|
35
|
+
"america/godthab" => "Greenland",
|
36
|
+
"atlantic/south_georgia" => "Mid-Atlantic",
|
37
|
+
"atlantic/azores" => "Azores",
|
38
|
+
"atlantic/cape_verde" => "Cape Verde Is.",
|
39
|
+
"europe/dublin" => "Dublin",
|
40
|
+
"europe/dublin" => "Edinburgh",
|
41
|
+
"europe/lisbon" => "Lisbon",
|
42
|
+
"europe/london" => "London",
|
43
|
+
"africa/casablanca" => "Casablanca",
|
44
|
+
"africa/monrovia" => "Monrovia",
|
45
|
+
"etc/utc" => "UTC",
|
46
|
+
"europe/belgrade" => "Belgrade",
|
47
|
+
"europe/bratislava" => "Bratislava",
|
48
|
+
"europe/budapest" => "Budapest",
|
49
|
+
"europe/ljubljana" => "Ljubljana",
|
50
|
+
"europe/prague" => "Prague",
|
51
|
+
"europe/sarajevo" => "Sarajevo",
|
52
|
+
"europe/skopje" => "Skopje",
|
53
|
+
"europe/warsaw" => "Warsaw",
|
54
|
+
"europe/zagreb" => "Zagreb",
|
55
|
+
"europe/brussels" => "Brussels",
|
56
|
+
"europe/copenhagen" => "Copenhagen",
|
57
|
+
"europe/madrid" => "Madrid",
|
58
|
+
"europe/paris" => "Paris",
|
59
|
+
"europe/amsterdam" => "Amsterdam",
|
60
|
+
"europe/berlin" => "Berlin",
|
61
|
+
"europe/berlin" => "Bern",
|
62
|
+
"europe/rome" => "Rome",
|
63
|
+
"europe/stockholm" => "Stockholm",
|
64
|
+
"europe/vienna" => "Vienna",
|
65
|
+
"africa/algiers" => "West Central Africa",
|
66
|
+
"europe/bucharest" => "Bucharest",
|
67
|
+
"africa/cairo" => "Cairo",
|
68
|
+
"europe/helsinki" => "Helsinki",
|
69
|
+
"europe/kiev" => "Kyev",
|
70
|
+
"europe/riga" => "Riga",
|
71
|
+
"europe/sofia" => "Sofia",
|
72
|
+
"europe/tallinn" => "Tallinn",
|
73
|
+
"europe/vilnius" => "Vilnius",
|
74
|
+
"europe/athens" => "Athens",
|
75
|
+
"europe/istanbul" => "Istanbul",
|
76
|
+
"europe/minsk" => "Minsk",
|
77
|
+
"asia/jerusalem" => "Jerusalem",
|
78
|
+
"africa/harare" => "Harare",
|
79
|
+
"africa/johannesburg" => "Pretoria",
|
80
|
+
"europe/moscow" => "Moscow",
|
81
|
+
"europe/moscow" => "St. Petersburg",
|
82
|
+
"europe/moscow" => "Volgograd",
|
83
|
+
"asia/kuwait" => "Kuwait",
|
84
|
+
"asia/riyadh" => "Riyadh",
|
85
|
+
"africa/nairobi" => "Nairobi",
|
86
|
+
"asia/baghdad" => "Baghdad",
|
87
|
+
"asia/tehran" => "Tehran",
|
88
|
+
"asia/muscat" => "Abu Dhabi",
|
89
|
+
"asia/muscat" => "Muscat",
|
90
|
+
"asia/baku" => "Baku",
|
91
|
+
"asia/tbilisi" => "Tbilisi",
|
92
|
+
"asia/yerevan" => "Yerevan",
|
93
|
+
"asia/kabul" => "Kabul",
|
94
|
+
"asia/yekaterinburg" => "Ekaterinburg",
|
95
|
+
"asia/karachi" => "Islamabad",
|
96
|
+
"asia/karachi" => "Karachi",
|
97
|
+
"asia/tashkent" => "Tashkent",
|
98
|
+
"asia/kolkata" => "Chennai",
|
99
|
+
"asia/kolkata" => "Kolkata",
|
100
|
+
"asia/kolkata" => "Mumbai",
|
101
|
+
"asia/kolkata" => "New Delhi",
|
102
|
+
"asia/katmandu" => "Kathmandu",
|
103
|
+
"asia/dhaka" => "Astana",
|
104
|
+
"asia/dhaka" => "Dhaka",
|
105
|
+
"asia/colombo" => "Sri Jayawardenepura",
|
106
|
+
"asia/almaty" => "Almaty",
|
107
|
+
"asia/novosibirsk" => "Novosibirsk",
|
108
|
+
"asia/rangoon" => "Rangoon",
|
109
|
+
"asia/bangkok" => "Bangkok",
|
110
|
+
"asia/bangkok" => "Hanoi",
|
111
|
+
"asia/jakarta" => "Jakarta",
|
112
|
+
"asia/krasnoyarsk" => "Krasnoyarsk",
|
113
|
+
"asia/shanghai" => "Beijing",
|
114
|
+
"asia/chongqing" => "Chongqing",
|
115
|
+
"asia/hong_kong" => "Hong Kong",
|
116
|
+
"asia/urumqi" => "Urumqi",
|
117
|
+
"asia/kuala_lumpur" => "Kuala Lumpur",
|
118
|
+
"asia/singapore" => "Singapore",
|
119
|
+
"asia/taipei" => "Taipei",
|
120
|
+
"australia/perth" => "Perth",
|
121
|
+
"asia/irkutsk" => "Irkutsk",
|
122
|
+
"asia/ulaanbaatar" => "Ulaan Bataar",
|
123
|
+
"asia/seoul" => "Seoul",
|
124
|
+
"asia/tokyo" => "Osaka",
|
125
|
+
"asia/tokyo" => "Sapporo",
|
126
|
+
"asia/tokyo" => "Tokyo",
|
127
|
+
"asia/yakutsk" => "Yakutsk",
|
128
|
+
"australia/darwin" => "Darwin",
|
129
|
+
"australia/adelaide" => "Adelaide",
|
130
|
+
"australia/melbourne" => "Canberra",
|
131
|
+
"australia/melbourne" => "Melbourne",
|
132
|
+
"australia/sydney" => "Sydney",
|
133
|
+
"australia/brisbane" => "Brisbane",
|
134
|
+
"australia/hobart" => "Hobart",
|
135
|
+
"asia/vladivostok" => "Vladivostok",
|
136
|
+
"pacific/guam" => "Guam",
|
137
|
+
"pacific/port_moresby" => "Port Moresby",
|
138
|
+
"asia/magadan" => "Magadan",
|
139
|
+
"asia/magadan" => "Solomon Is.",
|
140
|
+
"pacific/noumea" => "New Caledonia",
|
141
|
+
"pacific/fiji" => "Fiji",
|
142
|
+
"asia/kamchatka" => "Kamchatka",
|
143
|
+
"pacific/majuro" => "Marshall Is.",
|
144
|
+
"pacific/auckland" => "Auckland",
|
145
|
+
"pacific/auckland" => "Wellington",
|
146
|
+
"pacific/tongatapu" => "Nuku'alofa"
|
147
|
+
}
|
148
|
+
end
|
149
|
+
end
|
data/lib/harvest/user.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
module Harvest
|
2
|
+
|
3
|
+
# The model that contains information about a task
|
4
|
+
#
|
5
|
+
# == Fields
|
6
|
+
# [+id+] (READONLY) the id of the user
|
7
|
+
# [+email+] the email of the user
|
8
|
+
# [+first_name+] the first name for the user
|
9
|
+
# [+last_name+] the last name for the user
|
10
|
+
# [+telephone+] the telephone for the user
|
11
|
+
# [+department] the department for the user
|
12
|
+
# [+password|password_confirmation+] the password for the user (only used on create.)
|
13
|
+
# [+has_access_to_all_future_projects+] whether the user should be added to future projects by default
|
14
|
+
# [+hourly_rate+] what the default hourly rate for the user is
|
15
|
+
# [+admin?+] whether the user is an admin
|
16
|
+
# [+contractor?+] whether the user is a contractor
|
17
|
+
# [+contractor?+] whether the user is a contractor
|
18
|
+
# [+timezone+] the timezone for the user.
|
19
|
+
class User < BaseModel
|
20
|
+
include HappyMapper
|
21
|
+
|
22
|
+
api_path '/people'
|
23
|
+
element :id, Integer
|
24
|
+
element :email, String
|
25
|
+
element :first_name, String, :tag => 'first-name'
|
26
|
+
element :last_name, String, :tag => 'last-name'
|
27
|
+
element :has_access_to_all_future_projects, Boolean, :tag => 'has-access-to-all-future-projects'
|
28
|
+
element :hourly_rate, Float, :tag => 'default-hourly-rate'
|
29
|
+
element :active, Boolean, :tag => 'is-active'
|
30
|
+
element :admin, Boolean, :tag => 'is-admin'
|
31
|
+
element :contractor, Boolean, :tag => 'is-contractor'
|
32
|
+
element :telephone, String
|
33
|
+
element :department, String
|
34
|
+
element :timezone, String
|
35
|
+
element :password, String
|
36
|
+
element :password_confirmation, String, :tag => 'password-confirmation'
|
37
|
+
|
38
|
+
alias_method :active?, :active
|
39
|
+
alias_method :admin?, :admin
|
40
|
+
alias_method :contractor?, :contractor
|
41
|
+
|
42
|
+
# Sets the timezone for the user. This can be done in a variety of ways.
|
43
|
+
#
|
44
|
+
# == Examples
|
45
|
+
# user.timezone = :cst # the easiest way. CST, EST, MST, and PST are supported
|
46
|
+
#
|
47
|
+
# user.timezone = 'america/chicago' # a little more verbose
|
48
|
+
#
|
49
|
+
# user.timezone = 'Central Time (US & Canada)' # the most explicit way
|
50
|
+
def timezone=(timezone)
|
51
|
+
tz = timezone.to_s.downcase
|
52
|
+
case tz
|
53
|
+
when 'cst', 'cdt' then self.timezone = 'america/chicago'
|
54
|
+
when 'est', 'edt' then self.timezone = 'america/new_york'
|
55
|
+
when 'mst', 'mdt' then self.timezone = 'america/denver'
|
56
|
+
when 'pst', 'pdt' then self.timezone = 'america/los_angeles'
|
57
|
+
else
|
58
|
+
if Harvest::Timezones::MAPPING[tz]
|
59
|
+
@timezone = Harvest::Timezones::MAPPING[tz]
|
60
|
+
else
|
61
|
+
@timezone = timezone
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Harvest
|
2
|
+
class UserAssignment < BaseModel
|
3
|
+
include HappyMapper
|
4
|
+
|
5
|
+
tag 'user-assignment'
|
6
|
+
element :id, Integer
|
7
|
+
element :user_id, Integer, :tag => 'user-id'
|
8
|
+
element :project_id, Integer, :tag => 'project-id'
|
9
|
+
element :deactivated, Boolean
|
10
|
+
element :project_manager, Boolean, :tag => 'is-project-manager'
|
11
|
+
element :hourly_rate, Float, :tag => 'hourly-rate'
|
12
|
+
|
13
|
+
def user=(user)
|
14
|
+
@user_id = user.to_i
|
15
|
+
end
|
16
|
+
|
17
|
+
def project=(project)
|
18
|
+
@project_id = project.to_i
|
19
|
+
end
|
20
|
+
|
21
|
+
def active?
|
22
|
+
!deactivated
|
23
|
+
end
|
24
|
+
|
25
|
+
def user_xml
|
26
|
+
builder = Builder::XmlMarkup.new
|
27
|
+
builder.user do |t|
|
28
|
+
t.id(user_id)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
alias_method :project_manager?, :project_manager
|
33
|
+
end
|
34
|
+
end
|
data/lib/harvested.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'happymapper'
|
2
|
+
require 'httparty'
|
3
|
+
require 'base64'
|
4
|
+
require 'builder'
|
5
|
+
require 'delegate'
|
6
|
+
|
7
|
+
require 'harvest/credentials'
|
8
|
+
require 'harvest/errors'
|
9
|
+
require 'harvest/hardy_client'
|
10
|
+
require 'harvest/timezones'
|
11
|
+
|
12
|
+
require 'harvest/base'
|
13
|
+
|
14
|
+
%w(crud activatable).each {|a| require "harvest/behavior/#{a}"}
|
15
|
+
%w(base_model client contact project task user rate_limit_status task_assignment user_assignment expense_category expense time_entry).each {|a| require "harvest/#{a}"}
|
16
|
+
%w(base account clients contacts projects tasks users task_assignments user_assignments expense_categories expenses time reports).each {|a| require "harvest/api/#{a}"}
|
17
|
+
|
18
|
+
module Harvest
|
19
|
+
VERSION = "0.3.1".freeze
|
20
|
+
|
21
|
+
class << self
|
22
|
+
|
23
|
+
# Creates a standard client that will raise all errors it encounters
|
24
|
+
#
|
25
|
+
# == Options
|
26
|
+
# * +:ssl+ - Whether or not to use SSL when connecting to Harvest. This is dependent on whether your account supports it. Set to +true+ by default
|
27
|
+
# == Examples
|
28
|
+
# Harvest.client('mysubdomain', 'myusername', 'mypassword', :ssl => false)
|
29
|
+
#
|
30
|
+
# @return [Harvest::Base]
|
31
|
+
def client(subdomain, username, password, options = {})
|
32
|
+
Harvest::Base.new(subdomain, username, password, options)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Creates a hardy client that will retry common HTTP errors it encounters and sleep() if it determines it is over your rate limit
|
36
|
+
#
|
37
|
+
# == Options
|
38
|
+
# * +:ssl+ - Whether or not to use SSL when connecting to Harvest. This is dependent on whether your account supports it. Set to +true+ by default
|
39
|
+
# * +:retry+ - How many times the hardy client should retry errors. Set to +5+ by default.
|
40
|
+
#
|
41
|
+
# == Examples
|
42
|
+
# Harvest.hardy_client('mysubdomain', 'myusername', 'mypassword', :ssl => true, :retry => 3)
|
43
|
+
#
|
44
|
+
# == Errors
|
45
|
+
# The hardy client will retry the following errors
|
46
|
+
# * Harvest::Unavailable
|
47
|
+
# * Harvest::InformHarvest
|
48
|
+
# * Net::HTTPError
|
49
|
+
# * Net::HTTPFatalError
|
50
|
+
# * Errno::ECONNRESET
|
51
|
+
#
|
52
|
+
# == Rate Limits
|
53
|
+
# 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.getharvest.com/api
|
54
|
+
#
|
55
|
+
# @return [Harvest::HardyClient] a Harvest::Base wrapped in a Harvest::HardyClient
|
56
|
+
# @see Harvest::Base
|
57
|
+
def hardy_client(subdomain, username, password, options = {})
|
58
|
+
retries = options.delete(:retry)
|
59
|
+
Harvest::HardyClient.new(client(subdomain, username, password, options), (retries || 5))
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe Harvest::Base do
|
4
|
+
describe "username/password errors" do
|
5
|
+
it "should raise error if missing a credential" do
|
6
|
+
lambda { Harvest::Base.new("subdomain", nil, "secure") }.should raise_error(Harvest::InvalidCredentials)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe Harvest::Credentials do
|
4
|
+
describe "#valid?" do
|
5
|
+
it "should return true if domain, username, and password is filled out" do
|
6
|
+
Harvest::Credentials.new("some-domain", "username", "password").should be_valid
|
7
|
+
end
|
8
|
+
|
9
|
+
it "should return false if either domain, username, or password is nil" do
|
10
|
+
Harvest::Credentials.new("some-domain", "username", nil).should_not be_valid
|
11
|
+
Harvest::Credentials.new("some-domain", nil, "password").should_not be_valid
|
12
|
+
Harvest::Credentials.new(nil, "username", "password").should_not be_valid
|
13
|
+
Harvest::Credentials.new(nil, nil, nil).should_not be_valid
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "#basic_auth" do
|
18
|
+
it "should base64 encode the credentials" do
|
19
|
+
Harvest::Credentials.new("some-domain", "username", "password").basic_auth.should == "dXNlcm5hbWU6cGFzc3dvcmQ="
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe Harvest::Expense do
|
4
|
+
describe "#spent_at" do
|
5
|
+
it "should parse strings" do
|
6
|
+
expense = Harvest::Expense.new(:spent_at => "12/01/2009")
|
7
|
+
expense.spent_at.should == Time.parse("12/01/2009")
|
8
|
+
end
|
9
|
+
|
10
|
+
it "should accept times" do
|
11
|
+
expense = Harvest::Expense.new(:spent_at => Time.parse("12/01/2009"))
|
12
|
+
expense.spent_at.should == Time.parse("12/01/2009")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe Harvest::TaskAssignment do
|
4
|
+
describe "#task_xml" do
|
5
|
+
it "should generate the xml for existing tasks" do
|
6
|
+
assignment = Harvest::TaskAssignment.new(:task => mock(:task, :to_i => 3))
|
7
|
+
assignment.task_xml.should == '<task><id>3</id></task>'
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|