dovico 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|