dovico 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 +7 -0
- data/.dockerignore +1 -0
- data/.gitignore +4 -0
- data/.gitlab-ci.yml +15 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/Dockerfile +28 -0
- data/Gemfile +3 -0
- data/Makefile +66 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/console +6 -0
- data/bin/dovico +4 -0
- data/doc/API.md +135 -0
- data/dovico-client.gemspec +39 -0
- data/dovico.yml.example +26 -0
- data/lib/dovico.rb +9 -0
- data/lib/dovico/api_client.rb +67 -0
- data/lib/dovico/app.rb +138 -0
- data/lib/dovico/model/assignment.rb +26 -0
- data/lib/dovico/model/employee.rb +27 -0
- data/lib/dovico/model/project.rb +18 -0
- data/lib/dovico/model/task.rb +6 -0
- data/lib/dovico/model/time_entry.rb +83 -0
- data/lib/dovico/model/time_entry_generator.rb +58 -0
- data/lib/dovico/version.rb +3 -0
- data/spec/helper.rb +24 -0
- data/spec/unit/dovico/api_client_spec.rb +81 -0
- data/spec/unit/dovico/model/assignment_spec.rb +26 -0
- data/spec/unit/dovico/model/employee_spec.rb +44 -0
- data/spec/unit/dovico/model/project_spec.rb +56 -0
- data/spec/unit/dovico/model/task_spec.rb +26 -0
- data/spec/unit/dovico/model/time_entry_generator_spec.rb +86 -0
- data/spec/unit/dovico/model/time_entry_spec.rb +168 -0
- metadata +332 -0
data/dovico.yml.example
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# Personal token. Can be reset through Dovico setting page
|
2
|
+
user_token: "...."
|
3
|
+
# Your company token
|
4
|
+
client_token: "...."
|
5
|
+
assignments:
|
6
|
+
default_day:
|
7
|
+
- project_id: 1234
|
8
|
+
task_id: 100
|
9
|
+
hours: 3
|
10
|
+
- project_id: 9999
|
11
|
+
task_id: 120
|
12
|
+
hours: 2
|
13
|
+
- project_id: 4321
|
14
|
+
task_id: 424
|
15
|
+
hours: 2
|
16
|
+
# Quotes around day are mandatory
|
17
|
+
# On leave: use an empty array
|
18
|
+
'2016-01-17': []
|
19
|
+
# Specific day: redefine each tasks
|
20
|
+
'2017-12-19':
|
21
|
+
- project_id: 1234
|
22
|
+
task_id: 456
|
23
|
+
hours: 6
|
24
|
+
- project_id: 4321
|
25
|
+
task_id: 424
|
26
|
+
hours: 1
|
data/lib/dovico.rb
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
require 'dovico/version'
|
2
|
+
require 'dovico/api_client'
|
3
|
+
require 'dovico/app'
|
4
|
+
require 'dovico/model/assignment'
|
5
|
+
require 'dovico/model/time_entry_generator'
|
6
|
+
require 'dovico/model/employee'
|
7
|
+
require 'dovico/model/project'
|
8
|
+
require 'dovico/model/task'
|
9
|
+
require 'dovico/model/time_entry'
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'typhoeus'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Dovico
|
5
|
+
class ApiClient
|
6
|
+
API_URL = "https://api.dovico.com/"
|
7
|
+
API_VERSION = "5"
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def initialize!(client_token, user_token)
|
11
|
+
@client_token = client_token
|
12
|
+
@user_token = user_token
|
13
|
+
end
|
14
|
+
|
15
|
+
def get(path, params: {})
|
16
|
+
perform!(:get, path, params: params)
|
17
|
+
end
|
18
|
+
|
19
|
+
def post(path, params: {}, body: nil)
|
20
|
+
perform!(:post, path, params: params, body: body)
|
21
|
+
end
|
22
|
+
|
23
|
+
def put(path, params: {}, body: nil)
|
24
|
+
perform!(:put, path, params: params, body: body)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_accessor :client_token, :user_token
|
30
|
+
|
31
|
+
def authorization_token
|
32
|
+
"WRAP access_token=\"client=#{client_token}&user_token=#{user_token}\""
|
33
|
+
end
|
34
|
+
|
35
|
+
def request_headers
|
36
|
+
{
|
37
|
+
"Accept" => "application/json",
|
38
|
+
"Content-Type" => "application/json",
|
39
|
+
"Authorization" => authorization_token,
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
def perform!(method, path, params: {}, body: nil)
|
44
|
+
request = Typhoeus::Request.new(
|
45
|
+
"#{API_URL}#{path}",
|
46
|
+
method: method,
|
47
|
+
params: params.merge(version: API_VERSION),
|
48
|
+
headers: request_headers,
|
49
|
+
body: body,
|
50
|
+
)
|
51
|
+
|
52
|
+
response = request.run
|
53
|
+
|
54
|
+
if response.code != 200
|
55
|
+
response = JSON.parse(response.body)
|
56
|
+
puts "== Error during HTTP request =="
|
57
|
+
puts "Status: #{response["Status"]}"
|
58
|
+
puts "Description: #{response["Description"]}"
|
59
|
+
puts ""
|
60
|
+
raise "Error during HTTP request"
|
61
|
+
end
|
62
|
+
|
63
|
+
JSON.parse(response.body)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/dovico/app.rb
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'easy_app_helper'
|
2
|
+
|
3
|
+
module Dovico
|
4
|
+
class App
|
5
|
+
include EasyAppHelper
|
6
|
+
|
7
|
+
NAME = 'Dovico Simple Client'
|
8
|
+
DESCRIPTION = <<EOL
|
9
|
+
Simple client for Dovico TimeSheet web application.
|
10
|
+
|
11
|
+
Please refer to the README.md page to setup your client.
|
12
|
+
|
13
|
+
WARNING: --auto and --simulate options are not effective.
|
14
|
+
EOL
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
config.config_file_base_name = 'dovico'
|
18
|
+
config.describes_application app_name: NAME,
|
19
|
+
app_version: Dovico::VERSION,
|
20
|
+
app_description: DESCRIPTION
|
21
|
+
add_script_options
|
22
|
+
|
23
|
+
ApiClient.initialize!(config["client_token"], config["user_token"])
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_script_options
|
27
|
+
config.add_command_line_section('Display informations') do |slop|
|
28
|
+
slop.on :myself, 'Display info on yourself', argument: false
|
29
|
+
slop.on :tasks, 'Display info on tasks', argument: false
|
30
|
+
end
|
31
|
+
config.add_command_line_section('Fill the timesheets') do |slop|
|
32
|
+
slop.on :fill, 'Fill the timesheet', argument: false
|
33
|
+
end
|
34
|
+
config.add_command_line_section('Submit the timesheets') do |slop|
|
35
|
+
slop.on :submit, 'Submit timesheets', argument: false
|
36
|
+
end
|
37
|
+
config.add_command_line_section('Date options (for --fill and --submit)') do |slop|
|
38
|
+
slop.on :current_week, 'Current week', argument: false
|
39
|
+
slop.on :today, 'Current day', argument: false
|
40
|
+
slop.on :day, 'Specific day', argument: true
|
41
|
+
slop.on :week, 'Specific "commercial" week. See https://www.epochconverter.com/weeks/', argument: true, as: Integer
|
42
|
+
slop.on :year, '[optional] Specifiy year (for --week option), default current year', argument: true, as: Integer
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def run
|
47
|
+
if config[:help] || !(config[:myself] || config[:tasks] || config[:fill] || config[:submit])
|
48
|
+
display_help
|
49
|
+
exit 0
|
50
|
+
end
|
51
|
+
|
52
|
+
if config[:myself]
|
53
|
+
display_myself
|
54
|
+
end
|
55
|
+
|
56
|
+
if config[:tasks]
|
57
|
+
display_tasks
|
58
|
+
end
|
59
|
+
|
60
|
+
if config[:fill] || config[:submit]
|
61
|
+
start_date, end_date = parse_date_options
|
62
|
+
|
63
|
+
time_entry_generator = TimeEntryGenerator.new(
|
64
|
+
assignments: config["assignments"],
|
65
|
+
employee_id: myself.id,
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
if config[:fill]
|
70
|
+
time_entries = time_entry_generator.generate(start_date, end_date)
|
71
|
+
|
72
|
+
saved_time_entries = TimeEntry.batch_create!(time_entries)
|
73
|
+
puts "#{saved_time_entries["TimeEntries"].count} Entries created between #{start_date} and #{end_date}"
|
74
|
+
end
|
75
|
+
|
76
|
+
if config[:submit]
|
77
|
+
submitted_time_entries = TimeEntry.submit!(myself.id, start_date, end_date)
|
78
|
+
puts "#{submitted_time_entries["TimeEntries"].count} Entries submitted between #{start_date} and #{end_date}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def myself
|
85
|
+
@myself ||= Employee.myself
|
86
|
+
end
|
87
|
+
|
88
|
+
def display_myself
|
89
|
+
puts "Informations about yourself"
|
90
|
+
puts " - ID: #{myself.id}"
|
91
|
+
puts " - First Name: #{myself.first_name}"
|
92
|
+
puts " - Last Name: #{myself.last_name}"
|
93
|
+
puts ""
|
94
|
+
end
|
95
|
+
|
96
|
+
def display_tasks
|
97
|
+
projects = Project.all
|
98
|
+
|
99
|
+
puts "== List of available projects =="
|
100
|
+
puts " Project | Task | Description"
|
101
|
+
projects.each do |project|
|
102
|
+
if project.tasks.count > 0
|
103
|
+
project.tasks.each do |task|
|
104
|
+
puts sprintf ' %7d | %4d | %s: %s', project.id, task.id, project.name, task.name
|
105
|
+
end
|
106
|
+
else
|
107
|
+
puts sprintf " %7d | | %s (No tasks linked)", project.id, task.name
|
108
|
+
end
|
109
|
+
end
|
110
|
+
puts ""
|
111
|
+
end
|
112
|
+
|
113
|
+
def parse_date_options
|
114
|
+
if config[:week]
|
115
|
+
year = config[:year] || Date.current.year
|
116
|
+
start_date = Date.commercial(year, config[:week]).beginning_of_week
|
117
|
+
end_date = start_date.advance(days: 4)
|
118
|
+
elsif config[:current_week]
|
119
|
+
start_date = Date.current.beginning_of_week
|
120
|
+
end_date = start_date.advance(days: 4)
|
121
|
+
elsif config[:day]
|
122
|
+
start_date = end_date = Date.parse(config[:day])
|
123
|
+
elsif config[:today]
|
124
|
+
start_date = end_date = Date.current
|
125
|
+
else
|
126
|
+
puts "Error : You must precise one date options"
|
127
|
+
display_help
|
128
|
+
exit 1
|
129
|
+
end
|
130
|
+
|
131
|
+
[start_date, end_date]
|
132
|
+
end
|
133
|
+
|
134
|
+
def display_help
|
135
|
+
puts config.command_line_help
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'active_attr'
|
2
|
+
|
3
|
+
module Dovico
|
4
|
+
class Assignment
|
5
|
+
URL_PATH = 'Assignments/'
|
6
|
+
|
7
|
+
include ActiveAttr::Model
|
8
|
+
|
9
|
+
attribute :id
|
10
|
+
attribute :assignement_id
|
11
|
+
attribute :name
|
12
|
+
attribute :start_date
|
13
|
+
attribute :finish_date
|
14
|
+
|
15
|
+
|
16
|
+
def self.parse(hash)
|
17
|
+
self.new(
|
18
|
+
id: hash["ItemID"],
|
19
|
+
assignement_id: hash["AssignmentID"],
|
20
|
+
name: hash["Name"],
|
21
|
+
start_date: hash["StartDate"],
|
22
|
+
finish_date: hash["FinishDate"]
|
23
|
+
)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'active_attr'
|
2
|
+
|
3
|
+
module Dovico
|
4
|
+
class Employee
|
5
|
+
URL_PATH = 'Employees/'
|
6
|
+
|
7
|
+
include ActiveAttr::Model
|
8
|
+
|
9
|
+
attribute :id
|
10
|
+
attribute :first_name
|
11
|
+
attribute :last_name
|
12
|
+
|
13
|
+
def self.parse(hash)
|
14
|
+
Employee.new(
|
15
|
+
id: hash["ID"],
|
16
|
+
first_name: hash["FirstName"],
|
17
|
+
last_name: hash["LastName"],
|
18
|
+
)
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.myself
|
22
|
+
employees = ApiClient.get("#{URL_PATH}/Me")
|
23
|
+
|
24
|
+
parse(employees["Employees"].first)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'active_attr'
|
2
|
+
|
3
|
+
module Dovico
|
4
|
+
class Project < Assignment
|
5
|
+
|
6
|
+
attribute :tasks
|
7
|
+
|
8
|
+
def self.all
|
9
|
+
projects_search = ApiClient.get(URL_PATH)
|
10
|
+
projects = projects_search["Assignments"].map {|project_hash| parse(project_hash) }
|
11
|
+
|
12
|
+
projects.each do |project|
|
13
|
+
tasks_search = ApiClient.get("#{URL_PATH}#{project.assignement_id}")
|
14
|
+
project.tasks = tasks_search["Assignments"].map {|task_hash| Task.parse(task_hash) }
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'active_attr'
|
2
|
+
|
3
|
+
module Dovico
|
4
|
+
class TimeEntry
|
5
|
+
URL_PATH = 'TimeEntries/'
|
6
|
+
|
7
|
+
include ActiveAttr::Model
|
8
|
+
|
9
|
+
attribute :id
|
10
|
+
attribute :start_time
|
11
|
+
attribute :stop_time
|
12
|
+
attribute :project_id
|
13
|
+
attribute :task_id
|
14
|
+
attribute :employee_id
|
15
|
+
attribute :date
|
16
|
+
attribute :total_hours
|
17
|
+
attribute :description
|
18
|
+
|
19
|
+
def self.parse(hash)
|
20
|
+
TimeEntry.new(
|
21
|
+
id: parse_id(hash['ID']),
|
22
|
+
start_time: hash['StartTime'],
|
23
|
+
stop_time: hash['StopTime'],
|
24
|
+
project_id: hash['Project']['ID'],
|
25
|
+
task_id: hash['Task']['ID'],
|
26
|
+
employee_id:hash['Employee']['ID'],
|
27
|
+
date: hash['Date'],
|
28
|
+
total_hours:hash['TotalHours'],
|
29
|
+
description:hash['Description']
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.get(id)
|
34
|
+
entry = ApiClient.get("#{URL_PATH}/#{id}")["TimeEntries"].first
|
35
|
+
TimeEntry.parse(entry)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.batch_create!(assignments)
|
39
|
+
api_assignements = assignments.map(&:to_api)
|
40
|
+
ApiClient.post(URL_PATH, body: api_assignements.to_json)
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.submit!(employee_id, start_date, end_date)
|
44
|
+
ApiClient.post(
|
45
|
+
"#{URL_PATH}/Employee/#{employee_id}/Submit",
|
46
|
+
params: {
|
47
|
+
daterange: "#{start_date} #{end_date}"
|
48
|
+
},
|
49
|
+
body: {}.to_json,
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def create!
|
54
|
+
ApiClient.post(URL_PATH, body: [to_api].to_json)
|
55
|
+
end
|
56
|
+
|
57
|
+
def update!
|
58
|
+
ApiClient.put(URL_PATH, body: [to_api].to_json)
|
59
|
+
end
|
60
|
+
|
61
|
+
def to_api
|
62
|
+
{
|
63
|
+
"ID": id,
|
64
|
+
"StartTime": start_time,
|
65
|
+
"StopTime": stop_time,
|
66
|
+
"ProjectID": project_id.to_s,
|
67
|
+
"TaskID": task_id.to_s,
|
68
|
+
"EmployeeID": employee_id.to_s,
|
69
|
+
"Date": date,
|
70
|
+
"TotalHours": total_hours.to_s,
|
71
|
+
"Description": description,
|
72
|
+
}.compact.stringify_keys
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def self.parse_id(long_id)
|
78
|
+
# T: ID is a GUID, non-approved
|
79
|
+
# M: ID is a long, approved
|
80
|
+
long_id.sub(/^(T|M)/, "")
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'active_attr'
|
2
|
+
|
3
|
+
module Dovico
|
4
|
+
class TimeEntryGenerator
|
5
|
+
DEFAULT_START_HOUR = 9
|
6
|
+
|
7
|
+
def initialize(assignments:, employee_id:)
|
8
|
+
@assignments = assignments
|
9
|
+
@employee_id = employee_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate(start_date, end_date)
|
13
|
+
start_date.upto(end_date).flat_map do |day|
|
14
|
+
build_day_time_entries(day)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
attr_accessor :assignments, :employee_id
|
20
|
+
|
21
|
+
def build_day_time_entries(day)
|
22
|
+
time_entries = []
|
23
|
+
start_date = Time.parse(day.to_s).advance(hours: DEFAULT_START_HOUR)
|
24
|
+
|
25
|
+
day_assignments = day_assignments(day)
|
26
|
+
day_assignments.each do |assignment|
|
27
|
+
time_entry = build_time_entry(assignment, start_date)
|
28
|
+
time_entries << time_entry
|
29
|
+
|
30
|
+
start_date = start_date.advance(hours: time_entry.total_hours.to_f)
|
31
|
+
end
|
32
|
+
time_entries
|
33
|
+
end
|
34
|
+
|
35
|
+
def build_time_entry(assignment, start_date)
|
36
|
+
project_id = assignment["project_id"]
|
37
|
+
task_id = assignment["task_id"]
|
38
|
+
hours = assignment["hours"]
|
39
|
+
stop_date = start_date.advance(hours: hours)
|
40
|
+
start_time = sprintf "%02d%02d", start_date.hour, start_date.min
|
41
|
+
stop_time = sprintf "%02d%02d", stop_date.hour, stop_date.min
|
42
|
+
|
43
|
+
TimeEntry.new(
|
44
|
+
employee_id: employee_id,
|
45
|
+
project_id: project_id,
|
46
|
+
task_id: task_id,
|
47
|
+
date: start_date.to_date.to_s,
|
48
|
+
total_hours: hours,
|
49
|
+
start_time: start_time,
|
50
|
+
stop_time: stop_time,
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
def day_assignments(date)
|
55
|
+
assignments[date.to_s] || assignments["default_day"]
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|