rtt 0.0.0.1
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.
- 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
|