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.
@@ -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
@@ -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
@@ -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