Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement cache and TaskGroup #23

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
- 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
- Implement "Client" item

# Version 1.4.0
- Fix documentation: Specific months, weeks or days use `special_` prefix. Fixes trainline-eu/dovico#15
Expand Down
7 changes: 6 additions & 1 deletion 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 Expand Up @@ -162,4 +167,4 @@ yes
You are warmly welcome to contribute to the project!

# Dovico API Documentation
* http://apideveloper.dovico.com/
* https://www.dovico.com/developer/API_doc/
11 changes: 9 additions & 2 deletions lib/dovico.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
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/time_entry_formatter'
require 'dovico/model/time_entry_generator'
require 'dovico/model/assignments'
require 'dovico/model/client'
require 'dovico/model/employee'
require 'dovico/model/project'
require 'dovico/model/task'
require 'dovico/model/task_group'
require 'dovico/model/time_entry'
require 'dovico/model/time_entry_formatter'
require 'dovico/model/time_entry_generator'
19 changes: 12 additions & 7 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,8 +101,12 @@ def run

private

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

def myself
@myself ||= Employee.myself
assignments.myself
end

def display_myself
Expand All @@ -112,20 +117,20 @@ 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)
time_entries = TimeEntry.search(start_date, end_date)
formatter = TimeEntryFormatter.new(assignments)
time_entries = TimeEntry.search(myself.id, start_date, end_date)
puts formatter.format_entries(time_entries)
puts ""
end

def clear_time_entries(start_date, end_date)
time_entries = TimeEntry.search(start_date, end_date)
time_entries = TimeEntry.search(myself.id, start_date, end_date)
if highline.agree("• #{time_entries.count} Time Entries to be deleted. Are you sure? (yes/no)")
time_entries.each do |time_entry|
time_entry.delete!
Expand Down
98 changes: 92 additions & 6 deletions lib/dovico/model/assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,106 @@ 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
when 'C'
Client
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 'fileutils'

module Dovico
class Assignments
class CacheError < RuntimeError; end

attr_reader :assignments, :myself

CACHE_FILE = "#{APP_DIRECTORY}/assignments.json"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File.join(APP_DIRECTORY, 'assgnments.json') will be more portable I think

CACHE_VERSION = "2"

def initialize(force_refresh: false)
if !force_refresh
load_from_cache!
end

@assignments ||= Assignment.fetch_all
@myself ||= Employee.myself

save_to_cache!
end

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 load_from_cache!
raise CacheError.new("Cache does not exist") unless File.exists?(CACHE_FILE)

json = JSON.parse(File.read(CACHE_FILE))
raise CacheError.new("Cache version mismatch") unless json["version"] == CACHE_VERSION

@assignments = Assignment.unserialize(json["assignments"]) unless json["assignments"].nil?
@myself = Employee.unserialize(json["myself"]) unless json["myself"].nil?
rescue JSON::ParserError, CacheError => e
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 wdyt of logging something to stderr on CacheError exceptions?

end

def save_to_cache!
cache = {
version: CACHE_VERSION,
assignments: @assignments,
myself: @myself,
}

FileUtils.mkdir_p(APP_DIRECTORY)
File.write(CACHE_FILE, cache.to_json)
end
end
end
4 changes: 4 additions & 0 deletions lib/dovico/model/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module Dovico
class Client < Assignment
end
end
8 changes: 5 additions & 3 deletions lib/dovico/model/employee.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
require 'active_attr'

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

include ActiveAttr::Model
Expand All @@ -24,6 +22,10 @@ def self.myself
parse(employees["Employees"].first)
end

def self.unserialize(employee_hash)
Employee.new(employee_hash)
end

def to_s
%{ - ID: #{id}
- First Name: #{first_name}
Expand Down
45 changes: 0 additions & 45 deletions lib/dovico/model/project.rb
Original file line number Diff line number Diff line change
@@ -1,49 +1,4 @@
require 'active_attr'

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
2 changes: 0 additions & 2 deletions lib/dovico/model/task.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
require 'active_attr'

module Dovico
class Task < Assignment
end
Expand Down
4 changes: 4 additions & 0 deletions lib/dovico/model/task_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module Dovico
class TaskGroup < Assignment
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May it make sense to group various assignment types in Dovico::Assignment:: ?
Like Dovico::Assignment::TaskGroup, Dovico::Assignment::Task ?

module Dovico
  class Client < Assignment

is a bit confusing, I'm thinking rather of an API client then of an assignment

end
end
6 changes: 3 additions & 3 deletions lib/dovico/model/time_entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ def self.get(id)
TimeEntry.parse(entry)
end

def self.search(start_date, end_date)
def self.search(employee_id, start_date, end_date)
api_response = ApiClient.get_paginated_list(
URL_PATH,
"#{URL_PATH}/Employee/#{employee_id}/",
"TimeEntries",
params: {
daterange: "#{start_date} #{end_date}"
Expand All @@ -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
Loading