Skip to content

Commit

Permalink
Improve management of list of Assignments
Browse files Browse the repository at this point in the history
- Implement a cache so we don't have to fetch all tasks on every call
- Browse Assigments recursively so we handle TaskGroup (and any other
forms of tree if it's ever allowed by Dovico)
- Improve formatter so we have a nicer display per day
  • Loading branch information
Théophile Helleboid committed Nov 4, 2019
1 parent 58408d9 commit dce51f7
Show file tree
Hide file tree
Showing 18 changed files with 597 additions and 160 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
- Set minimal versions of gems explicitly
- Update minimal rubocop gem version (fix CVE-2017-8418)
- Implement pagination for Assignments (Projets and Tasks) and Time Entries
- Implement a cache so tasks are saved locally
- Browse Assignments recursively so we handle TaskGroup (and any other forms of tree if it's ever allowed by Dovico)
- Improve formatter so we have a nicer display for each day

# Version 1.4.0
- Fix documentation: Specific months, weeks or days use `special_` prefix. Fixes trainline-eu/dovico#15
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ Project | Task | Description
1600 | 100 | Frodo Project: Go home
~~~

## Force tasks refresh
`dovico --force-refresh --tasks`

It will force the application to download a fresh list of assignments from Dovico and save it locally.

## Fill the timesheet
`dovico --fill [date options]`

Expand Down
6 changes: 6 additions & 0 deletions lib/dovico.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
require 'active_support'
require 'active_support/core_ext/hash'

module Dovico
APP_DIRECTORY = "#{Dir.home}/.dovico/"
end

require 'dovico/version'
require 'dovico/api_client'
require 'dovico/app'
require 'dovico/config_parser'
require 'dovico/model/assignment'
require 'dovico/model/assignments'
require 'dovico/model/time_entry_formatter'
require 'dovico/model/time_entry_generator'
require 'dovico/model/employee'
require 'dovico/model/project'
require 'dovico/model/task'
require 'dovico/model/task_group'
require 'dovico/model/time_entry'
13 changes: 9 additions & 4 deletions lib/dovico/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ def initialize

def add_script_options
config.add_command_line_section('Display informations') do |slop|
slop.on :myself, 'Display info on yourself', argument: false
slop.on :tasks, 'Display info on tasks', argument: false
slop.on :myself, 'Display info on yourself', argument: false
slop.on :tasks, 'Display info on tasks', argument: false
slop.on :force_refresh, 'Force refresh of tasks list', argument: false
end
config.add_command_line_section('Fill the timesheets') do |slop|
slop.on :fill, 'Fill the timesheet', argument: false
Expand Down Expand Up @@ -100,6 +101,10 @@ def run

private

def assignments
@assignments ||= Assignments.load(force_refresh: config[:force_refresh])
end

def myself
@myself ||= Employee.myself
end
Expand All @@ -112,13 +117,13 @@ def display_myself

def display_tasks
puts "== List of available projects =="
puts Project.format_all
puts assignments.format_tree
puts ""
end

def display_time_entries(start_date, end_date)
puts "== List of Time Entries between #{start_date} and #{end_date} =="
formatter = TimeEntryFormatter.new(Project.all)
formatter = TimeEntryFormatter.new(assignments)
time_entries = TimeEntry.search(start_date, end_date)
puts formatter.format_entries(time_entries)
puts ""
Expand Down
96 changes: 90 additions & 6 deletions lib/dovico/model/assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,104 @@ class Assignment

include ActiveAttr::Model

attribute :assignments

attribute :id
attribute :assignement_id
attribute :get_assignments_uri
attribute :name
attribute :start_date
attribute :finish_date

def self.unserialize(assignments_hash)
assignments_hash.map do |assignment_hash|
assignments_array = assignment_hash.delete("assignments")

assignment = class_for(assignment_hash["assignement_id"].chr).new(assignment_hash)
assignment.assignments = unserialize(assignments_array)

assignment
end.sort_by(&:id)
end

def self.parse(hash)
self.new(
id: hash["ItemID"],
assignement_id: hash["AssignmentID"],
name: hash["Name"],
start_date: hash["StartDate"],
finish_date: hash["FinishDate"]
assignement_id = hash["AssignmentID"]

class_for(assignement_id).new(
id: hash["ItemID"],
assignement_id: assignement_id,
get_assignments_uri: hash["GetAssignmentsURI"],
name: hash["Name"],
start_date: hash["StartDate"],
finish_date: hash["FinishDate"],
assignments: [],
)
end

def self.class_for(assignement_id)
case assignement_id.chr
when 'P'
Project
when 'G'
TaskGroup
when 'T'
Task
else
raise "AssignmentID #{assignement_id} unsupported"
end
end
private_class_method :class_for

def self.fetch_assignments(assignments_path)
assignments_list = ApiClient.get_paginated_list(assignments_path, "Assignments")

assignments = assignments_list["Assignments"].map do |assignment_hash|
assignment = parse(assignment_hash)

if assignment.get_assignments_uri.present? &&
assignment.get_assignments_uri != "N/A" &&
!assignment.is_a?(Task) # Task has EmployeeAssignment as assignements that are not useful to fetch

assignment.assignments = fetch_assignments(assignment.get_assignments_uri)
end

assignment
end

assignments.sort_by(&:id)
end
private_class_method :fetch_assignments

def self.fetch_all
fetch_assignments(URL_PATH)
end

def find_object(klass, object_id)
if self.is_a?(klass) && id == object_id
self
elsif assignments.present?
assignments.find do |assignment|
object = assignment.find_object(klass, object_id)

break object if object.present?
end
end
end

def to_s(depth = 0)
string = " " * depth*2 + "#{type} ##{id} #{name}"

if assignments.present?
string += "\n#{assignments.map {|a| a.to_s(depth + 1) }.join("\n")}\n"
end

string
end

private
def type
self.class.name.demodulize
end

end
end
62 changes: 62 additions & 0 deletions lib/dovico/model/assignments.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
require 'active_attr'

module Dovico
class Assignments
include ActiveAttr::Model

attribute :assignments

CACHE_FILE = "#{APP_DIRECTORY}/assignments.json"
CACHE_VERSION = "2"

def initialize(**params)
super(**params)
save_to_cache
end

def self.load(force_refresh: false)
if !force_refresh
assignments = load_from_cache
end
assignments ||= Assignment.fetch_all

new(assignments: assignments)
end

def self.load_from_cache
if File.exists?(CACHE_FILE) && json = JSON.parse(File.read(CACHE_FILE))
if json["version"] == CACHE_VERSION
Assignment.unserialize(json["assignments"])
end
end
rescue JSON::ParserError => e
end
private_class_method :load_from_cache

def format_tree
assignments.map(&:to_s).join("\n")
end

def find_project_task(project_id, task_id)
project = assignments.find {|assignment| assignment.find_object(Project, project_id) }
task = project.find_object(Task, task_id)

if project.nil? || task.nil?
raise "Can't find project##{project_id}/task##{task_id}. Try with --force-refresh option"
end

[project, task]
end

private
def save_to_cache
cache = {
version: CACHE_VERSION,
assignments: assignments,
}

FileUtils.mkdir_p(APP_DIRECTORY)
File.write(CACHE_FILE, cache.to_json)
end
end
end
2 changes: 1 addition & 1 deletion lib/dovico/model/employee.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require 'active_attr'

module Dovico
class Employee
class Employee < Assignment
URL_PATH = 'Employees'

include ActiveAttr::Model
Expand Down
43 changes: 0 additions & 43 deletions lib/dovico/model/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,5 @@

module Dovico
class Project < Assignment

attribute :tasks

def self.parse(hash)
project = super(hash)
project.tasks ||= []
project
end

def self.all
projects_search = ApiClient.get_paginated_list(URL_PATH, "Assignments")
projects = projects_search["Assignments"].map {|project_hash| parse(project_hash) }

projects.each do |project|
tasks_search = ApiClient.get_paginated_list("#{URL_PATH}/#{project.assignement_id}", "Assignments")
tasks = tasks_search["Assignments"].map {|task_hash| Task.parse(task_hash) }

project.tasks = tasks.sort_by do |task|
task.id
end
end

projects
end

def self.format_all
text = " Project | Task | Description\n"
text += all.map(&:to_s).join("\n")
end

def to_s
text = ''

if tasks.count > 0
text += tasks.map do |task|
sprintf " %7d | %4d | %s: %s", id, task.id, name, task.name
end.join("\n")
else
text += sprintf " %7d | | %s (No tasks linked)", id, name
end

text
end
end
end
6 changes: 6 additions & 0 deletions lib/dovico/model/task_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
require 'active_attr'

module Dovico
class TaskGroup < Assignment
end
end
2 changes: 1 addition & 1 deletion lib/dovico/model/time_entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def self.search(start_date, end_date)

api_response["TimeEntries"].map do |time_entry|
TimeEntry.parse(time_entry)
end
end.sort_by(&:date)
end

def self.batch_create!(assignments)
Expand Down
43 changes: 22 additions & 21 deletions lib/dovico/model/time_entry_formatter.rb
Original file line number Diff line number Diff line change
@@ -1,41 +1,42 @@
module Dovico
class TimeEntryFormatter

def initialize(projects)
@projects = projects
def initialize(assignments)
@assignments = assignments
end

def format_entries(time_entries)
text = ""
time_entries.map do |time_entry|
text += "#{}"
time_entry_text(time_entry)
end.join("\n")
time_entries.group_by(&:date).map do |date, day_time_entries|
hours = 0

day_time_entries.map do |time_entry|
string = "#{date} #{progress_bar(hours, time_entry.total_hours)} #{time_entry_text(time_entry)}"
hours += time_entry.total_hours.to_f

string
end
end.flatten.join("\n")
end

private
attr_accessor :projects
attr_accessor :assignments

def progress_bar(shift, total_hours)
progress_bar_width = (total_hours.to_f * 2).to_i
sprintf(
"[%- 14s]", " " * shift * 2 + "×" * progress_bar_width
)
end

def time_entry_text(time_entry)
project, task = project_task(time_entry.project_id, time_entry.task_id)
project, task = assignments.find_project_task(time_entry.project_id, time_entry.task_id)

progress_bar_width = (time_entry.total_hours.to_f * 2).to_i
sprintf("%s [%s] %s : [%8s] %2sh %s %s",
time_entry.date,
"×" * progress_bar_width,
" " * [16 - progress_bar_width, 0].max,
sprintf("[%-12s] %2sh %s %s",
time_entry.formal_sheet_status,
time_entry.total_hours,
project.name,
task.name,
)
end

def project_task(project_id, task_id)
project = projects.select{ |project| project.id == project_id }.first
task = project.tasks.select{ |task| task.id == task_id }.first

[project, task]
end
end
end
Loading

0 comments on commit dce51f7

Please sign in to comment.