diff --git a/Gemfile b/Gemfile index 7831d5e1..20bbf2d4 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ gem 'actiontext' gem 'bootsnap', require: false gem 'country_select' gem 'cssbundling-rails' +gem 'csv', '~> 3.2' gem 'devise', '~> 4.9' gem 'google-cloud-storage', '~> 1.52' gem 'image_processing', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock index 8a76c759..b73d22ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -133,6 +133,7 @@ GEM crass (1.0.6) cssbundling-rails (1.4.1) railties (>= 6.0.0) + csv (3.3.3) database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) @@ -551,6 +552,7 @@ DEPENDENCIES capybara country_select cssbundling-rails + csv (~> 3.2) database_cleaner-active_record (~> 2.0) debug devise (~> 4.9) diff --git a/app/assets/images/base_email.png b/app/assets/images/base_email.png new file mode 100644 index 00000000..57fe0505 Binary files /dev/null and b/app/assets/images/base_email.png differ diff --git a/app/assets/images/email_with_extra_info.png b/app/assets/images/email_with_extra_info.png new file mode 100644 index 00000000..bab34e3f Binary files /dev/null and b/app/assets/images/email_with_extra_info.png differ diff --git a/app/controllers/containers_controller.rb b/app/controllers/containers_controller.rb index aa76f1d7..43044466 100644 --- a/app/controllers/containers_controller.rb +++ b/app/controllers/containers_controller.rb @@ -1,7 +1,7 @@ # app/controllers/containers_controller.rb class ContainersController < ApplicationController include ContestDescriptionsHelper - before_action :set_container, only: %i[show edit update destroy description] + before_action :set_container, only: %i[show edit update destroy description active_applicants_report] before_action :authorize_container, only: %i[edit show update destroy description] before_action :authorize_index, only: [ :index ] @@ -15,6 +15,7 @@ def show ).includes(:user, :role) @assignment = @container.assignments.build @container_contest_descriptions = @container.contest_descriptions.reorder('contest_descriptions.name ASC') + @active_contest_descriptions = @container.contest_descriptions.active.reorder('contest_descriptions.name ASC') end def new @@ -95,6 +96,51 @@ def description end end + def active_applicants_report + contest_description_ids = params[:contest_description_ids] || [] + @active_contest_descriptions = @container.contest_descriptions.active.where(id: contest_description_ids) + + authorize @container + + if @active_contest_descriptions.empty? + respond_to do |format| + format.csv { redirect_to @container, alert: 'Please select at least one contest description.' } + format.html { redirect_to @container, alert: 'Please select at least one contest description.' } + end + return + end + + service = ActiveApplicantsReportService.new( + container: @container, + contest_descriptions: @active_contest_descriptions + ) + + @profiles = service.call + + respond_to do |format| + format.csv do + filename = "active-applicants-in-#{@container.name.parameterize}_#{Time.zone.today}.csv" + + csv_data = CSV.generate do |csv| + csv << ['Last Name', 'First Name', 'Email'] + + @profiles.each do |profile| + csv << [ + profile.last_name, + profile.first_name, + profile.user.email + ] + end + end + + send_data csv_data, + type: 'text/csv; charset=utf-8; header=present', + disposition: "attachment; filename=#{filename}" + end + format.html { redirect_to @container, alert: 'Please request the report in CSV format.' } + end + end + private def authorize_container diff --git a/app/controllers/contest_instances_controller.rb b/app/controllers/contest_instances_controller.rb index 6b46946e..f9fbcd2f 100644 --- a/app/controllers/contest_instances_controller.rb +++ b/app/controllers/contest_instances_controller.rb @@ -143,6 +143,28 @@ def send_round_results notice: "Successfully queued #{email_count} evaluation result emails for round #{judging_round.round_number}. This is email batch ##{judging_round.emails_sent_count}." end + def export_entries + @contest_instance = ContestInstance.find(params[:id]) + @contest_description = @contest_instance.contest_description + @container = @contest_description.container + + authorize @contest_instance + + @entries = @contest_instance.entries.active.includes(:profile, :category) + + respond_to do |format| + format.csv do + filename = "#{@contest_description.name.parameterize}-entries_printed-#{Time.zone.today}.csv" + + csv_data = generate_entries_csv(@entries, @contest_description, @contest_instance) + + send_data csv_data, + type: 'text/csv; charset=utf-8; header=present', + disposition: "attachment; filename=#{filename}" + end + end + end + private def authorize_container_access @@ -179,4 +201,46 @@ def contest_instance_params category_ids: [], class_level_ids: [] ) end + + def generate_entries_csv(entries, contest_description, contest_instance) + require 'csv' + + CSV.generate do |csv| + # Header section - split across multiple columns for better layout + contest_info = "#{contest_description.name} - #{contest_instance.date_open.strftime('%b %Y')} to #{contest_instance.date_closed.strftime('%b %Y')}" + + # Distribute header across columns more evenly + header_row1 = [contest_info] + Array.new(11, '') + csv << header_row1 + csv << Array.new(12, '') # Empty row as separator with 12 empty cells + + # Column headers + headers = [ + 'Title', 'Category', + 'Pen Name', 'First Name', 'Last Name', 'UMID', 'Uniqname', + 'Class Level', 'Campus', 'Entry ID', 'Created At', 'Disqualified' + ] + csv << headers + + # Entry data + entries.each do |entry| + profile = entry.profile + + csv << [ + entry.title, + entry.category&.kind, + entry.pen_name, + profile&.user&.first_name, + profile&.user&.last_name, + profile&.umid, + profile&.user&.uniqname, + profile&.class_level&.name, + profile&.campus&.campus_descr, + entry.id, + entry.created_at.strftime('%m/%d/%Y %I:%M %p'), + entry.disqualified? ? 'Yes' : 'No' + ] + end + end + end end diff --git a/app/javascript/controllers/email_preview_controller.js b/app/javascript/controllers/email_preview_controller.js new file mode 100644 index 00000000..110788eb --- /dev/null +++ b/app/javascript/controllers/email_preview_controller.js @@ -0,0 +1,51 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["baseEmail", "optionsEmail"] + static values = { type: { type: String, default: "base" } } + + connect() { + console.log("Email preview controller connected") + + // Add event listener for the Bootstrap modal shown event + const modal = this.element.closest('.modal') + if (modal) { + console.log("Modal found, adding event listener") + modal.addEventListener('shown.bs.modal', () => { + console.log("Modal shown event triggered") + console.log("Current type value:", this.typeValue) + this.updatePreview() + }) + } + } + + // Set the type value when a button is clicked + setType(event) { + const newType = event.currentTarget.dataset.previewType + console.log("Setting type to:", newType) + this.typeValue = newType + } + + // Update the preview based on the current type + updatePreview() { + console.log("Updating preview with type:", this.typeValue) + + if (this.typeValue === "base") { + this.showBaseEmail() + } else if (this.typeValue === "options") { + this.showOptionsEmail() + } + } + + showBaseEmail() { + console.log("Showing base email") + this.baseEmailTarget.style.display = "block" + this.optionsEmailTarget.style.display = "none" + } + + showOptionsEmail() { + console.log("Showing options email") + this.baseEmailTarget.style.display = "none" + this.optionsEmailTarget.style.display = "block" + } +} diff --git a/app/policies/container_policy.rb b/app/policies/container_policy.rb index 62266e3e..0b12c963 100644 --- a/app/policies/container_policy.rb +++ b/app/policies/container_policy.rb @@ -48,6 +48,10 @@ def description? true end + def active_applicants_report? + owns_container? || axis_mundi? + end + private def user_has_containers? diff --git a/app/policies/contest_instance_policy.rb b/app/policies/contest_instance_policy.rb index fffa44d5..9f68bf5f 100644 --- a/app/policies/contest_instance_policy.rb +++ b/app/policies/contest_instance_policy.rb @@ -65,4 +65,8 @@ def deactivate? def send_round_results? user&.has_container_role?(record.contest_description.container) || axis_mundi? end + + def export_entries? + user&.has_container_role?(record.contest_description.container) || axis_mundi? + end end diff --git a/app/services/active_applicants_report_service.rb b/app/services/active_applicants_report_service.rb new file mode 100644 index 00000000..9e487adb --- /dev/null +++ b/app/services/active_applicants_report_service.rb @@ -0,0 +1,28 @@ +class ActiveApplicantsReportService + def initialize(container:, contest_descriptions:) + @container = container + @active_contest_descriptions = contest_descriptions + end + + def call + # Get all active entries for the specified contest descriptions + # Use distinct to avoid duplicate profiles + Profile + .joins(:entries) + .joins('INNER JOIN contest_instances ON entries.contest_instance_id = contest_instances.id') + .joins('INNER JOIN contest_descriptions ON contest_instances.contest_description_id = contest_descriptions.id') + .joins(:user) + .where( + entries: { deleted: false, disqualified: false }, + contest_descriptions: { + id: @active_contest_descriptions.map(&:id), + active: true + }, + contest_instances: { + active: true + } + ) + .select('DISTINCT profiles.*, users.first_name, users.last_name, users.email') + .order('users.last_name, users.first_name') + end +end diff --git a/app/views/containers/_active_applicants_report_form.html.erb b/app/views/containers/_active_applicants_report_form.html.erb new file mode 100644 index 00000000..0bb762b1 --- /dev/null +++ b/app/views/containers/_active_applicants_report_form.html.erb @@ -0,0 +1,30 @@ +<%= turbo_frame_tag "active_applicants_report_form" do %> +
+
+
Active Applicants Report
+
+
+ <%= form_with url: active_applicants_report_container_path(@container, format: :csv), method: :get, data: { turbo: false } do |f| %> +
+ +
+ <% @active_contest_descriptions.each do |contest_description| %> +
+ <%= check_box_tag "contest_description_ids[]", + contest_description.id, + false, + class: "form-check-input", + id: "contest_description_#{contest_description.id}" %> + <%= label_tag "contest_description_#{contest_description.id}", + contest_description.name, + class: "form-check-label" %> +
+ <% end %> +
+
+ + <%= f.submit "Generate Report", class: "btn btn-primary" %> + <% end %> +
+
+<% end %> diff --git a/app/views/containers/show.html.erb b/app/views/containers/show.html.erb index 63d2a9b9..49c06f62 100644 --- a/app/views/containers/show.html.erb +++ b/app/views/containers/show.html.erb @@ -12,6 +12,11 @@
+

Reports

+<%= render 'active_applicants_report_form' %> + +
+

User Permissions

<% if (content = render_editable_content('container', 'permissions')) %> <%= content %> diff --git a/app/views/contest_instances/_contest_instance_entries.html.erb b/app/views/contest_instances/_contest_instance_entries.html.erb index 2165a9a3..c80c02ba 100644 --- a/app/views/contest_instances/_contest_instance_entries.html.erb +++ b/app/views/contest_instances/_contest_instance_entries.html.erb @@ -1,5 +1,17 @@ <% if @contest_instance_entries.any? %> -

<%= pluralize(@contest_instance_entries.count, 'Entry') %>

+
+

<%= pluralize(@contest_instance_entries.count, 'Entry') %>

+ <%= link_to export_entries_container_contest_description_contest_instance_path( + @container, + @contest_description, + @contest_instance, + format: :csv + ), + class: "btn btn-sm btn-outline-primary", + data: { turbo: false } do %> + Export to CSV + <% end %> +
diff --git a/app/views/contest_instances/email_preferences.html.erb b/app/views/contest_instances/email_preferences.html.erb index 4faed82a..a7960aac 100644 --- a/app/views/contest_instances/email_preferences.html.erb +++ b/app/views/contest_instances/email_preferences.html.erb @@ -7,6 +7,18 @@

Select which information to include in the evaluation result emails for this round: + +

<%= form_with url: send_round_results_container_contest_description_contest_instance_path( @@ -47,3 +59,43 @@ <% end %>
+ + + + + + diff --git a/config/routes.rb b/config/routes.rb index d466808c..64087bb1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,7 @@ member do get 'email_preferences' post 'send_round_results' + get :export_entries end resources :judging_rounds do member do @@ -84,6 +85,7 @@ resources :assignments, only: %i[create destroy] member do get 'description' + get :active_applicants_report end end diff --git a/spec/controllers/contest_instances_controller_spec.rb b/spec/controllers/contest_instances_controller_spec.rb index 8b19b123..88f17ef5 100644 --- a/spec/controllers/contest_instances_controller_spec.rb +++ b/spec/controllers/contest_instances_controller_spec.rb @@ -239,4 +239,275 @@ end end end + + describe 'GET #export_entries' do + let(:department) { create(:department) } + let(:container) { create(:container, department: department) } + let(:contest_description) { create(:contest_description, container: container) } + + context 'with authorized users' do + let(:user) { create(:user, :axis_mundi) } + + before do + sign_in user + end + + context 'contest instance with entries' do + let(:contest_instance) do + create(:contest_instance, + contest_description: contest_description, + require_pen_name: true, + require_campus_employment_info: true) + end + + let(:profile1) { create(:profile, class_level: create(:class_level, name: 'Freshman')) } + let(:profile2) { create(:profile, class_level: create(:class_level, name: 'Senior')) } + let(:profile3) { create(:profile, class_level: create(:class_level, name: 'Graduate')) } + + let!(:entry1) do + create(:entry, + contest_instance: contest_instance, + profile: profile1, + pen_name: 'Writer One', + campus_employee: false, + title: 'Entry One') + end + + let!(:entry2) do + create(:entry, + contest_instance: contest_instance, + profile: profile2, + pen_name: 'Writer Two', + campus_employee: true, + title: 'Entry Two') + end + + let!(:entry3) do + create(:entry, + contest_instance: contest_instance, + profile: profile3, + pen_name: 'Writer Three', + campus_employee: false, + disqualified: true, + title: 'Entry Three') + end + + it 'returns a CSV file' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + format: :csv + } + + expect(response).to be_successful + expect(response.content_type).to include('text/csv') + expect(response.headers['Content-Disposition']).to include('attachment') + expect(response.headers['Content-Disposition']).to include('.csv') + end + + it 'includes all active entries in the CSV' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + format: :csv + } + + csv = CSV.parse(response.body) + # Skip header rows (contest info and empty row) + data_rows = csv[3..-1] # Starting from the fourth row (index 3) which has actual entry data + + # Should include all active entries + expect(data_rows.length).to eq(3) + + # Check for specific entry details + expect(csv.to_s).to include('Entry One') + expect(csv.to_s).to include('Entry Two') + expect(csv.to_s).to include('Entry Three') + expect(csv.to_s).to include('Writer One') + expect(csv.to_s).to include('Writer Two') + expect(csv.to_s).to include('Writer Three') + + # Check for class levels + expect(csv.to_s).to include('Freshman') + expect(csv.to_s).to include('Senior') + expect(csv.to_s).to include('Graduate') + + # Check for disqualified status + expect(csv.to_s).to include('Yes') # For disqualified entry + expect(csv.to_s).to include('No') # For non-disqualified entries + end + + it 'generates CSV with correct structure and headers' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + format: :csv + } + + csv = CSV.parse(response.body) + + # Row 0: Contest info header + expect(csv[0][0]).to include(contest_description.name) + + # Row 1: Should be separator row - check it doesn't contain significant content + expect(csv[1].join.strip).to be_empty + + # Row 2: Column headers (12 columns as per generate_entries_csv method) + expected_headers = [ + 'Title', 'Category', + 'Pen Name', 'First Name', 'Last Name', 'UMID', 'Uniqname', + 'Class Level', 'Campus', 'Entry ID', 'Created At', 'Disqualified' + ] + expect(csv[2]).to eq(expected_headers) + + # Check structure of data rows + entry_row = csv.find { |row| row[0] == 'Entry One' } + expect(entry_row).not_to be_nil + + # For entry1 + expect(entry_row[0]).to eq('Entry One') # Title + expect(entry_row[2]).to eq('Writer One') # Pen Name + expect(entry_row[3]).to eq(profile1.user.first_name) # First Name + expect(entry_row[4]).to eq(profile1.user.last_name) # Last Name + expect(entry_row[5]).to eq(profile1.umid) # UMID + expect(entry_row[6]).to eq(profile1.user.uniqname) # Uniqname + expect(entry_row[7]).to eq('Freshman') # Class Level + expect(entry_row[9]).to eq(entry1.id.to_s) # Entry ID + expect(entry_row[11]).to eq('No') # Disqualified + + # For disqualified entry + disqualified_row = csv.find { |row| row[0] == 'Entry Three' } + expect(disqualified_row).not_to be_nil + expect(disqualified_row[11]).to eq('Yes') # Disqualified + end + end + + context 'contest instance without entries' do + let(:empty_contest_instance) { create(:contest_instance, contest_description: contest_description) } + + it 'returns a CSV with only headers' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: empty_contest_instance.id, + format: :csv + } + + expect(response).to be_successful + expect(response.content_type).to include('text/csv') + + csv = CSV.parse(response.body) + # Should have header rows but no data rows + expect(csv.length).to be >= 3 + expect(csv[3..-1]).to be_empty if csv.length > 3 + end + end + + context 'contest instance with entries but no optional fields' do + let(:basic_contest_instance) do + create(:contest_instance, + contest_description: contest_description, + require_pen_name: false, + require_campus_employment_info: false) + end + + let(:profile) { create(:profile) } + + let!(:basic_entry) do + create(:entry, + contest_instance: basic_contest_instance, + profile: profile, + pen_name: nil, + title: 'Basic Entry') + end + + it 'returns a CSV with entries lacking optional fields' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: basic_contest_instance.id, + format: :csv + } + + expect(response).to be_successful + + csv = CSV.parse(response.body) + # Check header rows and entry data + expect(csv.to_s).to include('Basic Entry') + expect(csv.to_s).to include(profile.user.first_name) + expect(csv.to_s).to include(profile.user.last_name) + end + end + end + + context 'with container manager user' do + let(:container_user) { create(:user, :axis_mundi) } # Make the user axis_mundi + let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } + + before do + sign_in container_user + end + + it 'allows export access' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + format: :csv + } + + expect(response).to be_successful + expect(response.content_type).to include('text/csv') + end + end + + context 'with unauthorized user' do + let(:regular_user) { create(:user) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } + + before do + sign_in regular_user + end + + it 'denies access to export entries' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + format: :csv + } + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to match(/not authorized/i) + end + end + + context 'with judge user without container role' do + let(:judge_user) { create(:user) } + let(:judge_role) { create(:role, :judge) } + let!(:user_role) { create(:user_role, user: judge_user, role: judge_role) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } + + before do + # First give the user a judge role, then add as a judge + contest_instance.judges << judge_user + sign_in judge_user + end + + it 'denies access to export entries even for judges' do + get :export_entries, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + format: :csv + } + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to match(/not authorized/i) + end + end + end end