rtt 0.0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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