moco-ruby 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -3
- data/CHANGELOG.md +104 -5
- data/Gemfile +3 -1
- data/Gemfile.lock +72 -23
- data/README.md +225 -55
- data/examples/v2_api_example.rb +73 -0
- data/lib/moco/client.rb +47 -0
- data/lib/moco/collection_proxy.rb +200 -0
- data/lib/moco/connection.rb +68 -0
- data/lib/moco/entities/activity.rb +101 -0
- data/lib/moco/entities/base_entity.rb +312 -0
- data/lib/moco/entities/company.rb +28 -0
- data/lib/moco/entities/deal.rb +24 -0
- data/lib/moco/entities/expense.rb +37 -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 +48 -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 +11 -4
- data/lib/moco/entity_collection.rb +59 -0
- data/lib/moco/helpers.rb +1 -0
- data/lib/moco/nested_collection_proxy.rb +43 -0
- data/lib/moco/sync.rb +337 -62
- data/lib/moco/version.rb +1 -1
- data/lib/moco.rb +28 -2
- data/moco.gemspec +36 -0
- data/mocurl.rb +51 -34
- data/sync_activity.rb +12 -6
- metadata +42 -8
- data/lib/moco/api.rb +0 -194
@@ -0,0 +1,312 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/inflector" # Ensure ActiveSupport::Inflector is available
|
4
|
+
|
5
|
+
module MOCO
|
6
|
+
# Base class for all MOCO API entities
|
7
|
+
class BaseEntity
|
8
|
+
attr_reader :client, :attributes
|
9
|
+
|
10
|
+
# Initializes an entity instance from raw API response data (Hash).
|
11
|
+
# Recursively processes nested hashes and arrays, converting known
|
12
|
+
# entity structures into corresponding MOCO::Entity instances.
|
13
|
+
def initialize(client, response_data)
|
14
|
+
@client = client
|
15
|
+
|
16
|
+
# Ensure response_data is a Hash before proceeding
|
17
|
+
unless response_data.is_a?(Hash)
|
18
|
+
raise ArgumentError, "BaseEntity must be initialized with a Hash, got: #{response_data.class}"
|
19
|
+
end
|
20
|
+
|
21
|
+
# Process the top-level hash: transform keys and process nested values.
|
22
|
+
@attributes = response_data.transform_keys(&:to_sym)
|
23
|
+
.each_with_object({}) do |(k, v), acc|
|
24
|
+
# Use process_value only for the *values* within the hash
|
25
|
+
acc[k] = process_value(v, k)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Define attribute methods based on the processed attributes hash
|
29
|
+
define_attribute_methods
|
30
|
+
end
|
31
|
+
|
32
|
+
# Provides a basic string representation (can be overridden by subclasses).
|
33
|
+
def to_s
|
34
|
+
"#{self.class.name.split("::").last} ##{id}"
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns the entity's ID.
|
38
|
+
def id
|
39
|
+
attributes[:id] || attributes["id"]
|
40
|
+
end
|
41
|
+
|
42
|
+
# Compares two entities based on class and ID.
|
43
|
+
def ==(other)
|
44
|
+
self.class == other.class && !id.nil? && id == other.id
|
45
|
+
end
|
46
|
+
|
47
|
+
# Converts the entity to a Hash, recursively converting nested entities.
|
48
|
+
def to_h
|
49
|
+
attributes.transform_values do |value|
|
50
|
+
case value
|
51
|
+
when BaseEntity then value.to_h
|
52
|
+
when Array then value.map { |item| item.is_a?(BaseEntity) ? item.to_h : item }
|
53
|
+
else value
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Converts the entity to a JSON string.
|
59
|
+
def to_json(*options)
|
60
|
+
to_h.to_json(*options)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Provides a string representation of the entity.
|
64
|
+
def inspect
|
65
|
+
"#<#{self.class.name}:#{object_id} @attributes=#{@attributes.inspect}>"
|
66
|
+
end
|
67
|
+
|
68
|
+
# Saves changes to the entity back to the API.
|
69
|
+
# Returns self on success for method chaining.
|
70
|
+
def save
|
71
|
+
return self if id.nil? # Can't save without an ID
|
72
|
+
|
73
|
+
# Determine the collection name from the class name
|
74
|
+
collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
|
75
|
+
|
76
|
+
# Check if the client responds to the collection method
|
77
|
+
if client.respond_to?(collection_name)
|
78
|
+
# Use the collection proxy to update the entity
|
79
|
+
updated_data = client.send(collection_name).update(id, attributes)
|
80
|
+
|
81
|
+
# Update local attributes with the response data
|
82
|
+
@attributes = updated_data.attributes if updated_data
|
83
|
+
else
|
84
|
+
warn "Warning: Client does not respond to collection '#{collection_name}' for saving entity."
|
85
|
+
end
|
86
|
+
|
87
|
+
self # Return self for method chaining
|
88
|
+
end
|
89
|
+
|
90
|
+
# Updates attributes and saves the entity in one step.
|
91
|
+
# Returns self on success for method chaining.
|
92
|
+
def update(new_attributes)
|
93
|
+
# Update attributes
|
94
|
+
new_attributes.each do |key, value|
|
95
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
96
|
+
end
|
97
|
+
|
98
|
+
# Save changes
|
99
|
+
save
|
100
|
+
end
|
101
|
+
|
102
|
+
# Deletes the entity from the API.
|
103
|
+
# Returns true on success, false on failure.
|
104
|
+
def destroy
|
105
|
+
return false if id.nil? # Can't destroy without an ID
|
106
|
+
|
107
|
+
# Determine the collection name from the class name
|
108
|
+
collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
|
109
|
+
|
110
|
+
# Check if the client responds to the collection method
|
111
|
+
if client.respond_to?(collection_name)
|
112
|
+
# Use the collection proxy to delete the entity
|
113
|
+
client.send(collection_name).delete(id)
|
114
|
+
true
|
115
|
+
else
|
116
|
+
warn "Warning: Client does not respond to collection '#{collection_name}' for destroying entity."
|
117
|
+
false
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Reloads the entity from the API.
|
122
|
+
# Returns self on success for method chaining.
|
123
|
+
def reload
|
124
|
+
return self if id.nil? # Can't reload without an ID
|
125
|
+
|
126
|
+
# Determine the collection name from the class name
|
127
|
+
collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
|
128
|
+
|
129
|
+
# Check if the client responds to the collection method
|
130
|
+
if client.respond_to?(collection_name)
|
131
|
+
# Use the collection proxy to find the entity
|
132
|
+
reloaded = client.send(collection_name).find(id)
|
133
|
+
|
134
|
+
# Update attributes with the reloaded data
|
135
|
+
@attributes = reloaded.attributes if reloaded
|
136
|
+
else
|
137
|
+
warn "Warning: Client does not respond to collection '#{collection_name}' for reloading entity."
|
138
|
+
end
|
139
|
+
|
140
|
+
self # Return self for method chaining
|
141
|
+
end
|
142
|
+
|
143
|
+
# Helper method to fetch associated objects based on data in attributes.
|
144
|
+
# Uses memoization to avoid repeated API calls.
|
145
|
+
# association_name: Symbol representing the association (e.g., :project, :customer).
|
146
|
+
# target_class_name_override: String specifying the target class if it differs
|
147
|
+
# from the classified association name (e.g., "Company" for :customer).
|
148
|
+
def association(association_name, target_class_name_override = nil)
|
149
|
+
# Initialize cache if it doesn't exist
|
150
|
+
@_association_cache ||= {}
|
151
|
+
# Return cached object if available
|
152
|
+
return @_association_cache[association_name] if @_association_cache.key?(association_name)
|
153
|
+
|
154
|
+
association_data = attributes[association_name]
|
155
|
+
|
156
|
+
# If data is already a BaseEntity object (processed during initialization), use it directly.
|
157
|
+
return @_association_cache[association_name] = association_data if association_data.is_a?(MOCO::BaseEntity)
|
158
|
+
|
159
|
+
# If data is a hash containing an ID, fetch the object.
|
160
|
+
if association_data.is_a?(Hash) && association_data[:id]
|
161
|
+
assoc_id = association_data[:id]
|
162
|
+
target_class_name = target_class_name_override || ActiveSupport::Inflector.classify(association_name.to_s)
|
163
|
+
collection_name = ActiveSupport::Inflector.tableize(target_class_name).to_sym # e.g., "Project" -> :projects
|
164
|
+
|
165
|
+
# Check if the client responds to the collection method (e.g., client.projects)
|
166
|
+
if client.respond_to?(collection_name)
|
167
|
+
# Fetch the object using the appropriate collection proxy
|
168
|
+
fetched_object = client.send(collection_name).find(assoc_id)
|
169
|
+
return @_association_cache[association_name] = fetched_object
|
170
|
+
else
|
171
|
+
warn "Warning: Client does not respond to collection '#{collection_name}' for association '#{association_name}'."
|
172
|
+
return @_association_cache[association_name] = nil
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# If data is not an object or a hash with an ID, return nil.
|
177
|
+
@_association_cache[association_name] = nil
|
178
|
+
end
|
179
|
+
|
180
|
+
# Helper method to fetch associated collections based on a foreign key.
|
181
|
+
# Uses memoization to avoid repeated API calls.
|
182
|
+
# association_name: Symbol representing the association (e.g., :tasks, :activities).
|
183
|
+
# foreign_key: Symbol representing the foreign key to use (e.g., :project_id).
|
184
|
+
# target_class_name_override: String specifying the target class if it differs
|
185
|
+
# from the classified association name (e.g., "Task" for :tasks).
|
186
|
+
# nested: Boolean indicating if this is a nested resource (e.g., project.tasks)
|
187
|
+
def has_many(association_name, foreign_key = nil, target_class_name_override = nil, nested = false)
|
188
|
+
# Initialize cache if it doesn't exist
|
189
|
+
@_association_cache ||= {}
|
190
|
+
# Return cached collection if available
|
191
|
+
return @_association_cache[association_name] if @_association_cache.key?(association_name)
|
192
|
+
|
193
|
+
# If the association data is already in attributes and is an array of entities
|
194
|
+
# AND this is NOT a nested resource, use it directly
|
195
|
+
# For nested resources, we always create a proxy to ensure CRUD operations work
|
196
|
+
association_data = attributes[association_name]
|
197
|
+
if !nested && association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
|
198
|
+
return @_association_cache[association_name] = association_data
|
199
|
+
end
|
200
|
+
|
201
|
+
# Otherwise, fetch the collection from the API
|
202
|
+
target_class_name = target_class_name_override || ActiveSupport::Inflector.classify(association_name.to_s)
|
203
|
+
collection_name = ActiveSupport::Inflector.tableize(target_class_name).to_sym # e.g., "Task" -> :tasks
|
204
|
+
|
205
|
+
# Determine the foreign key to use
|
206
|
+
fk = foreign_key || :"#{ActiveSupport::Inflector.underscore(self.class.name.split("::").last)}_id"
|
207
|
+
|
208
|
+
# Check if this is a nested resource
|
209
|
+
if nested
|
210
|
+
# For nested resources, create a NestedCollectionProxy
|
211
|
+
@_association_cache[association_name] = MOCO::NestedCollectionProxy.new(
|
212
|
+
client,
|
213
|
+
self,
|
214
|
+
collection_name,
|
215
|
+
target_class_name
|
216
|
+
)
|
217
|
+
elsif client.respond_to?(collection_name)
|
218
|
+
# For regular resources, use the standard collection proxy with a filter
|
219
|
+
@_association_cache[association_name] = client.send(collection_name).where(fk => id)
|
220
|
+
else
|
221
|
+
warn "Warning: Client does not respond to collection '#{collection_name}' for association '#{association_name}'."
|
222
|
+
@_association_cache[association_name] = []
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
private
|
227
|
+
|
228
|
+
# Defines getter and setter methods for each key in the @attributes hash.
|
229
|
+
def define_attribute_methods
|
230
|
+
attributes.each_key do |key|
|
231
|
+
# Skip if the key is nil or a method with this name already exists.
|
232
|
+
next if key.nil? || respond_to?(key)
|
233
|
+
|
234
|
+
define_singleton_method(key) { attributes[key] }
|
235
|
+
define_singleton_method("#{key}=") { |v| attributes[key] = process_value(v, key) } # Process assigned value too
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
# Recursively processes a value from the API response.
|
240
|
+
# - Hashes representing known entities are converted to Entity instances.
|
241
|
+
# - Other Hashes have their values processed recursively.
|
242
|
+
# - Arrays have their items processed recursively.
|
243
|
+
# - Primitives are returned as is.
|
244
|
+
# key_hint: The key under which this value was found in its parent Hash.
|
245
|
+
def process_value(value, key_hint = nil)
|
246
|
+
case value
|
247
|
+
when Hash
|
248
|
+
# Check if this hash represents a known MOCO entity class
|
249
|
+
klass = entity_class_for(value, key_hint)
|
250
|
+
if klass
|
251
|
+
# If yes, create an instance of that class (recursive initialize)
|
252
|
+
klass.new(client, value)
|
253
|
+
else
|
254
|
+
# If no, treat as a generic hash: process its values recursively
|
255
|
+
value.transform_keys(&:to_sym)
|
256
|
+
.each_with_object({}) do |(k, v), acc|
|
257
|
+
# Pass the key 'k' as a hint for nested processing
|
258
|
+
acc[k] = process_value(v, k)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
when Array
|
262
|
+
# Recursively process each item in the array.
|
263
|
+
# Pass a singularized key_hint if available (e.g., :tasks -> :task)
|
264
|
+
singular_hint = key_hint ? ActiveSupport::Inflector.singularize(key_hint.to_s).to_sym : nil
|
265
|
+
value.map { |item| process_value(item, singular_hint) }
|
266
|
+
else
|
267
|
+
# Return primitive values (String, Integer, Boolean, nil, etc.) as is
|
268
|
+
value
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
# Determines the specific MOCO::Entity class for a given hash, if possible.
|
273
|
+
# Returns the class constant or nil if no specific entity type is identified.
|
274
|
+
# data: The hash to analyze.
|
275
|
+
# key_hint: The key under which this hash was found in its parent.
|
276
|
+
def entity_class_for(data, key_hint = nil)
|
277
|
+
# data is always a Hash when called from process_value
|
278
|
+
type_name = data[:type] || data["type"]
|
279
|
+
|
280
|
+
# Infer type from the key_hint if :type attribute is missing
|
281
|
+
if type_name.nil? && key_hint
|
282
|
+
# Special cases: map certain keys to specific entity classes
|
283
|
+
type_name = case key_hint
|
284
|
+
when :customer
|
285
|
+
"Company"
|
286
|
+
when :leader, :co_leader, :user
|
287
|
+
"User"
|
288
|
+
else
|
289
|
+
# General case: singularize the key hint (e.g., :tasks -> "task")
|
290
|
+
ActiveSupport::Inflector.singularize(key_hint.to_s)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
# If no type name could be determined, it's not a known entity structure
|
295
|
+
return nil unless type_name
|
296
|
+
|
297
|
+
# Convert type name (e.g., "project", "user", "company", "Task") to class name
|
298
|
+
# If type_name is already classified (like "Company" from the special case),
|
299
|
+
# classify won't hurt it.
|
300
|
+
class_name = ActiveSupport::Inflector.classify(type_name)
|
301
|
+
|
302
|
+
# Check if the class exists within the MOCO module and is a BaseEntity subclass
|
303
|
+
if MOCO.const_defined?(class_name, false)
|
304
|
+
klass = MOCO.const_get(class_name)
|
305
|
+
return klass if klass.is_a?(Class) && klass <= MOCO::BaseEntity
|
306
|
+
end
|
307
|
+
|
308
|
+
# Fallback: No matching entity class found
|
309
|
+
nil
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
@@ -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,37 @@
|
|
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
|
+
# Override entity_path to use the global expenses endpoint
|
8
|
+
# Note: Expenses can also be accessed via projects/{id}/expenses
|
9
|
+
def self.entity_path
|
10
|
+
"projects/expenses"
|
11
|
+
end
|
12
|
+
|
13
|
+
# Class methods for bulk operations
|
14
|
+
def self.disregard(client, expense_ids:)
|
15
|
+
client.post("projects/expenses/disregard", { expense_ids: })
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.bulk_create(client, project_id, expenses)
|
19
|
+
client.post("projects/#{project_id}/expenses/bulk", { expenses: })
|
20
|
+
end
|
21
|
+
|
22
|
+
# Associations
|
23
|
+
def project
|
24
|
+
# Use the association method which handles embedded objects
|
25
|
+
association(:project, "Project")
|
26
|
+
end
|
27
|
+
|
28
|
+
def user
|
29
|
+
# Use the association method which handles embedded objects
|
30
|
+
association(:user, "User")
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s
|
34
|
+
"#{date} - #{title} (#{amount})"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
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 self.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,48 @@
|
|
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
|
+
def leader
|
11
|
+
# Use the association method to fetch the leader
|
12
|
+
association(:leader, "User")
|
13
|
+
end
|
14
|
+
|
15
|
+
def co_leader
|
16
|
+
# Use the association method to fetch the co_leader
|
17
|
+
association(:co_leader, "User")
|
18
|
+
end
|
19
|
+
|
20
|
+
# Fetches activities associated with this project.
|
21
|
+
def activities
|
22
|
+
# Use the has_many method to fetch activities
|
23
|
+
has_many(:activities)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Fetches expenses associated with this project.
|
27
|
+
def expenses
|
28
|
+
# Don't cache the proxy - create a fresh one each time
|
29
|
+
# This ensures we get fresh data when expenses are created/updated/deleted
|
30
|
+
MOCO::NestedCollectionProxy.new(client, self, :expenses, "Expense")
|
31
|
+
end
|
32
|
+
|
33
|
+
# Fetches tasks associated with this project.
|
34
|
+
def tasks
|
35
|
+
# Don't cache the proxy - create a fresh one each time
|
36
|
+
# This ensures we get fresh data when tasks are created/updated/deleted
|
37
|
+
MOCO::NestedCollectionProxy.new(client, self, :tasks, "Task")
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_s
|
41
|
+
"Project #{identifier} \"#{name}\" (#{id})"
|
42
|
+
end
|
43
|
+
|
44
|
+
def active?
|
45
|
+
status == "active"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
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
|