Commit 0e03ea330c6dfb37a06256e9e1debb093876e57c
1 parent
46ed7dca
Exists in
master
and in
1 other branch
Refactoring data export to run in background, notify redis on completion
Showing
6 changed files
with
221 additions
and
58 deletions
Show diff stats
app/controllers/questions_controller.rb
@@ -155,14 +155,26 @@ class QuestionsController < InheritedResources::Base | @@ -155,14 +155,26 @@ class QuestionsController < InheritedResources::Base | ||
155 | end | 155 | end |
156 | def export | 156 | def export |
157 | type = params[:type] | 157 | type = params[:type] |
158 | + response_type = params[:response_type] | ||
158 | 159 | ||
159 | - if type == 'votes' | ||
160 | - export_votes | ||
161 | - elsif type == 'items' | ||
162 | - export_items | 160 | + if response_type == 'redis' |
161 | + redis_key = params[:redis_key] | ||
163 | else | 162 | else |
164 | - render :text => "Error! Specify a type of export" | 163 | + render :text => "Error! The only export type supported currently is local through redis!" and return |
165 | end | 164 | end |
165 | + | ||
166 | + if type.nil? | ||
167 | + render :text => "Error! Specify a type of export" and return | ||
168 | + end | ||
169 | + | ||
170 | + @question = current_user.questions.find(params[:id]) | ||
171 | + | ||
172 | + @question.send_later :export_and_delete, type, | ||
173 | + :response_type => response_type, :redis_key => redis_key, :delete_at => 3.days.from_now | ||
174 | + | ||
175 | + | ||
176 | + render :text => "Ok! Please wait for the response (as specified by your response_type)" | ||
177 | + | ||
166 | # export_type = params[:export_type] | 178 | # export_type = params[:export_type] |
167 | # export_format = params[:export_format] #CSV always now, could expand to xml later | 179 | # export_format = params[:export_format] #CSV always now, could expand to xml later |
168 | end | 180 | end |
@@ -326,57 +338,6 @@ class QuestionsController < InheritedResources::Base | @@ -326,57 +338,6 @@ class QuestionsController < InheritedResources::Base | ||
326 | end | 338 | end |
327 | 339 | ||
328 | protected | 340 | protected |
329 | - def export_votes | ||
330 | - @question = Question.find(params[:id]) | ||
331 | - | ||
332 | - outfile = "ideamarketplace_#{@question.id}_votes" + Time.now.strftime("%m-%d-%Y") + ".csv" | ||
333 | - headers = ['Vote ID', 'Session ID', 'Question ID','Winner ID', 'Winner Text', 'Loser ID', 'Loser Text', | ||
334 | - 'Prompt ID', 'Left Choice ID', 'Right Choice ID', 'Created at', 'Updated at'] | ||
335 | - @votes = @question.votes | ||
336 | - csv_data = FasterCSV.generate do |csv| | ||
337 | - csv << headers | ||
338 | - @votes.each do |v| | ||
339 | - prompt = v.prompt | ||
340 | - # these may not exist | ||
341 | - loser_data = v.loser_choice.nil? ? "" : "'#{v.loser_choice.data.strip}'" | ||
342 | - left_id = v.prompt.nil? ? "" : v.prompt.left_choice_id | ||
343 | - right_id = v.prompt.nil? ? "" : v.prompt.right_choice_id | ||
344 | - | ||
345 | - csv << [ v.id, v.voter_id, v.question_id, v.choice_id, "\'#{v.choice.data.strip}'", v.loser_choice_id, loser_data, | ||
346 | - v.prompt_id, left_id, right_id, v.created_at, v.updated_at] | ||
347 | - end | ||
348 | - end | ||
349 | - | ||
350 | - send_data(csv_data, | ||
351 | - :type => 'text/csv; charset=iso-8859-1; header=present', | ||
352 | - :disposition => "attachment; filename=#{outfile}") | ||
353 | - end | ||
354 | - | ||
355 | - def export_items | ||
356 | - @question = Question.find(params[:id], :include => [:choices, :prompts]) | ||
357 | - | ||
358 | - outfile = "ideamarketplace_#{@question.id}_ideas_" + Time.now.strftime("%m-%d-%Y") + ".csv" | ||
359 | - headers = ['Ideamarketplace ID','Idea ID', 'Idea Text', 'Wins', 'Losses', 'Score','User Submitted', 'Idea Creator ID', | ||
360 | - 'Created at', 'Last Activity', 'Active', 'Local Identifier', | ||
361 | - 'Prompts on Left', 'Prompts on Right', 'Prompts Count'] | ||
362 | - | ||
363 | - csv_data = FasterCSV.generate do |csv| | ||
364 | - csv << headers | ||
365 | - | ||
366 | - #ensure capital format for true and false | ||
367 | - @question.choices.each do |c| | ||
368 | - user_submitted = (c.item.creator != @question.creator) ? "TRUE" : "FALSE" | ||
369 | - | ||
370 | - csv << [c.question_id, c.id, "'#{c.data.strip}'", c.wins, c.losses, c.score, user_submitted , c.item.creator_id, | ||
371 | - c.created_at, c.updated_at, c.active, c.local_identifier, | ||
372 | - c.prompts_on_the_left(true).size, c.prompts_on_the_right(true).size, c.prompts_count] | ||
373 | - end | ||
374 | - end | ||
375 | - | ||
376 | - send_data(csv_data, | ||
377 | - :type => 'text/csv; charset=iso-8859-1; header=present', | ||
378 | - :disposition => "attachment; filename=#{outfile}") | ||
379 | - end | ||
380 | end | 341 | end |
381 | 342 | ||
382 | class String | 343 | class String |
app/models/prompt.rb
@@ -18,6 +18,7 @@ class Prompt < ActiveRecord::Base | @@ -18,6 +18,7 @@ class Prompt < ActiveRecord::Base | ||
18 | #named_scope :voted_on_by, proc {|u| { :conditions => { :methodology => methodology } } } | 18 | #named_scope :voted_on_by, proc {|u| { :conditions => { :methodology => methodology } } } |
19 | 19 | ||
20 | named_scope :active, :include => [:left_choice, :right_choice], :conditions => { 'left_choice.active' => true, 'right_choice.active' => true } | 20 | named_scope :active, :include => [:left_choice, :right_choice], :conditions => { 'left_choice.active' => true, 'right_choice.active' => true } |
21 | + named_scope :ids_only, :select => 'id' | ||
21 | 22 | ||
22 | 23 | ||
23 | def self.voted_on_by(u) | 24 | def self.voted_on_by(u) |
app/models/question.rb
@@ -17,6 +17,7 @@ class Question < ActiveRecord::Base | @@ -17,6 +17,7 @@ class Question < ActiveRecord::Base | ||
17 | end | 17 | end |
18 | end | 18 | end |
19 | has_many :votes | 19 | has_many :votes |
20 | + has_many :skips | ||
20 | has_many :densities | 21 | has_many :densities |
21 | has_many :appearances | 22 | has_many :appearances |
22 | 23 | ||
@@ -334,6 +335,98 @@ class Question < ActiveRecord::Base | @@ -334,6 +335,98 @@ class Question < ActiveRecord::Base | ||
334 | $redis.get(self.pq_key + "_" + date.to_s + "_"+ "hits") | 335 | $redis.get(self.pq_key + "_" + date.to_s + "_"+ "hits") |
335 | end | 336 | end |
336 | 337 | ||
338 | + def export_and_delete(type, options={}) | ||
339 | + delete_at = options.delete(:delete_at) | ||
340 | + filename = export(type, options) | ||
341 | + | ||
342 | + File.send_at(delete_at, :delete, filename) | ||
343 | + filename | ||
344 | + end | ||
345 | + | ||
346 | + def export(type, options = {}) | ||
347 | + | ||
348 | + case type | ||
349 | + when 'votes' | ||
350 | + outfile = "ideamarketplace_#{self.id}_votes.csv" | ||
351 | + | ||
352 | + headers = ['Vote ID', 'Session ID', 'Question ID','Winner ID', 'Winner Text', 'Loser ID', 'Loser Text', | ||
353 | + 'Prompt ID', 'Left Choice ID', 'Right Choice ID', 'Created at', 'Updated at', 'Response Time (ms)'] | ||
354 | + | ||
355 | + when 'ideas' | ||
356 | + outfile = "ideamarketplace_#{self.id}_ideas.csv" | ||
357 | + headers = ['Ideamarketplace ID','Idea ID', 'Idea Text', 'Wins', 'Losses', 'Times involved in Cant Decide', 'Score', | ||
358 | + 'User Submitted', 'Idea Creator ID', 'Created at', 'Last Activity', 'Active', 'Local Identifier', | ||
359 | + 'Appearances on Left', 'Appearances on Right'] | ||
360 | + when 'skips' | ||
361 | + outfile = "ideamarketplace_#{self.id}_skips.csv" | ||
362 | + headers = ['Skip ID', 'Session ID', 'Question ID','Left Choice ID', 'Left Choice Text', | ||
363 | + 'Right Choice ID', 'Right Choice Text', 'Prompt ID', 'Appearance ID', 'Reason', | ||
364 | + 'Created at', 'Updated at', 'Response Time (ms)'] | ||
365 | + else | ||
366 | + raise "Unsupported export type: #{type}" | ||
367 | + end | ||
368 | + | ||
369 | + filename = File.join(File.expand_path(Rails.root), "public", "system", "exports", | ||
370 | + self.id.to_s, Digest::SHA1.hexdigest(outfile + rand(10000000).to_s) + "_" + outfile) | ||
371 | + | ||
372 | + FileUtils::mkdir_p(File.dirname(filename)) | ||
373 | + csv_data = FasterCSV.open(filename, "w") do |csv| | ||
374 | + csv << headers | ||
375 | + | ||
376 | + case type | ||
377 | + when 'votes' | ||
378 | + | ||
379 | + self.votes.find_each(:include => [:prompt, :choice, :loser_choice]) do |v| | ||
380 | + prompt = v.prompt | ||
381 | + # these may not exist | ||
382 | + loser_data = v.loser_choice.nil? ? "" : "'#{v.loser_choice.data.strip}'" | ||
383 | + left_id = v.prompt.nil? ? "" : v.prompt.left_choice_id | ||
384 | + right_id = v.prompt.nil? ? "" : v.prompt.right_choice_id | ||
385 | + | ||
386 | + csv << [ v.id, v.voter_id, v.question_id, v.choice_id, "\'#{v.choice.data.strip}'", v.loser_choice_id, loser_data, | ||
387 | + v.prompt_id, left_id, right_id, v.created_at, v.updated_at, v.time_viewed] | ||
388 | + end | ||
389 | + | ||
390 | + when 'ideas' | ||
391 | + self.choices.each do |c| | ||
392 | + user_submitted = c.user_created ? "TRUE" : "FALSE" | ||
393 | + left_prompts_ids = c.prompts_on_the_left.ids_only | ||
394 | + right_prompts_ids = c.prompts_on_the_right.ids_only | ||
395 | + | ||
396 | + left_appearances = self.appearances.count(:conditions => {:prompt_id => left_prompts_ids}) | ||
397 | + right_appearances = self.appearances.count(:conditions => {:prompt_id => right_prompts_ids}) | ||
398 | + | ||
399 | + num_skips = self.skips.count(:conditions => {:prompt_id => left_prompts_ids + right_prompts_ids}) | ||
400 | + | ||
401 | + csv << [c.question_id, c.id, "'#{c.data.strip}'", c.wins, c.losses, num_skips, c.score, | ||
402 | + user_submitted , c.item.creator_id, c.created_at, c.updated_at, c.active, c.local_identifier, | ||
403 | + left_appearances, right_appearances] | ||
404 | + end | ||
405 | + when 'skips' | ||
406 | + | ||
407 | + self.skips.find_each(:include => :prompt) do |s| | ||
408 | + prompt = s.prompt | ||
409 | + csv << [ s.id, s.skipper_id, s.question_id, s.prompt.left_choice.id, s.prompt.left_choice.data.strip, | ||
410 | + s.prompt.right_choice.id, s.prompt.right_choice.data.strip, s.prompt_id, s.appearance_id, s.skip_reason, | ||
411 | + s.created_at, s.updated_at, s.time_viewed] | ||
412 | + end | ||
413 | + end | ||
414 | + | ||
415 | + end | ||
416 | + | ||
417 | + if options[:response_type] == 'redis' | ||
418 | + | ||
419 | + if options[:redis_key].nil? | ||
420 | + raise "No :redis_key specified" | ||
421 | + end | ||
422 | + | ||
423 | + $redis.lpush(options[:redis_key], filename) | ||
424 | + #TODO implement response_type == 'email' for use by customers of the API (not local) | ||
425 | + end | ||
426 | + | ||
427 | + filename | ||
428 | + end | ||
429 | + | ||
337 | 430 | ||
338 | 431 | ||
339 | end | 432 | end |
db/migrate/20100505235409_add_choice_indexes_to_prompts.rb
0 → 100644
@@ -0,0 +1,11 @@ | @@ -0,0 +1,11 @@ | ||
1 | +class AddChoiceIndexesToPrompts < ActiveRecord::Migration | ||
2 | + def self.up | ||
3 | + add_index :prompts, :left_choice_id | ||
4 | + add_index :prompts, :right_choice_id | ||
5 | + end | ||
6 | + | ||
7 | + def self.down | ||
8 | + remove_index :prompts, :left_choice_id | ||
9 | + remove_index :prompts, :right_choice_id | ||
10 | + end | ||
11 | +end |
spec/models/question_spec.rb
@@ -51,6 +51,7 @@ describe Question do | @@ -51,6 +51,7 @@ describe Question do | ||
51 | prompt = q.catchup_choose_prompt | 51 | prompt = q.catchup_choose_prompt |
52 | prompt.active?.should == true | 52 | prompt.active?.should == true |
53 | end | 53 | end |
54 | + | ||
54 | 55 | ||
55 | context "catchup algorithm" do | 56 | context "catchup algorithm" do |
56 | before(:all) do | 57 | before(:all) do |
@@ -127,10 +128,95 @@ describe Question do | @@ -127,10 +128,95 @@ describe Question do | ||
127 | 128 | ||
128 | @catchup_q.pop_prompt_queue.should == nil | 129 | @catchup_q.pop_prompt_queue.should == nil |
129 | end | 130 | end |
131 | + end | ||
132 | + | ||
133 | + context "exporting data" do | ||
134 | + before(:each) do | ||
135 | + user = Factory.create(:user) | ||
136 | + @question = Factory.create(:aoi_question, :site => user, :creator => user.default_visitor) | ||
137 | + | ||
138 | + end | ||
139 | + | ||
140 | + | ||
141 | + it "should export vote data to a csv file" do | ||
142 | + filename = @question.export('votes') | ||
143 | + | ||
144 | + filename.should_not be nil | ||
145 | + filename.should match /.*ideamarketplace_#{@question.id}_votes[.]csv$/ | ||
146 | + File.exists?(filename).should be_true | ||
147 | + # Not specifying exact file syntax, it's likely to change frequently | ||
148 | + # | ||
149 | + rows = FasterCSV.read(filename) | ||
150 | + rows.first.should include("Vote ID") | ||
151 | + rows.first.should_not include("Idea ID") | ||
152 | + File.delete(filename).should be_true | ||
153 | + | ||
154 | + end | ||
155 | + | ||
156 | + it "should notify redis after completing an export, if redis option set" do | ||
157 | + redis_key = "test_key123" | ||
158 | + $redis.del(redis_key) # clear if key exists already | ||
159 | + filename = @question.export('votes', :response_type => 'redis', :redis_key => redis_key) | ||
160 | + | ||
161 | + filename.should_not be nil | ||
162 | + filename.should match /.*ideamarketplace_#{@question.id}_votes[.]csv$/ | ||
163 | + File.exists?(filename).should be_true | ||
164 | + $redis.lpop(redis_key).should == filename | ||
165 | + | ||
166 | + # Not specifying exact file syntax, it's likely to change frequently | ||
167 | + $redis.del(redis_key) # clean up | ||
168 | + | ||
169 | + end | ||
170 | + it "should email question owner after completing an export, if email option set" do | ||
171 | + #TODO | ||
172 | + end | ||
130 | 173 | ||
174 | + it "should export skip data to a csv file" do | ||
175 | + filename = @question.export('skips') | ||
176 | + | ||
177 | + filename.should_not be nil | ||
178 | + filename.should match /.*ideamarketplace_#{@question.id}_skips[.]csv$/ | ||
179 | + File.exists?(filename).should be_true | ||
180 | + | ||
181 | + # Not specifying exact file syntax, it's likely to change frequently | ||
182 | + # | ||
183 | + rows = FasterCSV.read(filename) | ||
184 | + rows.first.should include("Skip ID") | ||
185 | + rows.first.should_not include("Idea ID") | ||
186 | + File.delete(filename).should_not be_nil | ||
187 | + | ||
188 | + | ||
189 | + end | ||
190 | + | ||
191 | + it "should export idea data to a csv file" do | ||
192 | + filename = @question.export('ideas') | ||
193 | + | ||
194 | + filename.should_not be nil | ||
195 | + filename.should match /.*ideamarketplace_#{@question.id}_ideas[.]csv$/ | ||
196 | + File.exists?(filename).should be_true | ||
197 | + # Not specifying exact file syntax, it's likely to change frequently | ||
198 | + # | ||
199 | + rows = FasterCSV.read(filename) | ||
200 | + rows.first.should include("Idea ID") | ||
201 | + rows.first.should_not include("Skip ID") | ||
202 | + File.delete(filename).should_not be_nil | ||
203 | + | ||
204 | + end | ||
205 | + | ||
206 | + it "should raise an error when given an unsupported export type" do | ||
207 | + lambda { @question.export("blahblahblah") }.should raise_error | ||
208 | + end | ||
209 | + | ||
210 | + it "should export data and schedule a job to delete export after X days" do | ||
211 | + Delayed::Job.delete_all | ||
212 | + filename = @question.export_and_delete('votes', :delete_at => 2.days.from_now) | ||
213 | + | ||
214 | + Delayed::Job.count.should == 1 | ||
215 | + Delayed::Job.delete_all | ||
216 | + File.delete(filename).should_not be_nil | ||
217 | + | ||
218 | + end | ||
131 | 219 | ||
132 | end | 220 | end |
133 | 221 | ||
134 | - | ||
135 | - #q = @aoi_clone.create_question("foobarbaz", {:name => 'foo'}) | ||
136 | end | 222 | end |