moco-ruby 0.1.1 → 1.0.0.alpha
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 +4 -4
- data/.rubocop.yml +10 -3
- data/CHANGELOG.md +79 -6
- data/Gemfile +2 -1
- data/Gemfile.lock +71 -23
- data/README.md +199 -55
- data/examples/v2_api_example.rb +73 -0
- data/lib/moco/client.rb +47 -0
- data/lib/moco/collection_proxy.rb +190 -0
- data/lib/moco/connection.rb +62 -0
- data/lib/moco/entities/activity.rb +96 -0
- data/lib/moco/entities/base_entity.rb +303 -0
- data/lib/moco/entities/company.rb +28 -0
- data/lib/moco/entities/deal.rb +24 -0
- data/lib/moco/entities/expense.rb +29 -0
- data/lib/moco/entities/holiday.rb +25 -0
- data/lib/moco/entities/invoice.rb +53 -0
- data/lib/moco/entities/planning_entry.rb +26 -0
- data/lib/moco/entities/presence.rb +30 -0
- data/lib/moco/entities/project.rb +39 -0
- data/lib/moco/entities/schedule.rb +26 -0
- data/lib/moco/entities/task.rb +20 -0
- data/lib/moco/entities/user.rb +33 -0
- data/lib/moco/entities/web_hook.rb +27 -0
- data/lib/moco/entities.rb +13 -4
- data/lib/moco/entity_collection.rb +59 -0
- data/lib/moco/helpers.rb +13 -0
- data/lib/moco/nested_collection_proxy.rb +40 -0
- data/lib/moco/sync.rb +74 -19
- data/lib/moco/version.rb +1 -1
- data/lib/moco.rb +26 -2
- data/mocurl.rb +51 -34
- data/sync_activity.rb +4 -4
- metadata +44 -10
- data/lib/moco/api.rb +0 -129
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO company (customer)
|
5
|
+
# Provides methods for company-specific associations
|
6
|
+
class Company < BaseEntity
|
7
|
+
# Associations
|
8
|
+
def projects
|
9
|
+
has_many(:projects)
|
10
|
+
end
|
11
|
+
|
12
|
+
def invoices
|
13
|
+
has_many(:invoices)
|
14
|
+
end
|
15
|
+
|
16
|
+
def deals
|
17
|
+
has_many(:deals)
|
18
|
+
end
|
19
|
+
|
20
|
+
def contacts
|
21
|
+
has_many(:contacts)
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_s
|
25
|
+
name
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO deal
|
5
|
+
# Provides methods for deal-specific associations
|
6
|
+
class Deal < BaseEntity
|
7
|
+
# Associations
|
8
|
+
def company
|
9
|
+
association(:company) || association(:customer, "Company")
|
10
|
+
end
|
11
|
+
|
12
|
+
def user
|
13
|
+
association(:user)
|
14
|
+
end
|
15
|
+
|
16
|
+
def category
|
17
|
+
association(:category, "DealCategory")
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s
|
21
|
+
"#{name} (#{company&.name})"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO expense
|
5
|
+
# Provides methods for expense-specific operations and associations
|
6
|
+
class Expense < BaseEntity
|
7
|
+
# Class methods for bulk operations
|
8
|
+
def self.disregard(client, expense_ids:)
|
9
|
+
client.post("projects/expenses/disregard", { expense_ids: })
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.bulk_create(client, project_id, expenses)
|
13
|
+
client.post("projects/#{project_id}/expenses/bulk", { expenses: })
|
14
|
+
end
|
15
|
+
|
16
|
+
# Associations
|
17
|
+
def project
|
18
|
+
@project ||= client.projects.find(project_id) if project_id
|
19
|
+
end
|
20
|
+
|
21
|
+
def user
|
22
|
+
@user ||= client.users.find(user_id) if user_id
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_s
|
26
|
+
"#{date} - #{title} (#{amount})"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO holiday entry
|
5
|
+
# Provides methods for holiday-specific associations
|
6
|
+
class Holiday < BaseEntity
|
7
|
+
# Override entity_path to match API path
|
8
|
+
def entity_path
|
9
|
+
"users/holidays"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Associations
|
13
|
+
def user
|
14
|
+
@user ||= client.users.find(user_id) if user_id
|
15
|
+
end
|
16
|
+
|
17
|
+
def creator
|
18
|
+
@creator ||= client.users.find(creator_id) if creator_id
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
"#{year} - #{title} - #{days} days (#{hours} hours) - #{user&.full_name}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO invoice
|
5
|
+
# Provides methods for invoice-specific operations and associations
|
6
|
+
class Invoice < BaseEntity
|
7
|
+
# Instance methods for invoice-specific operations
|
8
|
+
def update_status(status)
|
9
|
+
client.put("invoices/#{id}/update_status", { status: })
|
10
|
+
self
|
11
|
+
end
|
12
|
+
|
13
|
+
def pdf
|
14
|
+
client.get("invoices/#{id}.pdf")
|
15
|
+
end
|
16
|
+
|
17
|
+
def timesheet
|
18
|
+
client.get("invoices/#{id}/timesheet")
|
19
|
+
end
|
20
|
+
|
21
|
+
def timesheet_pdf
|
22
|
+
client.get("invoices/#{id}/timesheet.pdf")
|
23
|
+
end
|
24
|
+
|
25
|
+
def expenses
|
26
|
+
client.get("invoices/#{id}/expenses")
|
27
|
+
end
|
28
|
+
|
29
|
+
def send_email(recipient:, subject:, text:, **options)
|
30
|
+
payload = {
|
31
|
+
recipient:,
|
32
|
+
subject:,
|
33
|
+
text:
|
34
|
+
}.merge(options)
|
35
|
+
|
36
|
+
client.post("invoices/#{id}/send_email", payload)
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
# Associations
|
41
|
+
def company
|
42
|
+
association(:customer, "Company")
|
43
|
+
end
|
44
|
+
|
45
|
+
def project
|
46
|
+
association(:project)
|
47
|
+
end
|
48
|
+
|
49
|
+
def to_s
|
50
|
+
"#{identifier} - #{title} (#{date})"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO planning entry
|
5
|
+
# Provides methods for planning entry-specific associations
|
6
|
+
class PlanningEntry < BaseEntity
|
7
|
+
# Associations
|
8
|
+
def user
|
9
|
+
@user ||= client.users.find(user_id) if user_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def project
|
13
|
+
@project ||= client.projects.find(project_id) if project_id
|
14
|
+
end
|
15
|
+
|
16
|
+
def deal
|
17
|
+
@deal ||= client.deals.find(deal_id) if deal_id
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s
|
21
|
+
period = starts_on == ends_on ? starts_on : "#{starts_on} to #{ends_on}"
|
22
|
+
resource = project || deal
|
23
|
+
"#{period} - #{hours_per_day}h/day - #{user&.full_name} - #{resource&.name}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO presence entry
|
5
|
+
# Provides methods for presence-specific operations and associations
|
6
|
+
class Presence < BaseEntity
|
7
|
+
# Define the specific API path for this entity as a class method
|
8
|
+
def self.entity_path
|
9
|
+
"users/presences"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Class methods for special operations
|
13
|
+
def self.touch(client, is_home_office: false, override: nil)
|
14
|
+
payload = {}
|
15
|
+
payload[:is_home_office] = is_home_office if is_home_office
|
16
|
+
payload[:override] = override if override
|
17
|
+
|
18
|
+
client.post("users/presences/touch", payload)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Associations
|
22
|
+
def user
|
23
|
+
@user ||= client.users.find(user_id) if user_id
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_s
|
27
|
+
"#{date} - #{from} to #{to} - #{user&.full_name}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
class Project < BaseEntity
|
5
|
+
def customer
|
6
|
+
# Use the association method to fetch the customer
|
7
|
+
association(:customer, "Company")
|
8
|
+
end
|
9
|
+
|
10
|
+
# Fetches activities associated with this project.
|
11
|
+
def activities
|
12
|
+
# Use the has_many method to fetch activities
|
13
|
+
has_many(:activities)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Fetches tasks associated with this project.
|
17
|
+
def tasks
|
18
|
+
# Check if tasks are already loaded in attributes
|
19
|
+
if attributes[:tasks].is_a?(Array) && attributes[:tasks].all? { |t| t.is_a?(MOCO::Task) }
|
20
|
+
# If tasks are already loaded, create a NestedCollectionProxy with the loaded tasks
|
21
|
+
@_tasks_proxy ||= begin
|
22
|
+
require_relative "../nested_collection_proxy"
|
23
|
+
proxy = MOCO::NestedCollectionProxy.new(client, self, :tasks, "Task")
|
24
|
+
# We need to manually set the loaded records since we already have them
|
25
|
+
proxy.instance_variable_set(:@records, attributes[:tasks])
|
26
|
+
proxy.instance_variable_set(:@loaded, true)
|
27
|
+
proxy
|
28
|
+
end
|
29
|
+
else
|
30
|
+
# Otherwise, use has_many with nested=true
|
31
|
+
has_many(:tasks, nil, nil, true)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def active?
|
36
|
+
status == "active"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO schedule entry
|
5
|
+
# Provides methods for schedule-specific associations
|
6
|
+
class Schedule < BaseEntity
|
7
|
+
# Associations
|
8
|
+
def user
|
9
|
+
@user ||= client.users.find(user_id) if user_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def assignment
|
13
|
+
return nil unless assignment_id
|
14
|
+
|
15
|
+
@assignment ||= if assignment_type == "Absence"
|
16
|
+
client.absences.find(assignment_id)
|
17
|
+
else
|
18
|
+
client.projects.find(assignment_id)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_s
|
23
|
+
"#{date} - #{user&.full_name} - #{assignment&.name}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO task
|
5
|
+
# Provides methods for task-specific associations
|
6
|
+
class Task < BaseEntity
|
7
|
+
# Associations
|
8
|
+
def project
|
9
|
+
@project ||= client.projects.find(project_id) if project_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def activities
|
13
|
+
client.activities.where(task_id: id)
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
name
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO user
|
5
|
+
# Provides methods for user-specific operations and associations
|
6
|
+
class User < BaseEntity
|
7
|
+
# Instance methods for user-specific operations
|
8
|
+
def performance_report
|
9
|
+
client.get("users/#{id}/performance_report")
|
10
|
+
end
|
11
|
+
|
12
|
+
# Associations
|
13
|
+
def activities
|
14
|
+
has_many(:activities)
|
15
|
+
end
|
16
|
+
|
17
|
+
def presences
|
18
|
+
has_many(:presences)
|
19
|
+
end
|
20
|
+
|
21
|
+
def holidays
|
22
|
+
has_many(:holidays)
|
23
|
+
end
|
24
|
+
|
25
|
+
def full_name
|
26
|
+
"#{firstname} #{lastname}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_s
|
30
|
+
full_name
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO webhook
|
5
|
+
# Provides methods for webhook-specific operations
|
6
|
+
class WebHook < BaseEntity
|
7
|
+
# Override entity_path to match API path
|
8
|
+
def entity_path
|
9
|
+
"account/web_hooks"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Instance methods for webhook-specific operations
|
13
|
+
def enable
|
14
|
+
client.put("account/web_hooks/#{id}/enable")
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
def disable
|
19
|
+
client.put("account/web_hooks/#{id}/disable")
|
20
|
+
self
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
"#{target} - #{url}"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/moco/entities.rb
CHANGED
@@ -1,7 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "helpers"
|
4
|
+
|
3
5
|
module MOCO
|
4
6
|
# Base entity class others inherit from, providing comparison, to_h, to_json
|
7
|
+
# @deprecated Use MOCO::BaseEntity from entities/base_entity.rb instead
|
5
8
|
class BaseEntity
|
6
9
|
def eql?(other)
|
7
10
|
return false unless other.is_a? self.class
|
@@ -26,7 +29,6 @@ module MOCO
|
|
26
29
|
hash
|
27
30
|
end
|
28
31
|
|
29
|
-
# rubocop:disable Metrics/MethodLength
|
30
32
|
def to_json(*arg)
|
31
33
|
to_h do |k, v|
|
32
34
|
if v.is_a? Hash
|
@@ -41,9 +43,9 @@ module MOCO
|
|
41
43
|
end.to_h.to_json(arg)
|
42
44
|
end
|
43
45
|
end
|
44
|
-
# rubocop:enable Metrics/MethodLength
|
45
46
|
|
46
47
|
# https://hundertzehn.github.io/mocoapp-api-docs/sections/projects.html
|
48
|
+
# @deprecated Use MOCO::Project from entities/project.rb instead
|
47
49
|
class Project < BaseEntity
|
48
50
|
attr_accessor :id, :active, :name, :customer, :tasks
|
49
51
|
|
@@ -53,6 +55,7 @@ module MOCO
|
|
53
55
|
end
|
54
56
|
|
55
57
|
# https://hundertzehn.github.io/mocoapp-api-docs/sections/project_tasks.html
|
58
|
+
# @deprecated Use MOCO::Task from entities/task.rb instead
|
56
59
|
class Task < BaseEntity
|
57
60
|
attr_accessor :id, :active, :name, :project_id, :billable
|
58
61
|
|
@@ -62,22 +65,28 @@ module MOCO
|
|
62
65
|
end
|
63
66
|
|
64
67
|
# https://hundertzehn.github.io/mocoapp-api-docs/sections/activities.html
|
68
|
+
# @deprecated Use MOCO::Activity from entities/activity.rb instead
|
65
69
|
class Activity < BaseEntity
|
66
70
|
attr_accessor :id, :active, :date, :description, :project, :task, :seconds, :hours, :billable, :billed, :user,
|
67
71
|
:customer, :tag
|
68
72
|
|
69
73
|
def to_s
|
70
|
-
|
71
|
-
|
74
|
+
description_part = description.empty? ? "" : " (#{description})"
|
75
|
+
status_part = "(#{%i[billable billed].map { |x| (send(x) ? "" : "not ") + x.to_s }.join(", ")})"
|
76
|
+
|
77
|
+
"#{date} - #{Helpers.decimal_hours_to_civil(hours)}h (#{seconds}s) - " \
|
78
|
+
"#{project&.name} - #{task&.name}#{description_part} #{status_part}"
|
72
79
|
end
|
73
80
|
end
|
74
81
|
|
75
82
|
# https://hundertzehn.github.io/mocoapp-api-docs/sections/companies.html
|
83
|
+
# @deprecated Use MOCO::Company from entities/company.rb instead
|
76
84
|
class Customer < BaseEntity
|
77
85
|
attr_accessor :id, :name
|
78
86
|
end
|
79
87
|
|
80
88
|
# https://hundertzehn.github.io/mocoapp-api-docs/sections/users.html
|
89
|
+
# @deprecated Use MOCO::User from entities/user.rb instead
|
81
90
|
class User < BaseEntity
|
82
91
|
attr_accessor :id, :firstname, :lastname
|
83
92
|
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "collection_proxy"
|
4
|
+
|
5
|
+
module MOCO
|
6
|
+
# Provides high-level collection operations for MOCO entities
|
7
|
+
class EntityCollection
|
8
|
+
include Enumerable
|
9
|
+
attr_reader :client, :path, :entity_class_name
|
10
|
+
|
11
|
+
def initialize(client, path, entity_class_name)
|
12
|
+
@client = client
|
13
|
+
@path = path
|
14
|
+
@entity_class_name = entity_class_name
|
15
|
+
end
|
16
|
+
|
17
|
+
def all(params = {})
|
18
|
+
collection.all(params)
|
19
|
+
end
|
20
|
+
|
21
|
+
def find(id)
|
22
|
+
collection.find(id)
|
23
|
+
end
|
24
|
+
|
25
|
+
def where(filters = {})
|
26
|
+
collection.where(filters)
|
27
|
+
end
|
28
|
+
|
29
|
+
def create(attributes)
|
30
|
+
collection.create(attributes)
|
31
|
+
end
|
32
|
+
|
33
|
+
def each(&)
|
34
|
+
all.each(&)
|
35
|
+
end
|
36
|
+
|
37
|
+
def first
|
38
|
+
all.first
|
39
|
+
end
|
40
|
+
|
41
|
+
def count
|
42
|
+
all.count
|
43
|
+
end
|
44
|
+
|
45
|
+
def update(id, attributes)
|
46
|
+
collection.update(id, attributes)
|
47
|
+
end
|
48
|
+
|
49
|
+
def delete(id)
|
50
|
+
collection.delete(id)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def collection
|
56
|
+
@collection ||= CollectionProxy.new(client, path, entity_class_name)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/moco/helpers.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Utility class with helper methods for the MOCO API
|
5
|
+
class Helpers
|
6
|
+
def self.decimal_hours_to_civil(decimal_hours)
|
7
|
+
hours = decimal_hours.floor
|
8
|
+
fractional_hours = decimal_hours - hours
|
9
|
+
minutes = (fractional_hours * 60).round
|
10
|
+
"#{hours}:#{format("%02d", minutes)}"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Provides ActiveRecord-style query interface for nested MOCO entities
|
5
|
+
# For example, project.tasks is a nested collection of tasks under a project
|
6
|
+
class NestedCollectionProxy < CollectionProxy
|
7
|
+
attr_reader :parent, :records
|
8
|
+
|
9
|
+
def initialize(client, parent, path_or_entity_name, entity_class_name)
|
10
|
+
@parent = parent
|
11
|
+
super(client, path_or_entity_name, entity_class_name)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Override determine_base_path to include the parent's path
|
15
|
+
def determine_base_path(path_or_entity_name)
|
16
|
+
parent_type = ActiveSupport::Inflector.underscore(parent.class.name.split("::").last)
|
17
|
+
"#{parent_type.pluralize}/#{parent.id}/#{super}"
|
18
|
+
end
|
19
|
+
|
20
|
+
# Create a new entity in this nested collection
|
21
|
+
def create(attributes)
|
22
|
+
klass = entity_class
|
23
|
+
return nil unless klass && klass <= MOCO::BaseEntity
|
24
|
+
|
25
|
+
klass.new(client, client.post(@base_path, attributes))
|
26
|
+
end
|
27
|
+
|
28
|
+
# Delete all entities in this nested collection
|
29
|
+
def destroy_all
|
30
|
+
client.delete("#{@base_path}/destroy_all")
|
31
|
+
true
|
32
|
+
rescue StandardError => e
|
33
|
+
warn "Warning: Failed to destroy all entities in #{@base_path}: #{e.message}"
|
34
|
+
false
|
35
|
+
end
|
36
|
+
|
37
|
+
# Make these methods public so they can be accessed by Project#tasks
|
38
|
+
public :load_records, :loaded?
|
39
|
+
end
|
40
|
+
end
|