rtt 0.0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +22 -0
- data/Manifest +28 -0
- data/README.rdoc +78 -0
- data/Rakefile +16 -0
- data/USAGE.txt +13 -0
- data/bin/rtt +8 -0
- data/db/rtt.sqlite3 +0 -0
- data/db/test.sqlite3 +0 -0
- data/init.sh +5 -0
- data/lib/rtt.rb +137 -0
- data/lib/rtt/client.rb +25 -0
- data/lib/rtt/cmd_line_interpreter.rb +137 -0
- data/lib/rtt/hash_extensions.rb +86 -0
- data/lib/rtt/project.rb +35 -0
- data/lib/rtt/query_builder.rb +22 -0
- data/lib/rtt/report_generator.rb +190 -0
- data/lib/rtt/storage.rb +19 -0
- data/lib/rtt/task.rb +136 -0
- data/lib/rtt/user.rb +54 -0
- data/lib/rtt/user_configurator.rb +24 -0
- data/rtt.gemspec +50 -0
- data/rtt_report +401 -0
- data/spec/datamapper_spec_helper.rb +1 -0
- data/spec/lib/rtt/task_spec.rb +106 -0
- data/spec/lib/rtt_spec.rb +186 -0
- data/tasks/rtt.rake +66 -0
- data/todo.txt +3 -0
- metadata +207 -0
@@ -0,0 +1,86 @@
|
|
1
|
+
|
2
|
+
# Hash#deep_merge
|
3
|
+
# From: http://pastie.textmate.org/pastes/30372, Elliott Hird
|
4
|
+
# Source: http://gemjack.com/gems/tartan-0.1.1/classes/Hash.html
|
5
|
+
# This file contains extensions to Ruby and other useful snippits of code.
|
6
|
+
# Time to extend Hash with some recursive merging magic.
|
7
|
+
|
8
|
+
|
9
|
+
class Hash
|
10
|
+
|
11
|
+
# Merges self with another hash, recursively.
|
12
|
+
#
|
13
|
+
# This code was lovingly stolen from some random gem:
|
14
|
+
# http://gemjack.com/gems/tartan-0.1.1/classes/Hash.html
|
15
|
+
#
|
16
|
+
# Thanks to whoever made it.
|
17
|
+
|
18
|
+
def deep_merge(hash)
|
19
|
+
target = dup
|
20
|
+
|
21
|
+
hash.keys.each do |key|
|
22
|
+
if hash[key].is_a? Hash and self[key].is_a? Hash
|
23
|
+
target[key] = target[key].deep_merge(hash[key])
|
24
|
+
next
|
25
|
+
end
|
26
|
+
|
27
|
+
target[key] = hash[key]
|
28
|
+
end
|
29
|
+
|
30
|
+
target
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
# From: http://www.gemtacular.com/gemdocs/cerberus-0.2.2/doc/classes/Hash.html
|
35
|
+
# File lib/cerberus/utils.rb, line 42
|
36
|
+
|
37
|
+
def deep_merge!(second)
|
38
|
+
second.each_pair do |k,v|
|
39
|
+
if self[k].is_a?(Hash) and second[k].is_a?(Hash)
|
40
|
+
self[k].deep_merge!(second[k])
|
41
|
+
else
|
42
|
+
self[k] = second[k]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
#-----------------
|
49
|
+
|
50
|
+
# cf. http://subtech.g.hatena.ne.jp/cho45/20061122
|
51
|
+
def deep_merge2(other)
|
52
|
+
deep_proc = Proc.new { |k, s, o|
|
53
|
+
if s.kind_of?(Hash) && o.kind_of?(Hash)
|
54
|
+
next s.merge(o, &deep_proc)
|
55
|
+
end
|
56
|
+
next o
|
57
|
+
}
|
58
|
+
merge(other, &deep_proc)
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
def deep_merge3(second)
|
63
|
+
|
64
|
+
# From: http://www.ruby-forum.com/topic/142809
|
65
|
+
# Author: Stefan Rusterholz
|
66
|
+
|
67
|
+
merger = proc { |key,v1,v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
|
68
|
+
self.merge(second, &merger)
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
|
73
|
+
def keep_merge(hash)
|
74
|
+
target = dup
|
75
|
+
hash.keys.each do |key|
|
76
|
+
if hash[key].is_a? Hash and self[key].is_a? Hash
|
77
|
+
target[key] = target[key].keep_merge(hash[key])
|
78
|
+
next
|
79
|
+
end
|
80
|
+
#target[key] = hash[key]
|
81
|
+
target.update(hash) { |key, *values| values.flatten.uniq }
|
82
|
+
end
|
83
|
+
target
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
data/lib/rtt/project.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
module Rtt
|
3
|
+
class Project
|
4
|
+
include DataMapper::Resource
|
5
|
+
|
6
|
+
DEFAULT_NAME = 'default'
|
7
|
+
DEFAULT_DESCRIPTION = 'Default Project'
|
8
|
+
|
9
|
+
property :id, Serial
|
10
|
+
property :name, String, :required => true, :unique => true, :default => DEFAULT_NAME
|
11
|
+
property :description, String, :default => DEFAULT_DESCRIPTION
|
12
|
+
property :active, Boolean, :default => false
|
13
|
+
|
14
|
+
has n, :tasks #, :through => Resource
|
15
|
+
has n, :users, :through => :tasks
|
16
|
+
belongs_to :client
|
17
|
+
|
18
|
+
before :valid?, :set_default_client
|
19
|
+
|
20
|
+
def self.default
|
21
|
+
first_or_create :active => true
|
22
|
+
end
|
23
|
+
|
24
|
+
def activate_with_client(client)
|
25
|
+
self.client = client
|
26
|
+
self.active = true
|
27
|
+
self.save
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
def set_default_client
|
32
|
+
self.client = Client.default if self.client.nil?
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
module Rtt
|
3
|
+
module QueryBuilder
|
4
|
+
# Query among all tasks filtering based on parameters.
|
5
|
+
#
|
6
|
+
#
|
7
|
+
def query options = {}
|
8
|
+
Task.all(rtt_build_conditions(options))
|
9
|
+
end
|
10
|
+
|
11
|
+
#private
|
12
|
+
|
13
|
+
def rtt_build_conditions options
|
14
|
+
conditions = {}
|
15
|
+
conditions[:start_at.gte] = Date.parse(options[:from]) if options[:from]
|
16
|
+
conditions[:end_at.lte] = Date.parse(options[:to]) if options[:to]
|
17
|
+
conditions[:project] = { :name => options[:project] } if options[:project]
|
18
|
+
conditions.deep_merge!({ :project => { :client => { :name => options[:client] } }}) if options[:client]
|
19
|
+
conditions
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,190 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
module Rtt
|
3
|
+
module ReportGenerator
|
4
|
+
|
5
|
+
attr_accessor :data, :different_fixed
|
6
|
+
|
7
|
+
DEFAULT_FILENAME = 'rtt_report'
|
8
|
+
FORMATS_ACCEPTED = [ :csv, :pdf ]
|
9
|
+
REPORT_FIELDS = %w(Client Project Name Date Duration)
|
10
|
+
FIXED_FIELDS = %w(Client Project)
|
11
|
+
REPORT_FIELD_OUTPUT = {
|
12
|
+
'Client' => Proc.new { |task| task.client.name },
|
13
|
+
'Project' => Proc.new { |task| task.project.name },
|
14
|
+
'Name' => Proc.new { |task| task.name },
|
15
|
+
'Date' => Proc.new { |task| task.date.strftime('%m-%d-%y') },
|
16
|
+
'Duration' => Proc.new { |task| task.duration }
|
17
|
+
}
|
18
|
+
|
19
|
+
def fill_user_information(pdf)
|
20
|
+
pdf.cell [330, 790],
|
21
|
+
:text => current_user.full_name_and_nickname,
|
22
|
+
:width => 225, :padding => 10, :border_width => 0, :align => :right
|
23
|
+
pdf.cell [330, 770],
|
24
|
+
:text => current_user.company,
|
25
|
+
:width => 225, :padding => 10, :border_width => 0, :align => :right
|
26
|
+
pdf.cell [330, 750],
|
27
|
+
:text => current_user.location,
|
28
|
+
:width => 225, :padding => 10, :border_width => 0, :align => :right
|
29
|
+
pdf.cell [330, 730],
|
30
|
+
:text => current_user.address,
|
31
|
+
:width => 225, :padding => 10, :border_width => 0, :align => :right
|
32
|
+
pdf.cell [330, 710],
|
33
|
+
:text => current_user.phone,
|
34
|
+
:width => 225, :padding => 10, :border_width => 0, :align => :right
|
35
|
+
pdf.cell [330, 690],
|
36
|
+
:text => current_user.email,
|
37
|
+
:width => 225, :padding => 10, :border_width => 0, :align => :right
|
38
|
+
pdf.cell [330, 670],
|
39
|
+
:text => current_user.site,
|
40
|
+
:width => 225, :padding => 10, :border_width => 0, :align => :right
|
41
|
+
end
|
42
|
+
|
43
|
+
def fixed_fields_for_current_data
|
44
|
+
@fixed_fields_for_current_data ||= begin
|
45
|
+
calculate_fixed_fields_based_on_data
|
46
|
+
@data[:fixed_fields].keys + @different_fixed.keys.reject { |key| @different_fixed[key].length > 1 }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def fixed_value(field)
|
51
|
+
if @data[:fixed_fields].include? field
|
52
|
+
@data[:fixed_fields][field]
|
53
|
+
else
|
54
|
+
@different_fixed[field].first
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
#
|
59
|
+
#
|
60
|
+
def report options = {}
|
61
|
+
raise 'Argument must be a valid Hash. Checkout: rtt usage' unless options.is_a?(Hash) || options.keys.empty?
|
62
|
+
@different_fixed ||= FIXED_FIELDS.inject({}) { |result, key| result[key] = []; result }
|
63
|
+
extension = options.keys.select { |key| FORMATS_ACCEPTED.include?(key) }.first
|
64
|
+
path = options[extension]
|
65
|
+
fixed_fields = extract_fixed_fields(options)
|
66
|
+
fixed_fields_and_values = fixed_fields.inject({}) { |hash, key| hash[key] = options[key.downcase.to_sym]; hash }
|
67
|
+
@data = { :fixed_fields => fixed_fields_and_values, :rows => query(options) }
|
68
|
+
case extension
|
69
|
+
when :pdf
|
70
|
+
report_to_pdf path
|
71
|
+
when :csv
|
72
|
+
raise 'CSV format report not implemented yet'
|
73
|
+
report_to_csv path
|
74
|
+
else
|
75
|
+
raise 'Format not supported. Only csv and pdf are available for the moment.'
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def calculate_total_hours_and_minutes(data)
|
83
|
+
data.inject([0, 0]) do |totals, task|
|
84
|
+
total_h, total_m = totals
|
85
|
+
if task[4 - fixed_fields_for_current_data.length].match(/^(\d+)h(\d+)m$/)
|
86
|
+
total_m += ($2.to_i % 60)
|
87
|
+
total_h += ($1.to_i + $2.to_i / 60)
|
88
|
+
end
|
89
|
+
[ total_h, total_m ]
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def extract_fixed_fields(options)
|
94
|
+
# remove Duration as we can't filter by that
|
95
|
+
REPORT_FIELDS[0..-2].select { |field| options.include?(field.downcase.to_sym) }
|
96
|
+
end
|
97
|
+
|
98
|
+
def report_to_csv output_path
|
99
|
+
require 'fastercsv'
|
100
|
+
rescue LoadError
|
101
|
+
puts "Missing gem: Fastercsv"
|
102
|
+
end
|
103
|
+
|
104
|
+
def report_to_pdf output_path
|
105
|
+
require 'prawn'
|
106
|
+
require 'prawn/layout'
|
107
|
+
require "prawn/measurement_extensions"
|
108
|
+
columns = REPORT_FIELDS - fixed_fields_for_current_data
|
109
|
+
data = @data[:rows].map { |task| task_row_for_fields(task, columns) }
|
110
|
+
|
111
|
+
|
112
|
+
total_h, total_m = calculate_total_hours_and_minutes(data)
|
113
|
+
report_generator = self
|
114
|
+
|
115
|
+
pdf = Prawn::Document.new(:page_layout => :portrait,
|
116
|
+
:left_margin => 10.mm, # different
|
117
|
+
:right_margin => 1.cm, # units
|
118
|
+
:top_margin => 0.1.dm, # work
|
119
|
+
:bottom_margin => 0.01.m, # well
|
120
|
+
:page_size => 'A4') do
|
121
|
+
|
122
|
+
report_generator.fill_user_information(self)
|
123
|
+
|
124
|
+
move_up 140
|
125
|
+
font_size 16
|
126
|
+
text "RTT Report"
|
127
|
+
text "=========="
|
128
|
+
move_down 40
|
129
|
+
|
130
|
+
|
131
|
+
# text report_generator.current_user.full_name_and_nickname, :align => :right
|
132
|
+
#text report_generator.current_user.company, :align => :right
|
133
|
+
#text report_generator.current_user.email, :align => :right
|
134
|
+
#text report_generator.current_user.address, :align => :right
|
135
|
+
#text report_generator.current_user.location, :align => :right
|
136
|
+
#text report_generator.current_user.phone, :align => :right
|
137
|
+
#text report_generator.current_user.site, :align => :right
|
138
|
+
|
139
|
+
report_generator.fixed_fields_for_current_data.each do |field|
|
140
|
+
text "#{field}: #{report_generator.fixed_value(field)}"
|
141
|
+
end
|
142
|
+
|
143
|
+
move_down 50
|
144
|
+
|
145
|
+
table data,
|
146
|
+
:headers => columns,
|
147
|
+
#:position => :center,
|
148
|
+
:position => :left,
|
149
|
+
:border_width => 1,
|
150
|
+
:row_colors => [ 'fafafa', 'f0f0f0' ],
|
151
|
+
:font_size => 12,
|
152
|
+
:padding => 5,
|
153
|
+
:align => :left
|
154
|
+
#:width => 535
|
155
|
+
#:column_widths => { 1=> 50, 2 => 40, 3 => 30}
|
156
|
+
|
157
|
+
move_down 20
|
158
|
+
text "Total: #{total_h}h#{total_m}m"
|
159
|
+
|
160
|
+
# footer
|
161
|
+
# page_count.times do |i|
|
162
|
+
#go_to_page(i+1)
|
163
|
+
#lazy_bounding_box([bounds.right-50, bounds.bottom + 25], :width => 50) {
|
164
|
+
#text "#{i+1} / #{page_count}"
|
165
|
+
#}.draw
|
166
|
+
#end
|
167
|
+
number_pages "Page <page> / <total>", [bounds.right - 80, 0]
|
168
|
+
|
169
|
+
render_file(output_path || DEFAULT_FILENAME)
|
170
|
+
end
|
171
|
+
rescue LoadError
|
172
|
+
puts "Missing gem: prawn, prawn/layout or prawn/measurement_extensions"
|
173
|
+
end
|
174
|
+
|
175
|
+
def calculate_fixed_fields_based_on_data
|
176
|
+
@data[:rows].each do |task|
|
177
|
+
(REPORT_FIELDS - @data[:fixed_fields].keys).each do |field|
|
178
|
+
value = REPORT_FIELD_OUTPUT[field].call(task)
|
179
|
+
@different_fixed[field] << value if FIXED_FIELDS.include?(field) && !@different_fixed[field].include?(value)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def task_row_for_fields(task, fields)
|
185
|
+
fields.map do |field|
|
186
|
+
REPORT_FIELD_OUTPUT[field].call(task)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
data/lib/rtt/storage.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
module Rtt
|
3
|
+
module Storage
|
4
|
+
|
5
|
+
def init(database = :rtt)
|
6
|
+
DataMapper.setup(:default, {:adapter => "sqlite3", :database => File.join( File.dirname(__FILE__), '..', '..', "db/#{database.to_s}.sqlite3") })
|
7
|
+
migrate unless missing_tables
|
8
|
+
DataObjects::Sqlite3.logger = DataMapper::Logger.new(File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'log', 'sqlite3.log')), 0)
|
9
|
+
end
|
10
|
+
|
11
|
+
def migrate #:nodoc:
|
12
|
+
DataMapper.auto_migrate!
|
13
|
+
end
|
14
|
+
|
15
|
+
def missing_tables
|
16
|
+
%W(rtt_projects rtt_users rtt_clients rtt_tasks).reject { |table| DataMapper.repository.storage_exists?(table) }.empty?
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/lib/rtt/task.rb
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
module Rtt
|
3
|
+
class Task
|
4
|
+
include DataMapper::Resource
|
5
|
+
|
6
|
+
DEFAULT_NAME = 'Default task'
|
7
|
+
|
8
|
+
property :id, Serial
|
9
|
+
property :name, Text, :required => true, :default => DEFAULT_NAME
|
10
|
+
property :date, Date
|
11
|
+
property :start_at, DateTime
|
12
|
+
property :end_at, DateTime
|
13
|
+
property :active, Boolean, :default => false
|
14
|
+
property :accumulated_spent_time, Float, :default => 0
|
15
|
+
|
16
|
+
belongs_to :project
|
17
|
+
has 1, :client, :through => :project
|
18
|
+
belongs_to :user
|
19
|
+
|
20
|
+
before :valid?, :set_default_project
|
21
|
+
before :valid?, :set_default_user
|
22
|
+
|
23
|
+
def self.task(task_name)
|
24
|
+
base_attributes = { :name => task_name, :user => Rtt.current_user, :date => Date.today }
|
25
|
+
if task_name.nil?
|
26
|
+
existing_task = Task.first :active => true
|
27
|
+
if existing_task
|
28
|
+
existing_task
|
29
|
+
else
|
30
|
+
base_attributes.merge!(:name => DEFAULT_NAME) if task_name.blank?
|
31
|
+
Task.create(base_attributes)
|
32
|
+
end
|
33
|
+
elsif (existing_task = Task.first base_attributes.merge({:start_at.gte => Date.today.beginning_of_day})).present?
|
34
|
+
existing_task
|
35
|
+
else
|
36
|
+
Task.create(base_attributes)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def start
|
41
|
+
self.start_at = DateTime.now
|
42
|
+
self.active = true
|
43
|
+
save
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
def add_current_spent_time_to_accumulated_spent_time
|
48
|
+
self.accumulated_spent_time = self.accumulated_spent_time + self.time_difference_since_start_at
|
49
|
+
end
|
50
|
+
|
51
|
+
def clone_task(task)
|
52
|
+
task = Task.new
|
53
|
+
task.attributes = self.attributes
|
54
|
+
task.id = nil
|
55
|
+
task
|
56
|
+
end
|
57
|
+
|
58
|
+
def duration
|
59
|
+
return '-' if end_at.blank?
|
60
|
+
convert_to_hour_and_minutes(accumulated_spent_time)
|
61
|
+
end
|
62
|
+
|
63
|
+
def set_default_project
|
64
|
+
self.project = Project.default if self.project.nil?
|
65
|
+
end
|
66
|
+
|
67
|
+
def set_default_user
|
68
|
+
self.user = User.default if self.user.nil?
|
69
|
+
end
|
70
|
+
|
71
|
+
def stop
|
72
|
+
split_task if span_multiple_days?
|
73
|
+
finish
|
74
|
+
self
|
75
|
+
end
|
76
|
+
|
77
|
+
def pause
|
78
|
+
split_task if span_multiple_days?
|
79
|
+
finish(DateTime.now, true)
|
80
|
+
self
|
81
|
+
end
|
82
|
+
|
83
|
+
def time_difference_since_start_at
|
84
|
+
end_date_or_now = self.end_at ? self.end_at : DateTime.now
|
85
|
+
end_date_or_now - self.start_at
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def convert_to_hour_and_minutes(dif)
|
91
|
+
hours, mins = time_diff_in_hours_and_minutes(dif)
|
92
|
+
"#{hours}h#{mins}m"
|
93
|
+
end
|
94
|
+
|
95
|
+
def finish(end_at = DateTime.now, activation = false)
|
96
|
+
self.end_at = end_at
|
97
|
+
self.date = end_at.to_date
|
98
|
+
self.add_current_spent_time_to_accumulated_spent_time
|
99
|
+
self.active = activation
|
100
|
+
self.save
|
101
|
+
end
|
102
|
+
|
103
|
+
def save_in_between_days_split(date)
|
104
|
+
task = clone_task(self)
|
105
|
+
task.start_at = date.beginning_of_day
|
106
|
+
end_at = date.end_of_day.to_datetime
|
107
|
+
task.send(:finish, end_at)
|
108
|
+
task.save
|
109
|
+
end
|
110
|
+
|
111
|
+
def save_last_task_split(date)
|
112
|
+
task = clone_task(self)
|
113
|
+
end_at = date.end_of_day.to_datetime
|
114
|
+
task.send(:finish, end_at)
|
115
|
+
task.save
|
116
|
+
end
|
117
|
+
|
118
|
+
def span_multiple_days?
|
119
|
+
self.start_at.day != Date.today.day
|
120
|
+
end
|
121
|
+
|
122
|
+
def split_task
|
123
|
+
date = Date.today - 1
|
124
|
+
while (date > self.start_at)
|
125
|
+
save_in_between_days_split(date)
|
126
|
+
date -= 1
|
127
|
+
end
|
128
|
+
save_last_task_split(date)
|
129
|
+
self.start_at = Date.today.beginning_of_day.to_datetime
|
130
|
+
end
|
131
|
+
|
132
|
+
def time_diff_in_hours_and_minutes(dif)
|
133
|
+
Date::day_fraction_to_time(dif)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|