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 %>
+
+
+
+ <%= 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 %>
+
+
+
+
+
+
+
+
+ <%= image_tag asset_path("base_email.png"), class: "img-fluid", alt: "Base email preview" %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= image_tag asset_path("email_with_extra_info.png"), class: "img-fluid", alt: "Email with options preview" %>
+
+
+
+
+
+
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