Skip to content
This repository has been archived by the owner on Jul 24, 2020. It is now read-only.

[1337] Add user and equipment CSV export #1340

Merged
merged 1 commit into from
Feb 11, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ gem 'net-ldap', '~> 0.13.0'
# attachments
gem 'paperclip', '~> 4.3.2'

# for exporting multiple files
gem 'rubyzip', '~> 1.1.7'

# soft deletion
gem 'permanent_records', '~> 4.1.0'
gem 'nilify_blanks', '~> 1.2.1'
Expand Down
4 changes: 3 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ GEM
ruby-progressbar (1.7.5)
rubycas-client (2.3.9)
activesupport
rubyzip (1.1.7)
safe_yaml (1.0.4)
sass (3.4.21)
sass-rails (5.0.4)
Expand Down Expand Up @@ -474,6 +475,7 @@ DEPENDENCIES
rspec-rails (~> 3.4.0)
rubocop (~> 0.35.1)
ruby-progressbar (~> 1.7.5)
rubyzip (~> 1.1.7)
sass-rails (~> 5.0.4)
select2-rails (~> 4.0.1)
shoulda-matchers (~> 3.0.1)
Expand All @@ -487,4 +489,4 @@ DEPENDENCIES
whenever (~> 0.9.4)

BUNDLED WITH
1.10.6
1.11.2
5 changes: 5 additions & 0 deletions app/controllers/categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class CategoriesController < ApplicationController
only: [:show, :edit, :update, :destroy, :deactivate]

include ActivationHelper
include CsvExport

# --------- before filter methods -------- #
def set_current_category
Expand All @@ -18,6 +19,10 @@ def index
else
@categories = Category.active
end
respond_to do |format|
format.html
format.zip { download_equipment_data }
end
end

def show
Expand Down
5 changes: 5 additions & 0 deletions app/controllers/equipment_models_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ def index
else
@equipment_models = base.includes(:reservations).active
end

respond_to do |format|
format.html
format.zip { download_equipment_data }
end
end

def show # rubocop:disable AbcSize, MethodLength
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class UsersController < ApplicationController
before_action :check_cas_auth, only: [:show, :new, :create, :edit, :update]

include Autocomplete
include CsvExport

# ------------ before filter methods ------------ #

Expand All @@ -32,6 +33,14 @@ def index
else
@users = User.active.order('username ASC')
end

respond_to do |format|
format.html
format.csv do
col = %w(username first_name last_name nickname phone email affiliation)
download_csv(User.all, col, "users_#{Time.zone.today}")
end
end
end

def show
Expand Down
1 change: 1 addition & 0 deletions app/views/categories/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<% if can? :manage, Category %>
<%= link_to "New Category", new_category_path, :class => "btn btn-primary" %>
<%= link_to "Import Categories", equip_import_page_path, :class => "btn btn-default" %>
<%= link_to "Export Equipment Data", categories_path(format: 'zip'), :class => "btn btn-default" %>
<% end %>
<% if params[:show_deleted] %>
<%= link_to "Hide Old Categories from List", categories_path, :class => "btn btn-default" %>
Expand Down
1 change: 1 addition & 0 deletions app/views/equipment_items/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
<% else %>
<%= link_to "New Equipment Item", new_equipment_item_path, class: 'btn btn-primary' %>
<%= link_to "Import Equipment Item", equip_import_page_path, class: 'btn btn-default' %>
<%= link_to "Export Equipment Data", categories_path(format: 'zip'), :class => "btn btn-default" %>
<% end %>
<% if params[:show_deleted] %>
Expand Down
3 changes: 2 additions & 1 deletion app/views/equipment_models/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
<%= link_to "View All Equipment Models", equipment_models_path, :class => 'btn btn-default' %>
<% else %>
<%= link_to "New Equipment Model", new_equipment_model_path, :class => "btn btn-primary" %>
<%= link_to "Import Equipment Models", equip_import_page_path, :class => "btn btn-default" %>
<%= link_to "Import Equipment Models", equip_import_page_path, :class => "btn btn-default" %>
<%= link_to "Export Equipment Data", categories_path(format: 'zip'), :class => "btn btn-default" %>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add this link to the equipment items index as well?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure!

<% end %>
<% if params[:show_deleted] %>
<% if (@category.nil?) %>
Expand Down
1 change: 1 addition & 0 deletions app/views/users/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<%= link_to "New User", new_user_path, :class => "btn btn-primary" %>
<% if can? :manage, User %>
<%= link_to "Import Users", csv_import_page_path, :class => "btn btn-default" %>
<%= link_to "Export Users", users_path(format: 'csv'), :class => "btn btn-default" %>
<% end %>
<% if params[:show_banned] %>
<%= link_to "Hide Banned Users from List", users_path, :class => "btn btn-default" %>
Expand Down
73 changes: 73 additions & 0 deletions lib/csv_export.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
module CsvExport
require 'csv'
require 'zip'

PROTECTED_COLS = %w(id encrypted_password reset_password_token
reset_password_sent_at)

# generates a csv from the given model data
# columns is optional; defaults to all columns except protected
def generate_csv(data, columns = [])
columns = data.first.attributes.keys if columns.empty?

PROTECTED_COLS.each { |col| columns.delete(col) }

CSV.generate(headers: true) do |csv|
csv << columns

data.each do |o|
csv << columns.map do |attr|
s = o.send(attr)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole thing is great, really nice job 😄

s.is_a?(ActiveRecord::Base) ? s.name : s
end
end
end
end

# generates a zip file containing multiple CSVs
# expects tables to be an array of arrays with the following format:
# [[objects, columns], ...]
# where columns is optional; defaults to all columns except protected
def generate_zip(tables)
# create the CSVs
csvs = tables.map { |model| generate_csv(*model) }

Zip::OutputStream.write_buffer do |stream|
csvs.each_with_index do |csv, i|
model_name = tables[i].first.first.class.name
stream.put_next_entry "#{model_name}_#{Time.zone.now.to_s(:number)}.csv"
stream.write csv
end
end.string
end

# downloads a csv of the given model table
# NOTE: this method depends on ActionController
def download_csv(data, columns, filename)
send_data(generate_csv(data, columns),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, this isn't so much a problem but I was a bit confused about where send_data came from since it's not vanilla Ruby. There's nowhere in this module that makes the dependency on ActionController clear; I'm not sure what best practice is, but I feel like at least a comment before these two methods would be useful. We might even want to include an explicit require call at the top to make it even clearer. Let me know what you think.

filename: "#{filename}_#{Time.zone.now.to_s(:number)}.csv")
end

# downloads a zip file containing multiple CSVs
# expects tables to be an array of arrays with the following format:
# [[objects, columns], ...]
# where columns is optional; defaults to all columns except protected
# NOTE: this method depends on ActionController
def download_zip(tables, filename)
send_data(generate_zip(tables), type: 'application/zip',
filename: "#{filename}.zip")
end

# NOTE: this method depends on ActionController
def download_equipment_data
categories = [Category.all, %w(name max_per_user max_checkout_length
max_renewal_times max_renewal_length
renewal_days_before_due sort_order)]
models = [EquipmentModel.all, %w(category name description late_fee
replacement_fee max_per_user
max_renewal_length)]
items = [EquipmentItem.all, %w(equipment_model name serial)]
download_zip([categories, models, items],
"EquipmentData_#{Time.zone.now.to_s(:number)}")
end
end
40 changes: 40 additions & 0 deletions spec/lib/csv_export_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
require 'spec_helper'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great specs, this is really nicely done!

include CsvExport

describe CsvExport do
before(:all) { FactoryGirl.create(:app_config) }

MODELS = [:user, :category, :equipment_model, :equipment_item]
PROTECTED_COLS = %w(id encrypted_password reset_password_token
reset_password_sent_at)

shared_examples 'builds a csv' do |model|
let(:csv) do
generate_csv(FactoryGirl.build_list(model, 5)).split("\n")
end

it 'has the appropriate length' do
expect(csv.size).to eq(6)
end

it 'has the appropriate columns' do
expect(csv.first.split(',')).to eq(
FactoryGirl.build(model).attributes.keys - PROTECTED_COLS)
end

it "doesn't include protected columns" do
PROTECTED_COLS.each do |col|
expect(csv.first.split(',')).not_to include(col)
end
end

it 'limits columns appropriately' do
cols = FactoryGirl.build(model).attributes.keys.sample(4)
cols.delete 'id' if cols.include? 'id'
csv = generate_csv(FactoryGirl.build_list(model, 5), cols).split("\n")
expect(csv.first.split(',')).to eq(cols)
end
end

MODELS.each { |m| it_behaves_like 'builds a csv', m }
end