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,73 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "moco"
|
6
|
+
require "yaml"
|
7
|
+
|
8
|
+
# Load configuration from config.yml
|
9
|
+
config = YAML.load_file(File.join(File.dirname(__FILE__), "..", "config.yml"))
|
10
|
+
instance = config["instances"].first
|
11
|
+
|
12
|
+
# Initialize client
|
13
|
+
moco = MOCO::Client.new(
|
14
|
+
subdomain: instance["subdomain"],
|
15
|
+
api_key: instance["api_key"]
|
16
|
+
)
|
17
|
+
|
18
|
+
puts "Connected to MOCO instance: #{instance["subdomain"]}"
|
19
|
+
|
20
|
+
# Get all active projects
|
21
|
+
puts "\nActive Projects:"
|
22
|
+
projects = moco.projects.where(active: "true")
|
23
|
+
projects.each do |project|
|
24
|
+
puts "- #{project.id}: #{project.name} (#{project.customer&.name})"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get a specific project
|
28
|
+
if projects.any?
|
29
|
+
project = projects.first
|
30
|
+
puts "\nProject Details for #{project.name}:"
|
31
|
+
puts " Customer: #{project.customer&.name}"
|
32
|
+
|
33
|
+
# Get tasks for the project
|
34
|
+
puts " Tasks:"
|
35
|
+
project.tasks.each do |task|
|
36
|
+
puts " - #{task.name} (#{task.billable ? "Billable" : "Non-billable"})"
|
37
|
+
end
|
38
|
+
|
39
|
+
# Get recent activities for the project
|
40
|
+
puts "\nRecent Activities for #{project.name}:"
|
41
|
+
activities = project.activities
|
42
|
+
activities.each do |activity|
|
43
|
+
puts " - #{activity.date}: #{activity.hours}h - #{activity.description} (#{activity.user&.full_name})"
|
44
|
+
end
|
45
|
+
|
46
|
+
# Demonstrate chaining (commented out to avoid modifying data)
|
47
|
+
# project.archive.assign_to_group(123).unarchive
|
48
|
+
end
|
49
|
+
|
50
|
+
# Get users
|
51
|
+
puts "\nUsers:"
|
52
|
+
users = moco.users.all
|
53
|
+
users.each do |user|
|
54
|
+
puts "- #{user.id}: #{user.full_name}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Dynamic access to any collection
|
58
|
+
puts "\nDemonstrating dynamic collection access:"
|
59
|
+
collections = %w[companies deals invoices expenses schedules presences holidays planning_entries]
|
60
|
+
collections.each do |collection|
|
61
|
+
if moco.respond_to?(collection)
|
62
|
+
count = begin
|
63
|
+
moco.send(collection).count
|
64
|
+
rescue StandardError
|
65
|
+
0
|
66
|
+
end
|
67
|
+
puts "- #{collection}: #{count} items"
|
68
|
+
else
|
69
|
+
puts "- #{collection}: not available"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
puts "\nExample completed successfully!"
|
data/lib/moco/client.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Main client class for interacting with the MOCO API
|
5
|
+
# Provides dynamic access to all API endpoints through method_missing
|
6
|
+
class Client
|
7
|
+
attr_reader :connection
|
8
|
+
|
9
|
+
def initialize(subdomain:, api_key:, debug: false)
|
10
|
+
@connection = Connection.new(self, subdomain, api_key, debug: debug)
|
11
|
+
@collections = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
# Dynamically handle entity collection access (e.g., client.projects)
|
15
|
+
def method_missing(name, *args, &)
|
16
|
+
# Check if the method name corresponds to a known plural entity type
|
17
|
+
if collection_name?(name)
|
18
|
+
# Return a CollectionProxy directly for chainable queries
|
19
|
+
# Cache it so subsequent calls return the same proxy instance
|
20
|
+
@collections[name] ||= CollectionProxy.new(
|
21
|
+
self,
|
22
|
+
name.to_s, # Pass the plural name (e.g., "projects") as the path hint
|
23
|
+
ActiveSupport::Inflector.classify(name.to_s) # Get class name (e.g., "Project")
|
24
|
+
)
|
25
|
+
else
|
26
|
+
# Delegate to superclass for non-collection methods
|
27
|
+
super
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def respond_to_missing?(name, include_private = false)
|
32
|
+
collection_name?(name) || super
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check if the method name looks like a collection name (plural)
|
36
|
+
def collection_name?(name)
|
37
|
+
name.to_s == ActiveSupport::Inflector.pluralize(name.to_s)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Delegate HTTP methods to connection
|
41
|
+
%i[get post put patch delete].each do |method|
|
42
|
+
define_method(method) do |path, params = {}|
|
43
|
+
connection.send(method, path, params)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,200 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Provides ActiveRecord-style query interface for MOCO entities
|
5
|
+
class CollectionProxy
|
6
|
+
include Enumerable
|
7
|
+
attr_reader :client, :entity_class_name, :filters, :limit_value
|
8
|
+
|
9
|
+
def initialize(client, path_or_entity_name, entity_class_name)
|
10
|
+
@client = client
|
11
|
+
@entity_class_name = entity_class_name
|
12
|
+
@entity_class = load_entity_class # Load and store the class
|
13
|
+
@base_path = determine_base_path(path_or_entity_name)
|
14
|
+
@filters = {} # Store query filters
|
15
|
+
@limit_value = nil # Store limit for methods like first/find_by
|
16
|
+
@loaded = false # Flag to track if data has been fetched
|
17
|
+
@records = [] # Cache for fetched records
|
18
|
+
end
|
19
|
+
|
20
|
+
def load_entity_class
|
21
|
+
# Ensure ActiveSupport::Inflector is available if not already loaded globally
|
22
|
+
require "active_support/inflector" unless defined?(ActiveSupport::Inflector)
|
23
|
+
|
24
|
+
entity_file_name = ActiveSupport::Inflector.underscore(entity_class_name)
|
25
|
+
entity_file_path = "entities/#{entity_file_name}" # Path relative to lib/moco/
|
26
|
+
begin
|
27
|
+
# Use require_relative from the current file's directory
|
28
|
+
require_relative entity_file_path
|
29
|
+
MOCO.const_get(entity_class_name)
|
30
|
+
rescue LoadError
|
31
|
+
warn "Warning: Could not load entity file at #{entity_file_path}. Using BaseEntity."
|
32
|
+
MOCO::BaseEntity # Fallback
|
33
|
+
rescue NameError
|
34
|
+
warn "Warning: Could not find entity class #{entity_class_name}. Using BaseEntity."
|
35
|
+
MOCO::BaseEntity # Fallback
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Removed method_missing and respond_to_missing? as they are not
|
40
|
+
# currently used for building nested paths in this implementation.
|
41
|
+
|
42
|
+
# --- Chainable Methods ---
|
43
|
+
|
44
|
+
# Adds filters to the query. Returns self for chaining.
|
45
|
+
def where(conditions = {})
|
46
|
+
# TODO: Implement proper merging/handling of existing filters if called multiple times
|
47
|
+
@filters.merge!(conditions)
|
48
|
+
self # Return self to allow chaining like client.projects.where(active: true).where(...)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Sets a limit on the number of records to fetch. Returns self.
|
52
|
+
def limit(value)
|
53
|
+
@limit_value = value
|
54
|
+
self
|
55
|
+
end
|
56
|
+
|
57
|
+
# Modifies the base path to fetch assigned resources. Returns self.
|
58
|
+
def assigned
|
59
|
+
# Ensure this is only called once or handle idempotency if needed
|
60
|
+
@base_path += "/assigned"
|
61
|
+
self
|
62
|
+
end
|
63
|
+
|
64
|
+
# --- Methods Triggering API Call ---
|
65
|
+
|
66
|
+
# Fetches all records matching the current filters.
|
67
|
+
# Caches the result.
|
68
|
+
def all
|
69
|
+
load_records unless loaded?
|
70
|
+
@records
|
71
|
+
end
|
72
|
+
|
73
|
+
# Fetches a specific record by ID. Does not use current filters or limit.
|
74
|
+
def find(id)
|
75
|
+
# Ensure entity_class is loaded and valid before calling new
|
76
|
+
klass = entity_class
|
77
|
+
return nil unless klass && klass <= MOCO::BaseEntity
|
78
|
+
|
79
|
+
# Directly fetch by ID, bypassing stored filters/limit
|
80
|
+
response = client.get("#{@base_path}/#{id}")
|
81
|
+
# wrap_response now returns an array even for single results
|
82
|
+
result_array = wrap_response(response)
|
83
|
+
# Return the single entity or nil if not found (or if response was not a hash)
|
84
|
+
result_array.first
|
85
|
+
end
|
86
|
+
|
87
|
+
# NOTE: The duplicated 'where' method definition below is removed.
|
88
|
+
# The correct 'where' method is defined earlier in the class.
|
89
|
+
|
90
|
+
# Fetches the first record matching the current filters.
|
91
|
+
def first
|
92
|
+
limit(1).load_records unless loaded? && @limit_value == 1
|
93
|
+
@records.first
|
94
|
+
end
|
95
|
+
|
96
|
+
# Finds the first record matching the given attributes.
|
97
|
+
def find_by(conditions)
|
98
|
+
where(conditions).first
|
99
|
+
end
|
100
|
+
|
101
|
+
# Executes the query and yields each record.
|
102
|
+
def each(&)
|
103
|
+
load_records unless loaded?
|
104
|
+
@records.each(&)
|
105
|
+
end
|
106
|
+
|
107
|
+
# --- Persistence Methods (Pass-through) ---
|
108
|
+
# These don't typically belong on the relation/proxy but are kept for now.
|
109
|
+
# Consider moving them or ensuring they operate correctly in this context.
|
110
|
+
|
111
|
+
def create(attributes)
|
112
|
+
klass = entity_class
|
113
|
+
return nil unless klass && klass <= MOCO::BaseEntity
|
114
|
+
|
115
|
+
klass.new(client, client.post(@base_path, attributes))
|
116
|
+
end
|
117
|
+
|
118
|
+
def update(id, attributes)
|
119
|
+
klass = entity_class
|
120
|
+
return nil unless klass && klass <= MOCO::BaseEntity
|
121
|
+
|
122
|
+
klass.new(client, client.put("#{@base_path}/#{id}", attributes))
|
123
|
+
end
|
124
|
+
|
125
|
+
def delete(id)
|
126
|
+
client.delete("#{@base_path}/#{id}")
|
127
|
+
end
|
128
|
+
|
129
|
+
# --- Internal Methods ---
|
130
|
+
|
131
|
+
# Executes the API request based on current filters and limit.
|
132
|
+
# Populates @records and sets @loaded flag.
|
133
|
+
# Needs to be public for methods like first, each, find_by to call it.
|
134
|
+
def load_records
|
135
|
+
query_params = @filters.dup
|
136
|
+
query_params[:limit] = @limit_value if @limit_value
|
137
|
+
# MOCO API might use 'per_page' instead of 'limit' for pagination control
|
138
|
+
# Adjust if necessary based on API docs. Assuming 'limit' works for now.
|
139
|
+
|
140
|
+
# Filter out nil values before sending to avoid empty params like ?from&to=
|
141
|
+
query_params.compact!
|
142
|
+
|
143
|
+
response = client.get(@base_path, query_params)
|
144
|
+
@records = wrap_response(response) # wrap_response should return an Array here
|
145
|
+
@loaded = true
|
146
|
+
@records # Return the loaded records
|
147
|
+
end
|
148
|
+
|
149
|
+
protected
|
150
|
+
|
151
|
+
# Flag indicating if records have been loaded from the API.
|
152
|
+
def loaded?
|
153
|
+
@loaded
|
154
|
+
end
|
155
|
+
|
156
|
+
# Returns the loaded entity class constant.
|
157
|
+
attr_reader :entity_class
|
158
|
+
|
159
|
+
# Determines the base API path for the entity.
|
160
|
+
# Uses entity_path method if defined, otherwise uses the pluralized name.
|
161
|
+
def determine_base_path(path_or_entity_name)
|
162
|
+
klass = entity_class
|
163
|
+
# Check if the class itself responds to entity_path (class method)
|
164
|
+
return klass.entity_path if klass.respond_to?(:entity_path)
|
165
|
+
|
166
|
+
# Check if instances respond to entity_path (instance method)
|
167
|
+
# Need a dummy instance if the class is valid
|
168
|
+
if klass && klass <= MOCO::BaseEntity && klass.instance_methods.include?(:entity_path)
|
169
|
+
# We can't reliably call instance methods here without data.
|
170
|
+
# This indicates entity_path should likely be a class method.
|
171
|
+
# Falling back to default path generation.
|
172
|
+
warn "Warning: entity_path is defined as an instance method on #{klass.name}. It should ideally be a class method. Falling back to default path."
|
173
|
+
end
|
174
|
+
|
175
|
+
# Fallback: Use the pluralized/tableized version of the entity name or the provided path.
|
176
|
+
ActiveSupport::Inflector.tableize(path_or_entity_name.to_s)
|
177
|
+
end
|
178
|
+
|
179
|
+
private
|
180
|
+
|
181
|
+
# Wraps the raw API response (Hash or Array of Hashes) into entity objects.
|
182
|
+
def wrap_response(response_body)
|
183
|
+
klass = entity_class
|
184
|
+
# Ensure we have a valid class derived from BaseEntity
|
185
|
+
return [] unless klass && klass <= MOCO::BaseEntity # Return empty array if class invalid
|
186
|
+
|
187
|
+
if response_body.is_a?(Array)
|
188
|
+
# Convert array of hashes to array of entity objects
|
189
|
+
response_body.map { |item_hash| klass.new(client, item_hash) if item_hash.is_a?(Hash) }.compact
|
190
|
+
elsif response_body.is_a?(Hash)
|
191
|
+
# Wrap single hash response in an array for consistency internally
|
192
|
+
[klass.new(client, response_body)]
|
193
|
+
else
|
194
|
+
# Handle unexpected response types (like the String error we saw)
|
195
|
+
warn "Warning: Unexpected API response type received in wrap_response: #{response_body.class}. Expected Hash or Array."
|
196
|
+
response_body # Return the unexpected body as is
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module MOCO
|
7
|
+
# Handles HTTP communication with the MOCO API
|
8
|
+
# Responsible for building API requests and converting responses to entity objects
|
9
|
+
class Connection
|
10
|
+
attr_reader :client, :subdomain, :api_key, :debug
|
11
|
+
|
12
|
+
def initialize(client, subdomain, api_key, debug: false)
|
13
|
+
@client = client
|
14
|
+
@subdomain = subdomain
|
15
|
+
@api_key = api_key
|
16
|
+
@debug = debug
|
17
|
+
@conn = Faraday.new do |f|
|
18
|
+
f.request :json
|
19
|
+
f.response :json
|
20
|
+
f.request :authorization, "Token", "token=#{@api_key}"
|
21
|
+
f.url_prefix = "https://#{@subdomain}.mocoapp.com/api/v1"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Define methods for HTTP verbs (get, post, put, patch, delete)
|
26
|
+
# These methods send the request and return the raw parsed JSON response body.
|
27
|
+
%w[get post put patch delete].each do |http_method|
|
28
|
+
define_method(http_method) do |path, params = {}|
|
29
|
+
# Log URL if debug is enabled
|
30
|
+
if @debug
|
31
|
+
full_url = @conn.build_url(path, params).to_s
|
32
|
+
warn "[DEBUG] Fetching URL: #{http_method.upcase} #{full_url}"
|
33
|
+
end
|
34
|
+
response = @conn.send(http_method, path, params)
|
35
|
+
|
36
|
+
# Raise an error for non-successful responses
|
37
|
+
unless response.success?
|
38
|
+
# Attempt to parse error details from the body, otherwise use status/reason
|
39
|
+
error_details = response.body.is_a?(Hash) ? response.body["message"] : response.body
|
40
|
+
# Explicitly pass nil for original_error, and response for the third argument
|
41
|
+
# raise MOCO::Error.new("MOCO API Error: #{response.status} #{response.reason_phrase}. Details: #{error_details}",
|
42
|
+
# nil, response)
|
43
|
+
# Use RuntimeError for now
|
44
|
+
raise "MOCO API Error: #{response.status} #{response.reason_phrase}. Details: #{error_details}"
|
45
|
+
end
|
46
|
+
|
47
|
+
response.body
|
48
|
+
rescue Faraday::Error => e
|
49
|
+
# Wrap Faraday errors - pass e as the second argument (original_error)
|
50
|
+
# raise MOCO::Error.new("Faraday Connection Error: #{e.message}", e)
|
51
|
+
# Use RuntimeError for now
|
52
|
+
raise "Faraday Connection Error: #{e.message}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Define a custom error class for MOCO API errors
|
57
|
+
# Temporarily commented out
|
58
|
+
# class Error < StandardError
|
59
|
+
# attr_reader :original_error, :response
|
60
|
+
#
|
61
|
+
# def initialize(message, original_error = nil, response = nil)
|
62
|
+
# super(message)
|
63
|
+
# @original_error = original_error
|
64
|
+
# @response = response
|
65
|
+
# end
|
66
|
+
# end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MOCO
|
4
|
+
# Represents a MOCO activity (time entry)
|
5
|
+
# Provides methods for activity-specific operations and associations
|
6
|
+
class Activity < BaseEntity
|
7
|
+
# Instance methods for activity-specific operations
|
8
|
+
def start_timer
|
9
|
+
client.patch("activities/#{id}/start_timer")
|
10
|
+
self
|
11
|
+
end
|
12
|
+
|
13
|
+
def stop_timer
|
14
|
+
client.patch("activities/#{id}/stop_timer")
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
# Class methods for bulk operations
|
19
|
+
def self.disregard(client, reason:, activity_ids:, company_id:, project_id: nil)
|
20
|
+
payload = {
|
21
|
+
reason:,
|
22
|
+
activity_ids:,
|
23
|
+
company_id:
|
24
|
+
}
|
25
|
+
payload[:project_id] = project_id if project_id
|
26
|
+
client.post("activities/disregard", payload)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.bulk_create(client, activities)
|
30
|
+
api_entities = activities.map do |activity|
|
31
|
+
activity.to_h.except(:id, :project, :user, :customer).tap do |h|
|
32
|
+
h[:project_id] = activity.project.id if activity.project
|
33
|
+
h[:task_id] = activity.task.id if activity.task
|
34
|
+
end
|
35
|
+
end
|
36
|
+
client.post("activities/bulk", { activities: api_entities })
|
37
|
+
end
|
38
|
+
|
39
|
+
# Associations
|
40
|
+
# Fetches the associated Project object.
|
41
|
+
def project
|
42
|
+
# Check if the project attribute is a hash (contains ID) or already an object
|
43
|
+
project_data = attributes[:project]
|
44
|
+
return @project if defined?(@project) # Return memoized object if already fetched
|
45
|
+
|
46
|
+
@project = if project_data.is_a?(Hash) && project_data[:id]
|
47
|
+
client.projects.find(project_data[:id])
|
48
|
+
elsif project_data.is_a?(MOCO::Project) # If it was already processed into an object
|
49
|
+
project_data
|
50
|
+
else
|
51
|
+
nil # No project associated or data missing
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Fetches the associated Task object.
|
56
|
+
def task
|
57
|
+
task_data = attributes[:task]
|
58
|
+
return @task if defined?(@task)
|
59
|
+
|
60
|
+
@task = if task_data.is_a?(Hash) && task_data[:id]
|
61
|
+
client.tasks.find(task_data[:id])
|
62
|
+
elsif task_data.is_a?(MOCO::Task)
|
63
|
+
task_data
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Fetches the associated User object.
|
68
|
+
def user
|
69
|
+
user_data = attributes[:user]
|
70
|
+
return @user if defined?(@user)
|
71
|
+
|
72
|
+
@user = if user_data.is_a?(Hash) && user_data[:id]
|
73
|
+
client.users.find(user_data[:id])
|
74
|
+
elsif user_data.is_a?(MOCO::User)
|
75
|
+
user_data
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Fetches the associated Customer (Company) object.
|
80
|
+
def customer
|
81
|
+
customer_data = attributes[:customer]
|
82
|
+
return @customer if defined?(@customer)
|
83
|
+
|
84
|
+
@customer = if customer_data.is_a?(Hash) && customer_data[:id]
|
85
|
+
# Customer association points to the 'companies' collection
|
86
|
+
client.companies.find(customer_data[:id])
|
87
|
+
elsif customer_data.is_a?(MOCO::Company)
|
88
|
+
customer_data
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Access the remote_id attribute
|
93
|
+
def remote_id
|
94
|
+
attributes[:remote_id]
|
95
|
+
end
|
96
|
+
|
97
|
+
def to_s
|
98
|
+
"#{attributes[:date]} - #{attributes[:hours]}h - #{project&.name} - #{task&.name} - #{attributes[:description]}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|