From 38b0948a613d783538040edc9ea77ec18e2c8e70 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 | 4 +- 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_items/index.html.erb | 1 + app/views/equipment_models/index.html.erb | 3 +- app/views/users/index.html.erb | 1 + lib/csv_export.rb | 73 +++++++++++++++++++ spec/lib/csv_export_spec.rb | 40 ++++++++++ 11 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 lib/csv_export.rb create mode 100644 spec/lib/csv_export_spec.rb diff --git a/Gemfile b/Gemfile index 8cac6eb17..3193fe586 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 1b1f5d894..84f43cf9a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -487,4 +489,4 @@ DEPENDENCIES whenever (~> 0.9.4) BUNDLED WITH - 1.10.6 + 1.11.2 diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 1cde6b50f..f8a044b48 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 3e0cb59e7..63c519751 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 f432fd078..c551b6a87 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_items/index.html.erb b/app/views/equipment_items/index.html.erb index 37cb51694..95b3c00ef 100644 --- a/app/views/equipment_items/index.html.erb +++ b/app/views/equipment_items/index.html.erb @@ -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] %> 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..f39624bd3 --- /dev/null +++ b/lib/csv_export.rb @@ -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) + 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), + 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 diff --git a/spec/lib/csv_export_spec.rb b/spec/lib/csv_export_spec.rb new file mode 100644 index 000000000..a0ceac3b2 --- /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