mochigome 0.1.5 → 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. data/Gemfile +7 -0
  2. data/Gemfile.lock +15 -0
  3. data/TODO +3 -0
  4. data/lib/mochigome_ver.rb +1 -1
  5. data/lib/model_extensions.rb +1 -1
  6. data/lib/query.rb +6 -2
  7. data/test/app_root/app/controllers/application_controller.rb +15 -0
  8. data/test/app_root/app/controllers/report_controller.rb +388 -0
  9. data/test/app_root/app/helpers/application_helper.rb +8 -0
  10. data/test/app_root/app/models/product.rb +2 -2
  11. data/test/app_root/app/models/sale.rb +1 -1
  12. data/test/app_root/app/models/store.rb +4 -0
  13. data/test/app_root/app/transforms/report.fo.via-html.xslt.haml +173 -0
  14. data/test/app_root/app/transforms/report.html.xslt.haml +198 -0
  15. data/test/app_root/app/views/layouts/application.html.haml +15 -0
  16. data/test/app_root/app/views/report/edit.html.haml +56 -0
  17. data/test/app_root/app/views/report/show.html.haml +6 -0
  18. data/test/app_root/config/database.yml +7 -0
  19. data/test/app_root/config/environments/development.rb +17 -0
  20. data/test/app_root/config/initializers/mime_types.rb +8 -0
  21. data/test/app_root/config/routes.rb +3 -2
  22. data/test/app_root/db/development.sqlite3 +0 -0
  23. data/test/app_root/lib/apache_fop.rb +151 -0
  24. data/test/app_root/public/index.html +14 -0
  25. data/test/app_root/public/javascripts/prototype.js +6084 -0
  26. data/test/app_root/public/javascripts/report_edit.js +153 -0
  27. data/test/app_root/public/stylesheets/common.css +183 -0
  28. data/test/app_root/vendor/plugins/mochigome/init.rb +3 -1
  29. data/test/server.rb +3 -0
  30. data/test/test_helper.rb +1 -1
  31. data/test/unit/query_test.rb +12 -3
  32. metadata +20 -5
  33. data/test/app_root/app/controllers/owners_controller.rb +0 -2
data/Gemfile CHANGED
@@ -1,11 +1,13 @@
1
1
  source :rubygems
2
2
 
3
+ # Required by the plugin itself
3
4
  gem 'rails', '2.3.12'
4
5
  gem 'arel', '~> 2.1'
5
6
  gem 'nokogiri'
6
7
  gem 'ruport'
7
8
  gem 'rgl'
8
9
 
10
+ # Used in the test suite and/or demo app
9
11
  gem 'sqlite3'
10
12
  gem 'mysql2', '~> 0.2.0'
11
13
  gem 'factory_girl', '2.0.4'
@@ -17,3 +19,8 @@ gem 'mynyml-redgreen'
17
19
  gem 'rev'
18
20
  gem 'watchr'
19
21
  gem 'autowatchr'
22
+ gem 'haml'
23
+ gem 'googlecharts', :require => 'gchart'
24
+ gem "simple_xlsx_writer", :require => "simple_xlsx"
25
+ gem 'xslt-morpheus', '0.1', :require => "morpheus"
26
+ gem 'fastercsv'
data/Gemfile.lock CHANGED
@@ -16,7 +16,10 @@ GEM
16
16
  watchr
17
17
  color (1.4.1)
18
18
  factory_girl (2.0.4)
19
+ fast_xs (0.8.0)
19
20
  fastercsv (1.5.4)
21
+ googlecharts (1.6.8)
22
+ haml (3.1.4)
20
23
  hoe (2.12.0)
21
24
  rake (~> 0.8)
22
25
  iobuffer (1.0.0)
@@ -45,15 +48,22 @@ GEM
45
48
  rake
46
49
  stream (>= 0.5)
47
50
  ruby-prof (0.10.8)
51
+ rubyzip (0.9.4)
48
52
  ruport (1.6.3)
49
53
  fastercsv
50
54
  pdf-writer (= 1.1.8)
55
+ simple_xlsx_writer (0.5.3)
56
+ fast_xs (>= 0.7.3)
57
+ rubyzip (>= 0.9.4)
51
58
  sqlite3 (1.3.4)
52
59
  stream (0.5)
53
60
  term-ansicolor (1.0.6)
54
61
  transaction-simple (1.4.0)
55
62
  hoe (>= 1.1.7)
56
63
  watchr (0.7)
64
+ xslt-morpheus (0.1)
65
+ actionpack
66
+ nokogiri
57
67
 
58
68
  PLATFORMS
59
69
  ruby
@@ -62,6 +72,9 @@ DEPENDENCIES
62
72
  arel (~> 2.1)
63
73
  autowatchr
64
74
  factory_girl (= 2.0.4)
75
+ fastercsv
76
+ googlecharts
77
+ haml
65
78
  minitest
66
79
  mynyml-redgreen
67
80
  mysql2 (~> 0.2.0)
@@ -73,5 +86,7 @@ DEPENDENCIES
73
86
  rgl
74
87
  ruby-prof
75
88
  ruport
89
+ simple_xlsx_writer
76
90
  sqlite3
77
91
  watchr
92
+ xslt-morpheus (= 0.1)
data/TODO CHANGED
@@ -3,3 +3,6 @@
3
3
  - If there is more than one association from model A to model B and they're both focusable, pick the one with no conditions. If all the associations have conditions, complain and require that the correct association be manually specified (where "correct" might mean none of them should be valid)
4
4
  - Alternately, always ignore conditional associations unless they're specifically provided to Mochigome by the model
5
5
  - Named subsets of different fields and agg fields on a single model
6
+ - Some kind of single-page preview on the edit screen would be cool. Maybe use FOP with fake data and the PNG output option?
7
+ - Allow inwards-branching join patterns on layer list (i.e. Category->Store->Product)
8
+ - Automatically set default to 0 on sum and count aggregation
data/lib/mochigome_ver.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mochigome
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.6"
3
3
  end
@@ -81,7 +81,7 @@ module Mochigome
81
81
 
82
82
  # TODO: Apply association conditions.
83
83
 
84
- r.join(ftable, Arel::Nodes::OuterJoin).on(cond)
84
+ r.join(ftable, Arel::Nodes::InnerJoin).on(cond)
85
85
  end
86
86
  end
87
87
 
data/lib/query.rb CHANGED
@@ -166,18 +166,22 @@ module Mochigome
166
166
  if table.is_a? Array
167
167
  fields = data_model.mochigome_aggregation_settings.options[:fields]
168
168
  # Pre-fill the node with all fields in the right order
169
- fields.each{|agg| node[agg[:name]] = nil unless agg[:hidden] }
169
+ fields.each{|agg| node[agg[:name]] = agg[:default] unless agg[:hidden] }
170
170
  agg_row = {} # Hold regular aggs here to be used in ruby-based aggs
171
171
  fields.reject{|agg| agg[:in_ruby]}.zip(table).each do |agg, v|
172
+ v ||= agg[:default]
172
173
  agg_row[agg[:name]] = v
173
174
  node[agg[:name]] = v unless agg[:hidden]
174
175
  end
175
176
  fields.select{|agg| agg[:in_ruby]}.each do |agg|
176
177
  node[agg[:name]] = agg[:ruby_proc].call(agg_row)
177
178
  end
179
+ node.children.each do |c|
180
+ insert_aggregate_data_fields(c, [], data_model)
181
+ end
178
182
  else
179
183
  node.children.each do |c|
180
- subtable = table[c[:id]] or next
184
+ subtable = table[c[:id]] || []
181
185
  insert_aggregate_data_fields(c, subtable, data_model)
182
186
  end
183
187
  end
@@ -1,2 +1,17 @@
1
1
  class ApplicationController < ActionController::Base
2
+ layout "application"
3
+
4
+ # Allows the use of ActionView helper methods elsewhere
5
+ # i.e. help.pluralize(3, "banana")
6
+ class Helper
7
+ include Singleton
8
+ include ActionView::Helpers
9
+ include ApplicationHelper
10
+ end
11
+ def self.help
12
+ Helper.instance
13
+ end
14
+ def help
15
+ self.class.help
16
+ end
2
17
  end
@@ -0,0 +1,388 @@
1
+ # FIXME This whole controller needs a major cleanup, should really
2
+ # be largely moved into some base class in Mochigome.
3
+
4
+ class ReportController < ApplicationController
5
+ FOCUS_MODELS = {}
6
+ HUMAN_FOCUS_MODELS = {}
7
+ [
8
+ Owner,
9
+ Store,
10
+ Product,
11
+ Category
12
+ ].each do |m|
13
+ FOCUS_MODELS[m.name] = m
14
+ HUMAN_FOCUS_MODELS[m.human_name.titleize] = m.name
15
+ end
16
+
17
+ AGGREGATE_SOURCES = [
18
+ ["Sales", "Sale"],
19
+ ["Product Count", "Product"],
20
+ ["Store Count", "Store"]
21
+ ]
22
+
23
+ def show
24
+ return redirect_to(:action => :edit) if params.size <= 2
25
+ begin
26
+ query = setup_query
27
+ data_node = run_report(query)
28
+ generate_charts(data_node)
29
+ output_report(data_node)
30
+ rescue Mochigome::QueryError => e
31
+ flash[:alert] = "Sorry, these report settings are not valid: #{e.message}"
32
+ return redirect_to(params.merge(:action => :edit))
33
+ end
34
+ end
35
+
36
+ def edit
37
+ @possible_layer_models = HUMAN_FOCUS_MODELS.map{|k, v| [k, v]}.sort
38
+ end
39
+
40
+ private
41
+
42
+ def condition_desc(c)
43
+ cls = c['cls'].strip.constantize
44
+ cls_name = cls.human_name.titleize
45
+
46
+ ref_cls = nil
47
+ if c['fld'] == cls.primary_key
48
+ ref_cls = cls
49
+ else
50
+ cls.reflections.map(&:last).select{|r| r.belongs_to?}.each do |r|
51
+ if r.association_foreign_key == c['fld']
52
+ ref_cls = r.klass
53
+ break
54
+ end
55
+ end
56
+ end
57
+
58
+ if ref_cls && c['op'] == 'eq'
59
+ item = ref_cls.find(c['val'].to_i)
60
+ ref_name = cls_name + (cls == ref_cls ? "" : " - #{ref_cls.human_name}")
61
+ return "#{ref_name}: #{item.display_s}"
62
+ else
63
+ return [
64
+ cls_name,
65
+ c['fld'].humanize.downcase.gsub(cls_name.downcase, ''),
66
+ arel_op_humanize(c['op']),
67
+ c['val'].to_s
68
+ ].join(" ")
69
+ end
70
+ end
71
+
72
+ def arel_op_humanize(op)
73
+ op.
74
+ gsub('gteq', 'greater than or equal to').
75
+ gsub('lteq', 'less than or equal to').
76
+ gsub('eq', 'equal to').
77
+ gsub('lt', 'less than').
78
+ gsub('gt', 'greater than').
79
+ humanize.downcase
80
+ end
81
+
82
+ helper_method :condition_desc, :arel_op_humanize
83
+
84
+ def setup_query
85
+ raise Mochigome::QueryError.new("No layers provided") if @layer_names.empty?
86
+ layers = @layer_names.map{|n| FOCUS_MODELS[n]}
87
+
88
+ aggregate_sources = @aggregate_source_names.map do |s|
89
+ # Each aggregate source is a focus type paired with a data type
90
+ r = s.split(":").map(&:strip).map(&:constantize)
91
+ r.size > 1 ? r : r.first
92
+ end
93
+
94
+ @report_name = "#{layers.last.human_name.pluralize.titleize} Report"
95
+ unless @aggregate_source_names.empty?
96
+ @report_name += " : " + @aggregate_source_names.map{|n|
97
+ n.gsub(":","").underscore.titleize.pluralize
98
+ }.join(", ")
99
+ end
100
+
101
+ Mochigome::Query.new(layers,
102
+ :aggregate_sources => aggregate_sources,
103
+ #:access_filter => cancan_access_filter_proc,
104
+ :root_name => @report_name
105
+ )
106
+ end
107
+
108
+ def run_report(query)
109
+ full_cond = nil
110
+ @condition_params.each do |c|
111
+ cls = c['cls'].strip.constantize # TODO : Verify it's AR::Base
112
+ # TODO: Join cls into the query if it's not already (join on what, though?)
113
+ raise "No such op #{c['op']}" unless Arel::Predications.instance_methods.include?(c['op'])
114
+ val = interpret_val(cls, c['fld'], c['val'])
115
+ cond = Arel::Table.new(cls.table_name)[c['fld']].send(c['op'], val)
116
+ full_cond = full_cond ? full_cond.and(cond) : cond
117
+ end
118
+ query.run(full_cond)
119
+ end
120
+
121
+ def interpret_val(cls, fld, val)
122
+ if val.is_a?(Array)
123
+ val.map{|v| interpret_val(cls, fld, v)}
124
+ else
125
+ val = val.to_s
126
+ column = cls.columns_hash[fld] or raise "Can't find column #{fld} in #{cls}"
127
+ begin
128
+ case column.type
129
+ when :boolean then (val.to_i != 0)
130
+ when :date then Date.parse(val)
131
+ when :datetime then DateTime.parse(val)
132
+ when :time then Time.parse(val) # FIXME: Time#parse silently defaults to cur time!
133
+ when :integer then val.to_i
134
+ when :string, :text then val
135
+ else raise "Unknown column type #{column.type} for #{fld} in #{cls}"
136
+ end
137
+ rescue ArgumentError
138
+ raise Mochigome::QueryError.new("Unable to interpret value: #{val}")
139
+ end
140
+ end
141
+ end
142
+
143
+ def generate_charts(data_node)
144
+ @charts = []
145
+ return if data_node.children.size > 15 || data_node.children.empty?
146
+
147
+ @aggregate_source_names.each do |raw_name|
148
+ agg = AGGREGATE_SOURCES.select{|s| s[1] == raw_name}.first
149
+ next unless agg
150
+
151
+ chart_options = {
152
+ :type => 'bar',
153
+ :title => agg[0],
154
+ # :title_size => 20, # FIXME: Argh, googlecharts gem isn't reliable
155
+ :size => '720x250',
156
+ :bg => 'FFFFFF00', # White with fully-transparent alpha
157
+ :stacked => false,
158
+ :bar_colors => '859900,b58900,dc322f,268bd2',
159
+ :axis_with_labels => 'x,y',
160
+ :class => 'chart',
161
+ :alt => agg[0] + " Chart",
162
+ :custom => "chdlp=b|l"
163
+ }
164
+
165
+ data_model = agg[1].split(":").last.constantize
166
+ agg_fields = data_model.mochigome_aggregation_settings.options[:fields].
167
+ reject{|f| f[:hidden]}
168
+
169
+ # TODO: If there's only one agg field, we can use series names
170
+ # to go another level down in the report instead.
171
+ chart_options[:legend] = agg_fields.map{|f| f[:name]}
172
+ chart_options[:data] = agg_fields.map do |f|
173
+ data_node.children.map do |n|
174
+ (n[f[:name]] || "").to_f
175
+ end
176
+ end
177
+ chart_options[:axis_labels] = [data_node.children.map{|n| n.name}]
178
+ x_gap = [450/(chart_options[:axis_labels][0].size), 70].min
179
+ chart_options[:bar_width_and_spacing] = [8,2,x_gap]
180
+
181
+ min_val = [0, chart_options[:data].flatten.min].min
182
+ max_val = [100, chart_options[:data].flatten.max].max # FIXME No no no
183
+ chart_options[:axis_range] = [nil, [min_val, max_val]]
184
+ chart_options[:min_value] = chart_options[:axis_range][1][0]
185
+ chart_options[:max_value] = chart_options[:axis_range][1][1]
186
+
187
+ if chart_options[:axis_labels][0].size > 7
188
+ chart_options[:axis_labels][0].reverse!
189
+ chart_options[:orientation] = 'horizontal'
190
+ chart_options[:class] += ' horizontal'
191
+ chart_options[:axis_with_labels] = 'y,x'
192
+ chart_options[:size] = "350x#{50 + chart_options[:axis_labels][0].size*30}"
193
+ chart_options[:bar_width_and_spacing][2] = 10
194
+ end
195
+
196
+ @charts << Gchart.new(chart_options).image_tag
197
+ end
198
+ end
199
+
200
+ def output_report(data_node)
201
+ # These instance variables are used by the HTML transform
202
+ # FIXME: Just do the non-data part of the sidebar in HAML instead
203
+ # To do that, use seperate transforms for sidebar links and main content
204
+ @print_path = report_path(params.merge(:format => "pdf", :auto_print => true))
205
+ @download_paths = []
206
+ [
207
+ [:pdf, "PDF Document"],
208
+ [:xlsx, "Excel 2007 Spreadsheet"],
209
+ [:csv, "CSV Spreadsheet"],
210
+ [:xml, "XML Raw Data"]
211
+ ].each do |ext, name|
212
+ @download_paths << {
213
+ :path => report_path(params.merge(
214
+ :format => ext,
215
+ :auto_print => false,
216
+ :download => true
217
+ )),
218
+ :name => name,
219
+ :ext => ext
220
+ }
221
+ end
222
+
223
+ transform_opts = {
224
+ :src_type => "report",
225
+ :context => self
226
+ }
227
+
228
+ filename = "report"
229
+ respond_to do |format|
230
+ format.html do
231
+ @report_html = Morpheus.transform(
232
+ data_node.to_xml,
233
+ transform_opts.merge(:tgt_format => "html")
234
+ ).to_html.html_safe
235
+ render
236
+ end
237
+ format.xlsx do
238
+ send_xlsx(data_node.to_flat_arrays, "#{filename}.xlsx")
239
+ end
240
+ format.csv do
241
+ send_csv(data_node.to_flat_arrays, "#{filename}.csv")
242
+ end
243
+ format.pdf do
244
+ fo_data = Morpheus.transform(
245
+ data_node.to_xml,
246
+ transform_opts.merge(:tgt_format => "fo")
247
+ )
248
+ send_pdf(fo_data, "#{filename}.pdf", params[:download],
249
+ :from_url => report_url(params.merge(:format => "html")),
250
+ :auto_print => !params[:download]
251
+ )
252
+ end
253
+ format.xml do
254
+ send_data(
255
+ data_node.to_xml.to_s,
256
+ :type => "application/xml",
257
+ :disposition => 'attachment',
258
+ :filename => "#{filename}.xml"
259
+ )
260
+ end
261
+ end
262
+ end
263
+
264
+ # If you're using CanCan, this access filter will restrict your report
265
+ # results by the current user's permissions.
266
+ def cancan_access_filter_proc
267
+ af = proc do |cls|
268
+ r = {}
269
+ return r unless cls.real_model?
270
+ rules = current_ability.send(:relevant_rules_for_query, :index, cls)
271
+ return r if rules.empty?
272
+ adapter = CanCan::ModelAdapters::ActiveRecordAdapter.new(cls, rules)
273
+ conditions = adapter.conditions
274
+ conditions = cls.send(:sanitize_sql, conditions) if conditions.is_a?(Hash)
275
+ conditions = nil if conditions == "1=1"
276
+ if conditions
277
+ r[:condition] =
278
+ (Arel::Table.new(cls.table_name)[cls.primary_key].eq(nil)).or(
279
+ Arel::Nodes::SqlLiteral.new("(#{conditions})"))
280
+ end
281
+ if adapter.joins
282
+ r[:join_paths] = pathify_cancan_joins(adapter.joins).map{|p| [cls] + p}
283
+ end
284
+ r
285
+ end
286
+ end
287
+
288
+ def pathify_cancan_joins(j)
289
+ case j
290
+ when Array then
291
+ j.map{|i| pathify_cancan_joins(i)}
292
+ when Hash then
293
+ j.map{|k,v| pathify_cancan_joins(k) + pathify_cancan_joins(v).flatten}.flatten
294
+ when Symbol then
295
+ [j.to_s.classify.constantize]
296
+ else raise "Invalid cancan join path element #{j.inspect}"
297
+ end
298
+ end
299
+
300
+ # FIXME: Factor the send_X methods below
301
+
302
+ def send_pdf(fo_data, filename, download, pdf_options = {})
303
+ pdf = ApacheFop::generate_pdf(fo_data, pdf_options)
304
+ begin
305
+ send_file(
306
+ pdf.path,
307
+ :stream => false, # If this was on, temp file would be deleted too early
308
+ :type => "application/pdf",
309
+ :filename => filename,
310
+ :disposition => download ? 'attachment' : 'inline'
311
+ )
312
+ ensure
313
+ # FIXME: To allow use of X-Send-File, use a separate cron task to delete
314
+ # stale pdf files periodically.
315
+ pdf.close!
316
+ end
317
+ end
318
+
319
+ def send_xlsx(table, filename)
320
+ file = Tempfile.new("excel")
321
+ begin
322
+ path = file.path
323
+ file.close!
324
+ SimpleXlsx::Serializer.new(path) do |bk|
325
+ bk.add_sheet("Report") do |sheet|
326
+ table.each do |row|
327
+ sheet.add_row row
328
+ end
329
+ end
330
+ end
331
+ send_file(
332
+ path,
333
+ :stream => false, # If this was on, temp file would be deleted too early
334
+ :type => Mime::XLSX,
335
+ :filename => filename,
336
+ :disposition => 'attachment'
337
+ )
338
+ ensure
339
+ # FIXME: To allow use of X-Send-File, use a separate cron task to delete
340
+ # stale files periodically.
341
+ File.unlink(path) if File.exists?(path)
342
+ end
343
+ end
344
+
345
+ def send_csv(table, filename)
346
+ file = Tempfile.new("csv")
347
+ begin
348
+ path = file.path
349
+ file.close!
350
+ FasterCSV.open(path, "w") do |csv|
351
+ table.each do |row|
352
+ csv << row
353
+ end
354
+ end
355
+ send_file(
356
+ path,
357
+ :stream => false, # If this was on, temp file would be deleted too early
358
+ :type => Mime::CSV,
359
+ :filename => filename,
360
+ :disposition => 'attachment'
361
+ )
362
+ ensure
363
+ # FIXME: To allow use of X-Send-File, use a separate cron task to delete
364
+ # stale files periodically.
365
+ File.unlink(path) if File.exists?(path)
366
+ end
367
+ end
368
+
369
+
370
+ before_filter :load_params
371
+ def load_params
372
+ @layer_names = []
373
+ if params[:l].is_a?(Array)
374
+ @layer_names = params[:l].reject(&:blank?).map(&:strip)
375
+ end
376
+
377
+ @condition_params = []
378
+ if params[:c].is_a?(Hash)
379
+ @condition_params = params[:c].values.reject(&:blank?)
380
+ end
381
+ @condition_descs = @condition_params.map{|c| condition_desc(c)}
382
+
383
+ @aggregate_source_names = []
384
+ if params[:a].is_a?(Array)
385
+ @aggregate_source_names = params[:a].reject(&:blank?)
386
+ end
387
+ end
388
+ end
@@ -0,0 +1,8 @@
1
+ module ApplicationHelper
2
+ # Creates an xsl xpath match attribute for a tag with the given HTML class
3
+ # http://pivotallabs.com/users/alex/blog/articles/427-xpath-css-class-matching
4
+ # TODO: Move this into Morpheus
5
+ def xpath_class(cls)
6
+ "contains(concat(' ',normalize-space(@class),' '),' #{cls} ')"
7
+ end
8
+ end
@@ -13,8 +13,8 @@ class Product < ActiveRecord::Base
13
13
  )
14
14
  ]}
15
15
  ]
16
- a.hidden_fields [ {"Secret count" => :count} ]
17
- a.fields_in_ruby [ {"Count squared" => lambda{|row| row["Secret count"]**2}} ]
16
+ a.hidden_fields [ {"Secret count" => [:count]} ]
17
+ a.fields_in_ruby [ {"Count squared" => lambda{|row| row["Secret count"].try(:**, 2)}} ]
18
18
  end
19
19
 
20
20
  belongs_to :category
@@ -3,7 +3,7 @@ class Sale < ActiveRecord::Base
3
3
  a.fields [:count, {
4
4
  "Gross" => [:sum, lambda {|t|
5
5
  Product.arel_table[:price]
6
- }]
6
+ }, {:default => 0}]
7
7
  }]
8
8
  end
9
9
 
@@ -3,6 +3,10 @@ class Store < ActiveRecord::Base
3
3
  f.type_name "Storefront"
4
4
  end
5
5
 
6
+ has_mochigome_aggregations do |a|
7
+ a.fields [:count]
8
+ end
9
+
6
10
  belongs_to :owner
7
11
  has_many :store_products
8
12
  has_many :products, :through => :store_products