From 95a5c5f9584271434fe0b1820ca553a2b572b057 Mon Sep 17 00:00:00 2001 From: Sydney Young Date: Sat, 31 Oct 2015 17:11:52 -0400 Subject: [PATCH] Add user and equipment CSV export Resolves #1337 - adds the module CsvExport to handle exporting CSV files - also supports exporting multiple CSVs at once in a zip file --- Gemfile | 3 + Gemfile.lock | 2 + app/controllers/categories_controller.rb | 5 ++ .../equipment_models_controller.rb | 5 ++ app/controllers/users_controller.rb | 9 +++ app/views/categories/index.html.erb | 1 + app/views/equipment_models/index.html.erb | 3 +- app/views/users/index.html.erb | 1 + lib/csv_export.rb | 75 +++++++++++++++++++ spec/lib/csv_export_spec.rb | 40 ++++++++++ 10 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 lib/csv_export.rb create mode 100644 spec/lib/csv_export_spec.rb diff --git a/Gemfile b/Gemfile index 64b5dd70d..b7ac34f37 100644 --- a/Gemfile +++ b/Gemfile @@ -28,6 +28,9 @@ gem 'net-ldap', '~> 0.11' # attachments gem 'paperclip', '~> 4.2.1' +# for exporting multiple files +gem 'rubyzip' + # soft deletion gem 'permanent_records', '~> 3.2.0' gem 'nilify_blanks', '~> 1.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index f1a5737bf..c99d13e37 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -339,6 +339,7 @@ GEM ruby-progressbar (1.7.1) rubycas-client (2.3.9) activesupport + rubyzip (1.1.6) safe_yaml (1.0.4) sass (3.4.13) sass-rails (5.0.1) @@ -468,6 +469,7 @@ DEPENDENCIES rspec-rails (~> 3.2.0) rubocop (~> 0.29.1) ruby-progressbar (~> 1.7.1) + rubyzip sass-rails (~> 5.0.1) select2-rails (~> 3.5.9.3) shoulda-matchers (~> 2.8.0) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 84a7ba1d3..37bcf5814 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -5,6 +5,7 @@ class CategoriesController < ApplicationController only: [:show, :edit, :update, :destroy, :deactivate] include ActivationHelper + include CsvExport # --------- before filter methods -------- # def set_current_category @@ -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 diff --git a/app/controllers/equipment_models_controller.rb b/app/controllers/equipment_models_controller.rb index d924d2ab2..be09d4423 100644 --- a/app/controllers/equipment_models_controller.rb +++ b/app/controllers/equipment_models_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e9bc361ee..eb2d206d6 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 ------------ # @@ -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 diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index 5e9209255..8ecf35005 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -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" %> diff --git a/app/views/equipment_models/index.html.erb b/app/views/equipment_models/index.html.erb index 36330a859..f26b79ad0 100644 --- a/app/views/equipment_models/index.html.erb +++ b/app/views/equipment_models/index.html.erb @@ -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" %> <% end %> <% if params[:show_deleted] %> <% if (@category.nil?) %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 2cc6a277e..e3fc75790 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -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" %> diff --git a/lib/csv_export.rb b/lib/csv_export.rb new file mode 100644 index 000000000..a0b9d0d51 --- /dev/null +++ b/lib/csv_export.rb @@ -0,0 +1,75 @@ +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 table + # expects table to be an array with the following format: + # [objects, columns] + # where columns is optional; defaults to all columns except id + def generate_csv(table) + objects = table.first + columns = table.size == 1 ? objects.first.attributes.keys : table[1] + + PROTECTED_COLS.each { |col| columns.delete(col) } + + CSV.generate(headers: true) do |csv| + csv << columns + + objects.each do |o| + csv << columns.map do |attr| + s = o.send(attr) + 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 id + 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.today}.csv" + stream.write csv + end + end.string + end + + # downloads a csv of the given model table + # expects table to be an array with the following format: + # [objects, columns] + # where columns is optional; defaults to all columns except id + def download_csv(table, filename) + send_data(generate_csv(table), filename: "#{filename}.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 id + def download_zip(tables, filename) + send_data(generate_zip(tables), type: 'application/zip', + filename: "#{filename}.zip") + end + + 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.today}") + end +end diff --git a/spec/lib/csv_export_spec.rb b/spec/lib/csv_export_spec.rb new file mode 100644 index 000000000..4eb025acf --- /dev/null +++ b/spec/lib/csv_export_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' +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