diff --git a/.rubocop.yml b/.rubocop.yml index 80214a19..69ca5324 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -23,7 +23,7 @@ Rails/UnknownEnv: - test - production - staging - + # String Literals Style Style/StringLiterals: EnforcedStyle: single_quotes @@ -51,4 +51,7 @@ Capybara: # RSpec Rails settings RSpecRails: - Enabled: true \ No newline at end of file + Enabled: true + +RSpec/MultipleMemoizedHelpers: + Max: 12 diff --git a/Capfile b/Capfile index 2e5151cb..ca7a5777 100644 --- a/Capfile +++ b/Capfile @@ -30,6 +30,9 @@ install_plugin Capistrano::SCM::Git require 'capistrano/rails' require 'capistrano/bundler' require 'capistrano/asdf' +require 'capistrano/sidekiq' +install_plugin Capistrano::Sidekiq +install_plugin Capistrano::Sidekiq::Systemd # Load custom tasks from `lib/capistrano/tasks` if you have any defined Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r } diff --git a/Gemfile b/Gemfile index a9b55378..7831d5e1 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem 'pagy', '~> 6.4' gem 'puma' gem 'pundit' gem 'redis', '~> 5.0' +gem 'sidekiq', '~> 7.3' gem 'sassc-rails' gem 'simple_form', '~> 5.3' gem 'stimulus-rails' @@ -37,6 +38,7 @@ group :development do gem 'capistrano', '~> 3.17', require: false gem 'capistrano-rails', '~> 1.6', '>= 1.6.1', require: false gem 'capistrano-asdf', require: false + gem 'capistrano-sidekiq', '~> 2.0', require: false gem 'web-console' end diff --git a/Gemfile.lock b/Gemfile.lock index dd912d48..8a76c759 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,10 @@ GEM capistrano-rails (1.6.3) capistrano (~> 3.1) capistrano-bundler (>= 1.1, < 3) + capistrano-sidekiq (2.3.1) + capistrano (>= 3.9.0) + capistrano-bundler + sidekiq (>= 6.0) capybara (3.40.0) addressable matrix @@ -121,7 +125,7 @@ GEM logger (~> 1.5) coderay (1.1.3) concurrent-ruby (1.3.4) - connection_pool (2.4.1) + connection_pool (2.5.0) countries (6.0.1) unaccent (~> 0.3) country_select (9.0.0) @@ -232,7 +236,7 @@ GEM letter_opener (~> 1.9) railties (>= 6.1) rexml - logger (1.6.2) + logger (1.6.6) loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -268,20 +272,20 @@ GEM net-protocol net-ssh (7.2.3) nio4r (2.7.3) - nokogiri (1.18.3-arm64-darwin) + nokogiri (1.18.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-gnu) + nokogiri (1.18.4-x86_64-linux-gnu) racc (~> 1.4) - omniauth (2.1.2) + omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.1.2) + omniauth-saml (2.1.3) omniauth (~> 2.1) - ruby-saml (~> 1.17) + ruby-saml (~> 1.18) orm_adapter (0.5.0) os (1.1.4) pagy (6.5.0) @@ -310,11 +314,12 @@ GEM rspec-mocks (~> 3.12) rspec-support (~> 3.12) racc (1.8.1) - rack (3.1.10) + rack (3.1.12) rack-accept (0.4.5) rack (>= 0.4) - rack-protection (4.0.0) + rack-protection (4.1.1) base64 (>= 0.1.0) + logger (>= 1.6.0) rack (>= 3.0.0, < 4) rack-session (2.0.0) rack (>= 3.0.0) @@ -362,7 +367,7 @@ GEM psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) - redis-client (0.22.2) + redis-client (0.24.0) connection_pool regexp_parser (2.9.2) reline (0.5.9) @@ -375,7 +380,7 @@ GEM actionpack (>= 5.2) railties (>= 5.2) retriable (3.1.2) - rexml (3.3.9) + rexml (3.4.1) rouge (4.3.0) rspec-core (3.13.1) rspec-support (~> 3.13.0) @@ -432,7 +437,7 @@ GEM rubocop (~> 1.61) rubocop-rspec (~> 3, >= 3.0.1) ruby-progressbar (1.13.0) - ruby-saml (1.17.0) + ruby-saml (1.18.0) nokogiri (>= 1.13.10) rexml ruby-vips (2.2.2) @@ -452,6 +457,12 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) + sidekiq (7.3.9) + base64 + connection_pool (>= 2.3.0) + logger + rack (>= 2.2.4) + redis-client (>= 0.22.2) signet (0.19.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -501,7 +512,7 @@ GEM uber (0.1.0) unaccent (0.4.0) unicode-display_width (2.5.0) - uri (0.13.1) + uri (0.13.2) useragent (0.16.11) warden (1.2.9) rack (>= 2.0.9) @@ -536,6 +547,7 @@ DEPENDENCIES capistrano (~> 3.17) capistrano-asdf capistrano-rails (~> 1.6, >= 1.6.1) + capistrano-sidekiq (~> 2.0) capybara country_select cssbundling-rails @@ -571,6 +583,7 @@ DEPENDENCIES rubocop-rspec_rails sassc-rails selenium-webdriver + sidekiq (~> 7.3) simple_form (~> 5.3) simplecov skylight diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..6d19a6cf --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: bundle exec puma -C config/puma.rb +worker: bundle exec sidekiq -e ${RAILS_ENV:-development} -C config/sidekiq.yml diff --git a/README.md b/README.md index 822d02bf..1320ce5c 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,20 @@ In its initial implementation, LSA Evaluate will serve the University of Michiga rails server ``` +## Email Configuration with SendGrid + +This application uses SendGrid for email delivery in the production environment. Follow these steps to set it up: + +1. Create a SendGrid account if you don't have one already +2. Generate an API key in the SendGrid dashboard +3. Set the following environment variables in your production environment: + ``` + SENDGRID_USERNAME=apikey + SENDGRID_API_KEY=your_sendgrid_api_key_here + DOMAIN_NAME=yourdomain.com + ``` +4. Ensure Sidekiq is set up and running to process emails asynchronously + +Emails are automatically configured to be sent asynchronously through Sidekiq background jobs. + ## This project is licensed under the [MIT License](https://github.com/your-repo/lsa-evaluate/blob/main/LICENSE) diff --git a/app/controllers/containers_controller.rb b/app/controllers/containers_controller.rb index a4b1f7f3..aa76f1d7 100644 --- a/app/controllers/containers_controller.rb +++ b/app/controllers/containers_controller.rb @@ -110,7 +110,7 @@ def authorize_index end def container_params - params.require(:container).permit(:name, :description, :notes, :department_id, :visibility_id, + params.require(:container).permit(:name, :description, :notes, :contact_email, :department_id, :visibility_id, assignments_attributes: %i[id user_id role_id _destroy]) end end diff --git a/app/controllers/contest_instances_controller.rb b/app/controllers/contest_instances_controller.rb index 4d3e727f..4233ba06 100644 --- a/app/controllers/contest_instances_controller.rb +++ b/app/controllers/contest_instances_controller.rb @@ -1,7 +1,7 @@ class ContestInstancesController < ApplicationController before_action :set_container before_action :set_contest_description - before_action :set_contest_instance, only: %i[show edit update destroy] + before_action :set_contest_instance, only: %i[show edit update destroy send_round_results] before_action :authorize_container_access # GET /contest_instances @@ -78,6 +78,70 @@ def destroy end end + def email_preferences + set_contest_instance + authorize @contest_instance, :send_round_results? + + round_id = params[:round_id] + @judging_round = @contest_instance.judging_rounds.find_by(id: round_id) + + if @judging_round.nil? + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + alert: 'Judging round not found.' + return + end + + if !@judging_round.completed? + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + alert: 'Cannot send results for an incomplete judging round.' + nil + end + end + + def send_round_results + authorize @contest_instance, :send_round_results? + + round_id = params[:round_id] + judging_round = @contest_instance.judging_rounds.find_by(id: round_id) + + if judging_round.nil? + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + alert: 'Judging round not found.' + return + end + + if !judging_round.completed? + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + alert: 'Cannot send results for an incomplete judging round.' + return + end + + # Update email preferences if they were provided + if params[:include_average_ranking].present? || params[:include_advancement_status].present? + judging_round.update( + include_average_ranking: params[:include_average_ranking] == '1', + include_advancement_status: params[:include_advancement_status] == '1' + ) + end + + # Get all entries for this round + entries = judging_round.entries.uniq + + email_count = 0 + + # Send an email for each entry + entries.each do |entry| + ResultsMailer.entry_evaluation_notification(entry, judging_round).deliver_later + email_count += 1 + end + + # Increment the emails sent counter for this round + judging_round.increment!(:emails_sent_count) + + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + notice: "Successfully queued #{email_count} evaluation result emails for round #{judging_round.round_number}. This is email batch ##{judging_round.emails_sent_count}." + end + private def authorize_container_access diff --git a/app/mailers/results_mailer.rb b/app/mailers/results_mailer.rb new file mode 100644 index 00000000..797e0b7d --- /dev/null +++ b/app/mailers/results_mailer.rb @@ -0,0 +1,44 @@ +class ResultsMailer < ApplicationMailer + def entry_evaluation_notification(entry, round) + @entry = entry + @round = round + @profile = entry.profile + @user = @profile.user + @contest_instance = entry.contest_instance + @contest_description = @contest_instance.contest_description + @container = @contest_description.container + + # Get the contact email from the container, with fallbacks if not present + @contact_email = @container.contact_email.presence || + Rails.application.credentials.dig(:mailer, :default_contact_email) || + Rails.application.credentials.dig(:devise, :mailer_sender) || + 'contests@example.com' + + # Get all rankings for this entry in this round + @rankings = EntryRanking.where(entry: @entry, judging_round: @round) + + # Calculate the average rank + @avg_rank = @round.average_rank_for_entry(@entry) + + # Check if the entry was selected for the next round + @selected_for_next_round = @rankings.any?(&:selected_for_next_round?) + + # Only include external comments that are meant to be shared with applicants + # and include the judge information with each comment + @external_comments_with_judges = @rankings.map do |ranking| + if ranking.external_comments.present? && !ranking.external_comments.empty? + { + comment: ranking.external_comments, + judge: ranking.user.display_name_or_first_name_last_name + } + end + end.compact + + subject = "Evaluation Results for \"#{@entry.title}\" - #{@contest_description.name}" + + mail( + to: @user.email, + subject: subject + ) + end +end diff --git a/app/mailers/test_mailer.rb b/app/mailers/test_mailer.rb new file mode 100644 index 00000000..7c471b73 --- /dev/null +++ b/app/mailers/test_mailer.rb @@ -0,0 +1,8 @@ +class TestMailer < ApplicationMailer + def test_email + mail( + to: 'test-t68vvtnfc@srv1.mail-tester.com', + subject: 'Test Email from LSA Evaluate' + ) + end +end diff --git a/app/models/container.rb b/app/models/container.rb index 27120add..9ac37f82 100644 --- a/app/models/container.rb +++ b/app/models/container.rb @@ -5,6 +5,7 @@ # Table name: containers # # id :bigint not null, primary key +# contact_email :string(255) # name :string(255) # notes :text(65535) # created_at :datetime not null @@ -40,6 +41,8 @@ class Container < ApplicationRecord validates :name, presence: true, uniqueness: true validates :department_id, presence: { message: 'You must select a department' } validates :visibility_id, presence: { message: 'You must select a visibility option' } + validates :contact_email, presence: { message: 'You must enter a contact email' } + validates :contact_email, format: { with: URI::MailTo::EMAIL_REGEXP, message: 'You must enter a valid email address' } scope :visible, -> { joins(:visibility).where(visibilities: { kind: 'Public' }) } # Only show containers with 'Public' visibility diff --git a/app/models/judging_round.rb b/app/models/judging_round.rb index fdc3fd1b..e7c74c29 100644 --- a/app/models/judging_round.rb +++ b/app/models/judging_round.rb @@ -5,7 +5,10 @@ # id :bigint not null, primary key # active :boolean default(FALSE), not null # completed :boolean default(FALSE), not null +# emails_sent_count :integer default(0), not null # end_date :datetime +# include_advancement_status :boolean default(FALSE) +# include_average_ranking :boolean default(FALSE) # min_external_comment_words :integer default(0), not null # min_internal_comment_words :integer default(0), not null # require_external_comments :boolean default(FALSE), not null @@ -48,6 +51,11 @@ class JudgingRound < ApplicationRecord before_create :set_active_by_default + # Alias for completed? to match what's used in views + def complete? + completed? + end + def activate! return false unless valid? diff --git a/app/policies/contest_instance_policy.rb b/app/policies/contest_instance_policy.rb index 0cf147a5..fffa44d5 100644 --- a/app/policies/contest_instance_policy.rb +++ b/app/policies/contest_instance_policy.rb @@ -58,7 +58,11 @@ def activate? user&.has_container_role?(record.contest_description.container) || axis_mundi? end - def deactivate? + def deactivate? + user&.has_container_role?(record.contest_description.container) || axis_mundi? + end + + def send_round_results? user&.has_container_role?(record.contest_description.container) || axis_mundi? end end diff --git a/app/views/containers/_form.html.erb b/app/views/containers/_form.html.erb index 3471a71f..142b67fc 100644 --- a/app/views/containers/_form.html.erb +++ b/app/views/containers/_form.html.erb @@ -6,16 +6,18 @@
<%= f.input :name, required: true %> <%= f.input :description, as: :rich_text_area %> - - <%= f.input :department_id, - collection: Department.all, - label_method: :name, - value_method: :id, + <%= f.input :contact_email, + hint: "This email will be displayed in notifications to applicants as the contact for questions about contests in this collection." %> + + <%= f.input :department_id, + collection: Department.all, + label_method: :name, + value_method: :id, required: true %> - - <%= f.input :visibility_id, - collection: Visibility.all, - label_method: :kind, + + <%= f.input :visibility_id, + collection: Visibility.all, + label_method: :kind, value_method: :id, hint: safe_join([ content_tag(:strong, "Public"), " visibility means that this collection of contests will be visible in the applicant's dashboard. ", diff --git a/app/views/contest_instances/_judging_results.html.erb b/app/views/contest_instances/_judging_results.html.erb index 33e87ec2..28331c5d 100644 --- a/app/views/contest_instances/_judging_results.html.erb +++ b/app/views/contest_instances/_judging_results.html.erb @@ -1,107 +1,134 @@
-
- <% if @contest_instance.judging_rounds.any? %> - <% @contest_instance.judging_rounds.order(:round_number).each do |round| %> -
-

Round <%= round.round_number %>

-
- - - - - - - - - - - - - <% entries_with_avg_rank = round.entries.distinct.map do |entry| - avg_rank = entry.entry_rankings.where(judging_round: round).average(:rank) - [entry, avg_rank || Float::INFINITY] - end.sort_by { |_, avg_rank| avg_rank } %> + <% if @contest_instance.judging_rounds.any? %> + <% @contest_instance.judging_rounds.order(:round_number).each do |round| %> +
+

Round <%= round.round_number %>

+
+
+ <%= link_to email_preferences_container_contest_description_contest_instance_path( + @container, + @contest_description, + @contest_instance, + round_id: round.id + ), + class: "btn btn-sm btn-info me-3", + disabled: !round.complete? do %> + + Email round <%= round.round_number %> results + <% end %> +
- <% entries_with_avg_rank.each do |entry, _| %> -
- - - - - + + + <% end %> + +
Entry IDTitleAverage RankIndividual RankingsCommentsSelected for Next Round
<%= entry.id %><%= entry.title %> - <%= entry.entry_rankings.where(judging_round: round).average(:rank)&.round(2) || 'No rankings' %> - -
- <% rankings = entry.entry_rankings.where(judging_round: round) %> - <% if rankings.any? %> - <% rankings.each do |ranking| %> -
-
- <%= content_tag(:span, - display_email(ranking.user.email), - data: { - 'bs-toggle': 'tooltip', - 'bs-html': 'true', - 'bs-title': "#{h(ranking.user.display_name_or_first_name_last_name)}
#{h(display_email(ranking.user.email))}" - } - ) %> -
- <%= ranking.rank || 'Not ranked' %> -
- <% end %> - <% else %> -
No rankings yet
- <% end %> -
-
-
- <% rankings = entry.entry_rankings.where(judging_round: round) %> - <% has_comments = false %> + <% if round.emails_sent_count > 0 %> + + + Emails sent: <%= round.emails_sent_count %> time<%= 's' if round.emails_sent_count > 1 %> + + <% end %> +
+
+ + + + + + + + + + + + + <% entries_with_avg_rank = round.entries.distinct.map do |entry| + avg_rank = entry.entry_rankings.where(judging_round: round).average(:rank) + [entry, avg_rank || Float::INFINITY] + end.sort_by { |_, avg_rank| avg_rank } %> + + <% entries_with_avg_rank.each do |entry, _| %> + + + + + + - - - <% end %> - -
Entry IDTitleAverage RankIndividual RankingsCommentsSelected for Next Round
<%= entry.id %><%= entry.title %> + <%= entry.entry_rankings.where(judging_round: round).average(:rank)&.round(2) || 'No rankings' %> + +
+ <% rankings = entry.entry_rankings.where(judging_round: round) %> + <% if rankings.any? %> <% rankings.each do |ranking| %> - <% if ranking.external_comments.present? || ranking.internal_comments.present? %> - <% has_comments = true %> -
- <%= content_tag(:span, +
+
+ <%= content_tag(:span, display_email(ranking.user.email), data: { 'bs-toggle': 'tooltip', 'bs-html': 'true', 'bs-title': "#{h(ranking.user.display_name_or_first_name_last_name)}
#{h(display_email(ranking.user.email))}" } - ) %>
- <% if ranking.external_comments.present? %> -
- External: - <%= ranking.external_comments %> -
- <% end %> - <% if ranking.internal_comments.present? %> -
- Internal: - <%= ranking.internal_comments %> -
- <% end %> + ) %>
- <% end %> + <%= ranking.rank || 'Not ranked' %> +
<% end %> - <% unless has_comments %> -
No comments yet
+ <% else %> +
No rankings yet
+ <% end %> +
+
+
+ <% rankings = entry.entry_rankings.where(judging_round: round) %> + <% has_comments = false %> + <% rankings.each do |ranking| %> + <% if ranking.external_comments.present? || ranking.internal_comments.present? %> + <% has_comments = true %> +
+ <%= content_tag(:span, + display_email(ranking.user.email), + data: { + 'bs-toggle': 'tooltip', + 'bs-html': 'true', + 'bs-title': "#{h(ranking.user.display_name_or_first_name_last_name)}
#{h(display_email(ranking.user.email))}" + } + ) %>
+ <% if ranking.external_comments.present? %> +
+ External: + <%= ranking.external_comments %> +
+ <% end %> + <% if ranking.internal_comments.present? %> +
+ Internal: + <%= ranking.internal_comments %> +
+ <% end %> +
<% end %> -
-
-
- <%= boolean_to_yes_no(entry.entry_rankings.where(judging_round: round, selected_for_next_round: true).exists?) %> -
-
+ <% end %> + <% unless has_comments %> +
No comments yet
+ <% end %> +
+ +
+ <%= boolean_to_yes_no(entry.entry_rankings.where(judging_round: round, selected_for_next_round: true).exists?) %> +
- <% end %> - <% else %> -

No judging rounds have been created yet.

+
<% end %> -
+ <% else %> +

No judging rounds have been created yet.

+ <% end %>
diff --git a/app/views/contest_instances/_manage_judges.html.erb b/app/views/contest_instances/_manage_judges.html.erb index 22195a9d..8f2d695f 100644 --- a/app/views/contest_instances/_manage_judges.html.erb +++ b/app/views/contest_instances/_manage_judges.html.erb @@ -1,93 +1,84 @@ - <%# Judging Information Section - Kept as a distinct card %> -
-
- -

Total Judging Rounds: - <%= contest_instance.actual_judging_rounds_count %>

-
- <%= render 'management_links' %> +
+

Total Judging Rounds:<%= contest_instance.actual_judging_rounds_count %>

+ <%= render 'management_links' %> +
+ <% contest_instance.judging_rounds.order(:round_number).each do |round| %> +
+
+
Round <%= round.round_number %>
+ + <%= round.active ? 'Active' : (round.completed ? 'Completed' : 'Pending') %> + +
+
+
+
+ Dates: + <%= format_datetime(round.start_date) %> - <%= format_datetime(round.end_date) %> +
+
-
- <% contest_instance.judging_rounds.order(:round_number).each do |round| %> -
-
-
Round <%= round.round_number %>
- - <%= round.active ? 'Active' : (round.completed ? 'Completed' : 'Pending') %> - +
+
+
+ Judges Assigned: +
+ <% if round.judges.any? %> + <% round.judges.each do |judge| %> + + <%= judge.display_name_and_uid %> + + <% end %> + <% else %> + No judges assigned to this round + <% end %>
-
-
-
- Dates: - <%= format_datetime(round.start_date) %> - <%= format_datetime(round.end_date) %> -
-
- -
-
-
- Judges Assigned: -
- <% if round.judges.any? %> - <% round.judges.each do |judge| %> - - <%= judge.display_name_and_uid %> - - <% end %> - <% else %> - No judges assigned to this round - <% end %> -
-
-
-
- -
-
- Minimum entries to be evaluated per judge: - <%= round.required_entries_count %> -
-
+
+
+
-
- Comment requirements: -
-

Internal: - <%= round.require_internal_comments ? 'Required' : 'Optional' %> - <% if round.require_internal_comments && round.min_internal_comment_words > 0 %> - (minimum <%= pluralize(round.min_internal_comment_words, 'word') %>) - <% end %> -

-
-
-

External: - <%= round.require_external_comments ? 'Required' : 'Optional' %> - <% if round.require_external_comments && round.min_external_comment_words > 0 %> - (minimum <%= pluralize(round.min_external_comment_words, 'word') %>) - <% end %> -

-
-
-
+
+
+ Minimum entries to be evaluated per judge: + <%= round.required_entries_count %> +
+
- <% if round.special_instructions.present? %> -
-
-
- Instructions:
- <%= simple_format(round.special_instructions) %> -
-
-
+
+ Comment requirements: +
+

Internal: + <%= round.require_internal_comments ? 'Required' : 'Optional' %> + <% if round.require_internal_comments && round.min_internal_comment_words > 0 %> + (minimum <%= pluralize(round.min_internal_comment_words, 'word') %>) <% end %> -

+

+
+
+

External: + <%= round.require_external_comments ? 'Required' : 'Optional' %> + <% if round.require_external_comments && round.min_external_comment_words > 0 %> + (minimum <%= pluralize(round.min_external_comment_words, 'word') %>) + <% end %> +

- <% end %>
+ + <% if round.special_instructions.present? %> +
+
+
+ Instructions:
+ <%= simple_format(round.special_instructions) %> +
+
+
+ <% end %>
-
+ <% end %> +
+
diff --git a/app/views/contest_instances/email_preferences.html.erb b/app/views/contest_instances/email_preferences.html.erb new file mode 100644 index 00000000..4faed82a --- /dev/null +++ b/app/views/contest_instances/email_preferences.html.erb @@ -0,0 +1,49 @@ +

Email Results Preferences

+ +
+
+

Round <%= @judging_round.round_number %> Email Content Options

+
+
+

+ 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( + @container, + @contest_description, + @contest_instance, + round_id: @judging_round.id + ), + method: :post, + class: "email-preferences-form", + data: { + controller: "confirm", + confirm_message_value: "Are you sure you want to send evaluation results to all applicants for round #{@judging_round.round_number}?" + } do |f| %> + +
+ <%= f.check_box :include_average_ranking, + id: "include_average_ranking", + class: "form-check-input", + checked: @judging_round.include_average_ranking %> + <%= f.label :include_average_ranking, "Include average ranking information", class: "form-check-label" %> +
When checked, emails will include the average ranking each entry received from judges.
+
+ +
+ <%= f.check_box :include_advancement_status, + id: "include_advancement_status", + class: "form-check-input", + checked: @judging_round.include_advancement_status %> + <%= f.label :include_advancement_status, "Include advancement status information", class: "form-check-label" %> +
When checked, emails will include whether the entry was selected to advance to the next round or was selected as a finalist.
+
+ +
+ <%= f.submit "Send Emails", class: "btn btn-primary me-2" %> + <%= link_to "Cancel", container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), class: "btn btn-secondary" %> +
+ <% end %> +
+
diff --git a/app/views/mailers/results_mailer/entry_evaluation_notification.html.erb b/app/views/mailers/results_mailer/entry_evaluation_notification.html.erb new file mode 100644 index 00000000..ea7009c6 --- /dev/null +++ b/app/views/mailers/results_mailer/entry_evaluation_notification.html.erb @@ -0,0 +1,149 @@ + + + + + + + +
+
+ + +

<%= @contest_description.name %>

+
+ +

Dear <%= @user.display_name_or_first_name_last_name %>,

+ +

Thank you for your submission to the <%= @contest_description.name %>. We appreciate your participation and the effort you put into your work.

+ +
+

Your Entry Details

+

Title: <%= @entry.title %>

+

Submitted: <%= @entry.created_at.strftime("%B %d, %Y") %>

+ <% if @entry.category.present? %> +

Category: <%= @entry.category.kind %>

+ <% end %> + <% if @entry.pen_name.present? %> +

Pen Name: <%= @entry.pen_name %>

+ <% end %> +
+ +

Evaluation Results - Round <%= @round.round_number %>

+ + <% if @avg_rank.present? && @round.include_average_ranking %> +

Your submission received an average ranking of <%= @avg_rank %> from our judging panel.

+ <% end %> + + <% if @round.include_advancement_status %> + <% if @selected_for_next_round && @contest_instance.judging_rounds.exists?(round_number: @round.round_number + 1) %> +
+

Congratulations! Your entry has been selected to advance to Round <%= @round.round_number + 1 %>.

+
+ <% elsif @round.round_number == @contest_instance.judging_rounds.maximum(:round_number) %> + <% if @selected_for_next_round %> +
+

Congratulations! Your entry has been selected as a finalist.

+
+ <% else %> +
+

We regret to inform you that your entry was not selected as a finalist.

+
+ <% end %> + <% elsif !@selected_for_next_round %> +
+

We regret to inform you that your entry was not selected to advance to the next round of judging.

+
+ <% end %> + <% end %> + + <% if @external_comments_with_judges.any? %> +

Feedback from Judges

+

Our judges have provided the following feedback on your submission:

+ + <% @external_comments_with_judges.each do |comment_data| %> +
+

From Judge: <%= comment_data[:judge] %>

+ <%= simple_format(comment_data[:comment]) %> +
+ <% end %> + <% end %> + +

We value your creativity and hope you found this competition to be a rewarding experience. Your participation contributes to the vibrant artistic and academic community at our university.

+ + <% if @contact_email.present? %> +

If you have any questions about the judging process or results, please contact <%= @contact_email %>.

+ <% end %> + + +
+ + diff --git a/app/views/mailers/results_mailer/entry_evaluation_notification.text.erb b/app/views/mailers/results_mailer/entry_evaluation_notification.text.erb new file mode 100644 index 00000000..1f585ed2 --- /dev/null +++ b/app/views/mailers/results_mailer/entry_evaluation_notification.text.erb @@ -0,0 +1,54 @@ +<%= @contest_description.name %> + +Dear <%= @user.display_name_or_first_name_last_name %>, + +Thank you for your submission to the <%= @contest_description.name %>. We appreciate your participation and the effort you put into your work. + +YOUR ENTRY DETAILS +================= +Title: <%= @entry.title %> +Submitted: <%= @entry.created_at.strftime("%B %d, %Y") %> +<% if @entry.category.present? %>Category: <%= @entry.category.kind %><% end %> +<% if @entry.pen_name.present? %>Pen Name: <%= @entry.pen_name %><% end %> + +EVALUATION RESULTS - ROUND <%= @round.round_number %> +================= +<% if @avg_rank.present? && @round.include_average_ranking %> +Your submission received an average ranking of <%= @avg_rank %> from our judging panel. +<% end %> + +<% if @round.include_advancement_status %> +<% if @selected_for_next_round && @contest_instance.judging_rounds.exists?(round_number: @round.round_number + 1) %> +CONGRATULATIONS! Your entry has been selected to advance to Round <%= @round.round_number + 1 %>. +<% elsif @round.round_number == @contest_instance.judging_rounds.maximum(:round_number) %> +<% if @selected_for_next_round %> +CONGRATULATIONS! Your entry has been selected as a finalist. +<% else %> +We regret to inform you that your entry was not selected as a finalist. +<% end %> +<% elsif !@selected_for_next_round %> +We regret to inform you that your entry was not selected to advance to the next round of judging. +<% end %> +<% end %> + +<% if @external_comments_with_judges.any? %> +FEEDBACK FROM JUDGES +================= +Our judges have provided the following feedback on your submission: + +<% @external_comments_with_judges.each do |comment_data| %> +* From Judge: <%= comment_data[:judge] %> + <%= comment_data[:comment].gsub(/\n/, "\n ") %> + +<% end %> +<% end %> + +We value your creativity and hope you found this competition to be a rewarding experience. Your participation contributes to the vibrant artistic and academic community at our university. + +<% if @contact_email.present? %> +If you have any questions about the judging process or results, please contact <%= @contact_email %>. +<% end %> + +--- +This is an automated email. Please do not reply to this message. +© <%= Date.today.year %> <%= @container.name %> | All Rights Reserved diff --git a/app/views/test_mailer/test_email.html.erb b/app/views/test_mailer/test_email.html.erb new file mode 100644 index 00000000..ed843f9d --- /dev/null +++ b/app/views/test_mailer/test_email.html.erb @@ -0,0 +1,11 @@ + + + + + + +

Test Email from LSA Evaluate

+

This is a test email to verify email authentication settings.

+

Sent at: <%= Time.current %>

+ + diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 438dca9a..cc6a9a1c 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -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--XlVLPw52t5lI4c+b--nvp3Tp+JVxREaTOqbHZwFA== \ No newline at end of file +nYmwRlFvMtr2CpdxHe9D+J2TqERLOC0sy6mirxiVyBz5OvYfcc8bS64oKKla/kd220f3CmqKhIIP4/leD0uzSZFW/GiU2xX8JXUHUcJ8z8xDm3g0Ce7bZsnxeC+wkpQihPm4fz7ftCY/rOa8nLxXVUN51uRSwuCftU5Bx/OHJVcK8s2Zv6Ra3oScLVF2zav9phNvHaOJAVWX6z9AeuxoBprFWamWZc3EBaNl0acd4tGOL7Oi+PCKo/7M/XVGBjo04lNzuh4w8q1UP+LHCfPYR79qpSRGTcE+FRx22EtowpEPgE7829TURirI5HKkabES6VgHIAZcEPca2yY9vHRBtFPpP5l24mDbav6UXdmfUzmkCY3n4g796hEZkmGuAXX93ZsEq+C6NR4djJf+3StCl5tP7lPj1HITTZ9LwdPXgmbRlSKd+rfQJ7H3JfSt6Sw5FcT/tjDGs+DuLdh6wXkEJ+kb4da4pSUYaR1RboAgdHdU7x2t+2RdthltapEsrDLW2UOSa8OW3fqVfJ8oq6Vw7qJOCLZ5Z0+B5BmGKOkFK/uzcNTb1m5Mg0nWbTUjwdxAATHdSy3nsqucp3NJOlsSynSy9K/3KImDMh73MmfcHo2U86HU95EdmOO8yh6Hnn0twL24cTWJLFZxBxZpKFWa1HiWmNE9QAaa5so6HBLAwwbLig0J1C/tNri2pS5ycBYIkunIUoWXWzy3Tflc3TvtRoeoxpBA67oSLIGOec5E4eQV3YItoT3TrKh9VL4M5Ta79Bj6BgGF2AjZQfH2VUV/Ce/e8M3mudBf/IiKR6HWyggu4E96oo7P1ilWKWlRxRs1Cxq3dAQmhYnQDC0v7tY97tbrLSTqSQu4GhJX+bE9RTIoB41G5Agm886NgsNg1pkYgeFhClcOKYJ4obawoH0p5AquNUZeTldtMljzj3EGLKAIEGFvljQwiL6JFy1jyNGnXyA6Av3LrUEPIcp9WacpO5gMVEkJAjkcqQQUtTIiNig9Z36mhlBKytSAUsJwa/COn9tcO9zdUyRkGKkNn08FChHuJQMoc7TuIS3W0IWC3ly36oLKL8qIQoW9IHw1HIgiG3xIGcvwZK0Ov2nJV9qwekOy3+jVS3R6CniiDHyKEGLLHfggZYPVwne6OCeQI7X1qxZ2zDD1nGj2bJoS2GXHpuPNFnEVQreoAIhjW0fNqcpYcXqKU0XGRl8s1srvgNH+TAEbAMrslh6UcTsJFeo4uI+jIKE9ShUxk9wUglyF9Zm2R2AYSjc81gJIYZkI/nObhM20BxpDlvSvmTCU/OhqcQfkmYBhReMCA9XhQRe3dmElV8D+DuDR6ro0/c2oK8PdjqQXeFilV39+Szi3nw5DEtMfpi7FHGQ2ui30hFOmeWdtZh/i5SvXIIPDyHQOCIwkMJCTs3JfqtkC+qek9u5OzVp8Ftzb+VvEZ/6jEVUaBTdV1JflKAU6VcWxFroHHg8zKZ0hGqcisrv9hvkRfauWKDFRydxgj1CND6hO/QJ47FYiDpqBtKHjykzdDi7jT01ci34STCpQWs6dYdFxc50mgvtRdjlGSgYZLBcyqq+7cs6MHkswJxlA+ycZgY0s/q5zgklE2zIcpmD4hWLeDxFVcJZeyBjKZhRyt6egdZo2te6LwZJe/51aKFfHtHlcILIYnduwxsgumo8tv4syteWaFntg3RtZllnI39AD5aN4LojsIlPx1IkPxZiyM8EYNifaQJxUYrqb0vya2KxhynSU7YcYZWdC+cTuEO3GAlP4tKuNAIyhLcvYcmVklYwCKN9eoY6yv8KgThJhwOMh3U77B08TwU89FGPFeJu2irXkdmWciFog2J+QCrkzxhC877m7ijBqOsyi2E35GiEqmxX/i+QOrzjcMX83c20pLjEEccE3ruuWBwgPbV+yeqEjkl/PP2DCT+I2m7jwRIANDBJNPkOr0qp3xNWag1jSl863hf0BPuizK98ZL5tijz9oAOIF11qtfLGbWgEHADoGtFuflMO13VCKoYGwcfIAc/aU+mN/s2EAvzbwSsozptfJ+TowQtUmg+qyYIXLTSdN4uAJy1tcABMH5Flg2wxWURXAZG6Sn2HPy+rsgJ1vCVFoVaUuAv29idwtpDJTYJJkZ8062v/ba3xDpMRFyuaGLT6BVofHD5Ml6FWdYfuL+17CYOF6A+9jLtx9dONt1+cpdAVSwZtf9X8dKYQERI2nqz5vZvZsf9UqkXoZxT71/VdiJUThX20+QUStPl9ceiM/FdlmSEdPLXTRkEhsD/6pvtA6LvuRMPhJ4XHba4ToJ4CDoSYD7eeXvIHsGzoAWVTdCi6lqob/HweVl9anlA2OJX4RH2SITy4WQiuzmxU2s9gJiid95iAlEP66PXocDDX0xEXXJNiycRRULWczbzGGEkFx5nOI1XWuqAUEhBjY66vTyWk/fVaoz9mEaEWT5xTUSh9WzNk96781T5ltuyrYFfMP2iavw7QAeJL25Zz1AjLN0Ye6visnw8iMB3TSYc9LxbENFiei6Af+AFFJTh86zO2tvdb4Cg7k5fqj0bVKxpgGf/CegFpVIYjmJ6z/rtHpO9NteIX9zSILAyHs76sGX0QT11ALc3h7H8vVuPYYMXlp8EWVJ99VJlIzTn1/W5ez400lGb/VMGj6DYDgoFkYO+wJFgp23/W+ZK8lWE3o2orkqJHTgebZlcTXtS0oe6MCCSHgBiBhugfWLt1TGBPoAsyGA+BwhSCGloDWiAokk8S1y3kkgRfwMHzAS2pGm3ZfsnoNsCFC6OeC6fznyOLOO7sUOrQu7QBhxdPgjsF/tW2lU1RegJlBFg/DYYO8pI1fszKSgn20EaZtJKF4xlwCrFm6jmVii9Nt6y/dr/QGJTFUOjKXb2YaVhD4LAMlXF6fySW1fzfoUnIu6KgE+xcGrQSGTkq3dWuzuRR6StirqgFycgTXrxwWeUbWujqd12fhwnl4XSoa+xXSl6IRtaZtF4UAjvigGjKduTeHFnQQ0qNTh9/74qnfkuruCJMY8bmHyPu7Z1HZSDd73YoA5qnzwn03kwIHiss188DeS9zM4hRl87YaI/RymMHa38uZsiodjGmdDeSyCLmBH3WIspDHfSgZHZoCIR/HPse14GQa56WGvUjiaG17OhRBQPnss8kCj4kmdRk69yPNfoc4uKmkCBMnezxy7SSmo/RXzAIZLkUy7vy+8U4IMpraRT/Ieu+n5t8YuWODDQjoVH/qgQhbajNnVqe2GKixMXMY8VCBPn1HG8erU1tVNYo2TsqhXizSRpC5XtCkVOY3i/LSiRocP0eW6xR/IAHGw29jptirW1ydx7MciSsDOUgel5uSuAmf8vj9LUolOF/2S/Ggv5wK8bOBCdSUrFPABuTlXhh5UPPEVHEdsrcSTCUTM+XSmhkIwM8pGPUN3kFXKMtAIX2zNGli0VPvenNvGka5fnfQI4rcGDjsA4mVEvYos61/8yf+p1rQ20EozMJGNVsH5z4j0Qna+wc8j2NO2wpF4GxgBQncQWwjHL14hjSbI4GUgR9SWlj6BCt5mHyNU4ZDHbYQgX+tWmeKFmVkuOmmLxgwpIqI9xEMCb1YK8b0YsTt8PbjpV3/x6Q1BJ8cj68XzjouRK2PNX17MquEYY6gwiJg5e7ayPRqCkLMccoKB8n9lJdsJW2Nybcl45h1l3J1npTHPGErSdBbQjav058npODxVwxrrytnIDgkVF8n8o2zh6OJeoJ/8k70XYmFAIInC9vrX4I5muT3ZzX1jCnjcmObN807I6hEUNhNecLMzIhjtXwT6XTVxfkVNnQ/xM6oczBZ7BHWPuwuGW5E+Q5KtJkAw9O7ahGwUuTS85BKyKxcanDG1UiEyi4umwpY/D7D+6UEHf+lJNA+EeWW+dG+ZCxfxyzLW4leqtS3oskxb4S1LrvMLFWXRrawurb38I9loZRXq+GjpQtaMxQLXRh+BoKf5n1tjK2h3opRAMYGQ+Gei1rirZTOkXLPWYILn5c4HEpj3auZJJkFyGjnav6qZ7UtUHrgsdAkReOQZMIK2aCt8uOngAiJLuvaILFtqDyXZMoOGJqNOidAkoQa65pTbQOaqYOCly132XZw+l9G5bWFPg9wOUpTyizHS8N0EOCRoOmTResdlhNwfjfgCnNgDQu6sI73U3tzEHYUtwRta/te1fn3np5/slrx8keKlPxdFioVq2aonocEU5R/p64BO9xZ3y2B5DEpGMh6BtFRq56Z4YOjIuB5DyxqQ5k9PHlQ1+UcVW1+8tEdqsA41z+o3WsDXTsl765c6SKxUo6G5rzzd4LyoXVJjE9TF5NwqV8jrhjx62yvaXmjYPh4SG0/pWp+DgXxUfK6nkXPrvaNrHrA3bV949ZtGdJ9bySwfwa02OjI9tnajjaMsbLz9RfFyBeF7EkRD7KHPfPnvJ7sfIUPS17OOL1nSnNapDrpnTp6/4SdA5wENA0Y2y5K1tkQeCqKgcJOCx9mI3Vrg0UEgXSkwJ1sqpvWsCWZtAYCe4Ob7wopH1PKtBhWWRJ38kfBu7Unb4TJq/5uXtOpcah7Nns5LnQi6gopS/1R1MGIQikz02qSW0nAB5Ukb//LKV2X8xjlsDAUW2jkG3Nth8uCTrztqJFq1yfEhd8JTZMBuxMFZetSgbp7BkLwERvagYYHUoPaChrzKh1PJCh+C2pIYz0v2WwwWUFdhH3+OdjiToZb5nOstm0yw4UUk7mtjiSnCiIH6olK9yOc/eo9hqF/X1N6Kc93ZA3KPKn9KU8FNX5df+ssjA0RakoI15CsT/1T6h7oasP2SEN1nqho6LNBbsrva9GXC5hATMk/qtQ58X1n+zXqGpNLkG4VX1uwY1qS1AT4reF7Y8zOhzBD3DaesGZNctAPeiYiqhRhZCXmahk3nTJosdr5hfXWkcTvnIFEyRwnzDDhNbCICuGp7pdLPgMtucVermZCfKxhJU33ZlrjiAunsgX70wMakuBnlTtIo113aRTGIVbov7QXCnrbgEyYgX7rCCew8wRakUTvjRkikRmPFWhFqYbb90EGKQZi46LBxE/bvSkk//p9Rn8DlTpf76jEMvcL1bIzpQFhrRxutyHQYKJ3QwxAhPWeFHYdVluBcBe/kVfPHKJD/tw0+PgZTaQPPvs7gtwk6iOuM38RPXm/aTeHTpDj9WsKCsY3oN+/v/Z2Ly5hVavpBE5H48Gn8IqP4k6SI/2NuyFPnoDKt0zy3PKVqZHm1Ahb/RSTkIKMaCDYlyDRQ6iEQT03EO5lFKNsRN7T3r7Qjj+8XyIOTTeUhXK818soSCfAZQROAxKH4wXsuL5AT3d6JWzetuLrxUy1T0C/9Wwa9VHZe+JWTZ1KtXqXaFHDnYgpNjVpDDm0MsQPbB6mujcVaIcAuPKX14R56V1qNzsCdqXsybiog/ah/YnhvrhJA9OBNEwI5JJWoDqjNNRsrdHYNmGyy08u0ofOZ4dWwGIVQvQoRncH1xbmdMXgvchfW1ReB2xsRyl6iTfgdQMWBW91nSxktmKJ2CsZV75ppZxqsgEl7ru9SPyFyLZFezN5LW3CxYLwsBJVQbFvW73uacA0oHuxAYkQ+OA3Y0+0mlFKB3LWDs5B7IzqdMqLe54qXLgQ5div+Qmyv0Np9SyH79UGY8O1DpNhMf6fY1lruYYcY9EcbANJ361ANcGMAsslG9HNaIWcNXFP7lFnp4Pd/WEUuXiJ9mmsP65E72VyZPiY8PdXwVfcwHbtNkmRe7vb7cYy34nDFNKJxvXTiCagYe4Y3268unbh2/NZoBYelp8Zhp+EhL6fW5WhnnTNHhyxH927ABxdFshwh8gbCwMGkUSJs787t9KV3cnShBgMm9xBn/ee4JaHDKi7ZNAjwaYGt8j+glDve93ktZ7m9EmdEcl0PUSfgNWzeab3OZhm+rVKneF1TD+783TrYfdH+14PTEuMn3xWqgpo8/gcRfu4Bjmcez5CaRpdHqHK+yFidmc7HJeez77PVjrZBy0XSL+cU6bimIONGlobQNF40DPY6GsMHupvX/G+TpDO0Y7GEpVugFcndTv6KlWfl0FRRRYku8ivllAkUBG3ehSA35Swz4+XF54KpP2LSRMQBzzIoFVJjxYQOuqT2ZojocLuko9tzwBO2oxM4V3WQsxO2QpozpOMrrTLtztW8n94jjid/wV2MkE//NEj0zRqZHVFqsweJiIz0RjhJcDwBfqXGiAYu9NeJeR76Zpt25FLrME1wu6i6T6KNh/YZzMnKY6blsNcqeQsfcrga/wn3hBu0jfdcC8hcZducUFmZLi9alaUzCco/GjkKvxkRTCj6kmulP47Dvjm39ixbsEaUiu4BMwaiw7wtzZPZEF9nkurNdsnV1KSfHjuTXZfE2L8M9tv5hAJ259JCYJhXBcIVTYBqozhRbRR026uyZWFVAHxdeNJbCiBBqfQ5pRCWqOHtBVSltALyADxdEbZNt/8F8HO56Gz2yZSTmQ+Qyfu4TKHGk7PRPnQI8A/+YZaMXq0u14NOTBSrSRsJnSOwzxo7+mYinGQ5in7Da6d/orZUfcjQlElLWumJ/4Pqw/GDCW3ZD6Vi2b7T8D5x4CO6xHnMvxutx6W5JdHAFehiLppTPaGZhEyMfAG6QlV6Db/2flNY7lQDPt7AAq0o+2TKJwgrcKKjdIVexEqp/9ND3U/ijBNYbhM8Yi15sMOWRduLLKoY0gNlecBNV2wnPcTIHcikBr228RPxwwvsivZGjQJlldCiBI+8aizO8L8TKX0NRFdiKynpBP/U4JnM9owaBCYkSbkU2nZstHAf8hUtRPlrAy86ROrUsmCgjTMWvL6LbwR8p3nzE/uCmXX8HgnG50RlnUxd5zdoBd/0m7dyBs2dqVQzECSMwg9yAxjlGGSZPcwATdwLeV/Grqs66+RafcsuOs780MsQgVrD7Ttqvdw4pV8uXH+JDg+thEvfhjzmUQpa3ZEE+9D8V5GbeoUP3G4wYWEcRyOKm7YhK2cNOrrn6vPR8p4Qarm857J2IK+zzQdnnffLG50QpKr0LX3e8MOx4BKB5DFmIRvvsLD5B+SoGGdK5+y7G0AQDsQiA0vX8LdVvyTOPUxYFaAoieERGI9oxHITLXydaIKXLUF6BFoj5CYBZv0N4K/OqLaa9Y/eahSRMhRJzClM6WHWff6syjsfF1hCQO6iGkLRbilNI0a6ivviI+4+Flp30rnP54oOa70qftUyns4LsG8nplr0t0zy6k5j15V2wniiGZogA6Ms0Hz1XMuoHlOP+TuV5L+7Ij5DbiGIjNZcPCWqRWR+WnOM1zFXE2LJZNWJQ7kKDv/OgoOprM3hnZThV6cIW2ud1dXxZXcJYyjjg6MgrvprZrCH78o7WIlUOSFoOwq3GYKg1vRHV/mo/05SllmHW2SPBWcc31cyrxJOzBcsll2ZWpvTIpjUSkNFbT54PJuXNvCr/G+BFo59958hGARm3evlLazUE8SrPXTuL7eDLcPeDF1pWvYyydVX4Lg/0Qf2Lg6yNKn4rubfAQOsv+2IrfBKPbfDtZm5Z+WYtPGilQPzTCCWXzJ8yJ8lLPlrpjePz12M4Cc5aJv3jFwK3o2EKu1+xYxH9rPlSd+qj3JP7S82f13vnaWbah+14LeYyWSaDixrs/xgeuLciPFlCjVPm2tg3T70wDqX3i/VY9tsoIhKl3TO/TqF0d1umGOKncrHqhY+iFg6V1Y0i0mC/EygqwRoU7vQ99n3g29NMQPnl8fXyUfI/Ic+rxD7bIy8w3anNEwKRH6iJS3E5iEe9Uk5bCQXk/jgpYbuGa9fb6Zw5akynhoGFofArcPmbSmbI3bxTWhXvQh1/2LiUYRJ6ZIB++Zhu2IS+D63RjGMK8ZV53/cDFS5jP+/WA/Xy12CITs7hMqTrbL5B3feR+Wp6IZD+5+cxnKZbAHSgIRLIjsP11wEzH/4c1SbhJCy1SHRsZmoJFiHF3EGT/6djfrI9hyJ0pBYZ+890YYE5d1JBJ00SZiB/zFnran+w4tfbQSOTkOdoLQjSSkVystKDqMLZ72La3yJUnjHRQsUXm0AmrqPvO/5mXCbAS8tXel990uokHRjzN/Yrmru5xs4LP1AqwPZEZshoh6tZPx7ev4P9gfq4KM1yTTa0K0k9a7/nKuO9RLmyPcUipqMXQNWlQ9OQf6JHmYOds2g7vFhqnf+Bk5KxMGkhBXSMcPBaiC5UmNwb/tfsJO1j74ha8m8cnYtA3O+YuEfWgUDvlSR8Xs4QEz5p4V/qR4020J0vHy49O0rJjg5mq2ZeJLTiOM3IG1gjxJ2PnCkEVmfOZSXN51xH/uH4JLNff4YGz0+5angokeRW5joN8FKz0GxfKiUMhFF85iW7MNy+Hc4imuRHQnqGT+Xdj3TGQ58qtN7JyC8GwO3LcPFO7FD1EYE0bTjDA6WQJ8eTLYl0YvPKKRpAsADkA+KU7sBIi3Al6TWo/XLjHzyDDLh+NfXBYXzelBq1UHkqgLP01fAZcnEfsBgodaQWpsL8xJ4i4Hxc2Jtzqk8XHyZB5v9UyYDmnmZsk31mXyr6f52Q1KhpXJ0Zcw6wLrzcZMwmTDe1pREsd1jNSyE+hoxPO4W1Hr4kemzV9oIxEnIbP/4raNP94eyDgfpriYrfY0OsDTHTHFwTFSUMmWGbTWYFHpwky1I15j9D942bmSa3r3Bc7ymed8G0wgKNrj2IMLqi8N+bhiw+MS0Q6mr3/C6VMcgxMH36bEuvpoUcvCfj36NMLZZuCZCL6ZQ7sGA3HQK+Cq1+Q53ln0R1N5OtOb/eBsmlZJSO8GSFI08WuqtmYZN79y3/iuPpzVq8BqwOFPsq3Cwj0EiTiKBXsXEV6U5yfBM0Glz+bnlAfGiL2w45+Vf+auN5BQ7LD03FPPdkYZWIiQnRINvRXtm1SVbe8UzrY8MifonYXzfOHFZoe854AUV99oqxj6Dsztik/LJ9qgPlSN62hKW657mpDHMMrqgduibfo5JXkRhfSH6m00cq4o59hGSdWqiQdwYRIXcVtPB5QCyvI2P3zN1dVY9uj8PJuOAH9bWtDnb/q6Ifl3hFgDuvkzVSBf6NrJUepQApYVlN+i8eBkOFFk5W5fx6uLbgwtjxNwr+eF//wyxOoU8L6QGPjQhTa7NEzFD/p8KbKwyqAUYozLKxdUWE1vNqFg6NLAYnFdD85+rKcMDRSkhr+5oU0PbLiM4Z4pREOtOkfzyiTXQum/EmZAti+O8HnfD6qjSQxw25DMdHaDt+Jp5jLy+9XxsFGx//awonRr5wAvShRQDBc0K0fQDWPwKGPIiUynJggSRZn8+EXeJLtfjYW5uuwVDtroy1vmLa5CeJDoExL9WQDBBd9XXUGn/WUUZPRKnVu3TJaIFV33V6pJ0zCskfgBGL+aHvDBZtv5sZy1ixBI5VQoUGmhoaHtgyz+vPKJbUFtDKYi0unZ7EQeTKoUiecNL5kLFsk+x8pMVfojYZsEi42bFY1M6JmdTz34f9UEwQPARj/ntOziAaPPXZN6agICaFKVipmPg4hv2KOEUIZ9PK3WTSeQoFAwWVcIgS8/9qj1z5KyYkH8qygpPeGdun8a0CjGrRFp2TyxkMLdv1GFhN3niSst55AIr1yKG2+WGQ4T202DXdElVBxwgyfmZ6sm3WMrYGgWnPmxX5zoVIjaQzWGaWpB9gENI0uDo5pHFqSFIOFKFe2fSmhoDZ7oIWtxk284olg0eoXXtVk=--3Uy3rk2jkSTh1gUy--Kzt4qhpqDPoxBJSdMj39MA== \ No newline at end of file diff --git a/config/deploy/templates/sidekiq.service.erb b/config/deploy/templates/sidekiq.service.erb new file mode 100644 index 00000000..cacc0102 --- /dev/null +++ b/config/deploy/templates/sidekiq.service.erb @@ -0,0 +1,22 @@ +[Unit] +Description=sidekiq +After=syslog.target network.target + +[Service] +Type=simple +WorkingDirectory=<%= current_path %> +ExecStart=/bin/bash -lc 'bundle exec sidekiq -e <%= fetch(:rails_env) %> -C <%= current_path %>/config/sidekiq.yml' +User=<%= fetch(:user) %> +Group=<%= fetch(:user) %> +UMask=0002 +RestartSec=1 +Restart=on-failure +StandardOutput=append:<%= shared_path %>/log/sidekiq.log +StandardError=append:<%= shared_path %>/log/sidekiq.error.log + +# Greatly reduce Ruby memory fragmentation and heap usage +# https://www.mikeperham.com/2018/04/25/taming-rails-memory-bloat/ +Environment=MALLOC_ARENA_MAX=2 + +[Install] +WantedBy=multi-user.target diff --git a/config/environments/development.rb b/config/environments/development.rb index 148d0bba..7715d246 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -51,6 +51,10 @@ config.action_mailer.delivery_method = :letter_opener_web config.action_mailer.perform_deliveries = true + # Use the inline adapter for Active Job in development so emails sent with deliver_later + # will be processed immediately and show up in letter_opener_web + config.active_job.queue_adapter = :inline + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/production.rb b/config/environments/production.rb index 59a33eba..8c76959b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -49,6 +49,7 @@ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = true + config.ssl_options = { hsts: false } # This disables the HSTS header while still forcing SSL. The header is set in the nginx config on the server. # Ensure the session cookies are also set to secure in production config.session_store :cookie_store, key: 'evaluate_session', secure: Rails.env.production? @@ -64,8 +65,8 @@ # config.cache_store = :mem_cache_store # Use a real queuing backend for Active Job (and separate queues per environment). - # config.active_job.queue_adapter = :resque - # config.active_job.queue_name_prefix = "lsa_evaluate_production" + config.active_job.queue_adapter = :sidekiq + config.active_job.queue_name_prefix = 'lsa_evaluate_production' config.action_mailer.perform_caching = false @@ -73,6 +74,22 @@ # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false + # SendGrid configuration for email delivery + config.action_mailer.delivery_method = :smtp + config.action_mailer.perform_deliveries = true + config.action_mailer.smtp_settings = { + user_name: 'apikey', + password: Rails.application.credentials.sendgrid[:apikey], + domain: evaluate.lsa.umich.edu, + address: 'smtp.sendgrid.net', + port: 587, + authentication: :plain, + enable_starttls_auto: true + } + + # Set the host for URL generation in emails + config.action_mailer.default_url_options = { host: 'evaluate.lsa.umich.edu', protocol: 'https' } + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). config.i18n.fallbacks = true diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 342017ee..b86d6913 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -36,7 +36,7 @@ # Logging config.log_level = :info - config.log_tags = [:request_id] + config.log_tags = [ :request_id ] if ENV['RAILS_LOG_TO_STDOUT'].present? logger = ActiveSupport::Logger.new($stdout) @@ -52,7 +52,7 @@ # Action Mailer host = 'https://evaluate-staging.lsa.umich.edu/' - config.action_mailer.default_url_options = { host: } + config.action_mailer.default_url_options = { host: host } config.action_mailer.raise_delivery_errors = true config.action_mailer.perform_caching = false config.action_mailer.delivery_method = :letter_opener_web @@ -65,8 +65,8 @@ config.active_support.report_deprecations = false # Active Job - # config.active_job.queue_adapter = :resque - # config.active_job.queue_name_prefix = "lsa_evaluate_staging" + config.active_job.queue_adapter = :sidekiq + config.active_job.queue_name_prefix = 'lsa_evaluate_staging' # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false diff --git a/config/environments/test.rb b/config/environments/test.rb index e44d6803..a44ad823 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -45,6 +45,12 @@ # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Configure ActiveJob to run synchronously in test environment + config.active_job.queue_adapter = :inline + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/config/initializers/action_mailer.rb b/config/initializers/action_mailer.rb new file mode 100644 index 00000000..67a821d4 --- /dev/null +++ b/config/initializers/action_mailer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Configure ActionMailer to deliver emails asynchronously using Sidekiq +Rails.application.config.action_mailer.deliver_later_queue_name = :default + +# Extend ActionMailer to use mail delivery logging +ActionMailer::Base.class_eval do + # Add delivery tracking method + def notify_delivered + if self.class.delivery_notification_method + send(self.class.delivery_notification_method, message) + end + end + + # Hook into the delivery process + self.register_observer(-> (message) { message.delivery_method.settings[:return_response] = true }) + self.register_interceptor(-> (message) { message }) + self.register_observer(-> (message) { + message.deliver_later unless Rails.env.test? + }) + + class << self + attr_accessor :delivery_notification_method + + # Method to set notification callback + def notify_on_delivery(method_name) + self.delivery_notification_method = method_name + end + end +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 00000000..92618c14 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,21 @@ +require 'sidekiq' + +# Configure Redis connection +redis_config = { + url: ENV.fetch('REDIS_URL') { 'redis://localhost:6379/1' }, + ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE } # Only if using Redis over SSL +} + +Sidekiq.configure_server do |config| + config.redis = redis_config + + # Set default retry count for Sidekiq 7.3.x + # The retry option is now set in the sidekiq.yml file +end + +Sidekiq.configure_client do |config| + config.redis = redis_config +end + +# Set Sidekiq as the ActiveJob queue adapter +Rails.application.config.active_job.queue_adapter = :sidekiq diff --git a/config/routes.rb b/config/routes.rb index d05e9120..d466808c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,12 @@ get 'bulk_contest_instances/create' root 'static_pages#home' + # Sidekiq Web UI + require 'sidekiq/web' + authenticate :user, ->(user) { user.axis_mundi? } do + mount Sidekiq::Web => '/sidekiq' + end + resources :entries do member do patch 'soft_delete' @@ -34,6 +40,10 @@ resources :containers do resources :contest_descriptions do resources :contest_instances do + member do + get 'email_preferences' + post 'send_round_results' + end resources :judging_rounds do member do patch :activate diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 00000000..3c3ec642 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,29 @@ +--- +# Default configuration +:concurrency: 5 +:retry: 3 + +# Sidekiq will run this file through ERB when loading it +:queues: + - [critical, 3] + - [default, 2] + - [<%= ENV['RAILS_ENV'] || 'development' %>, 2] + - [<%= ENV['RAILS_ENV'] || 'development' %>_mailers, 2] + - [lsa_evaluate_<%= ENV['RAILS_ENV'] || 'development' %>_default, 2] + - [lsa_evaluate_<%= ENV['RAILS_ENV'] || 'development' %>_mailers, 2] + - [low, 1] + +# Environment-specific configurations +development: + :concurrency: 5 + +test: + :concurrency: 1 + +staging: + :concurrency: 10 + :retry: 5 + +production: + :concurrency: 20 + :retry: 10 diff --git a/db/migrate/20250311192043_add_emails_sent_count_to_judging_rounds.rb b/db/migrate/20250311192043_add_emails_sent_count_to_judging_rounds.rb new file mode 100644 index 00000000..08a40fe1 --- /dev/null +++ b/db/migrate/20250311192043_add_emails_sent_count_to_judging_rounds.rb @@ -0,0 +1,5 @@ +class AddEmailsSentCountToJudgingRounds < ActiveRecord::Migration[7.2] + def change + add_column :judging_rounds, :emails_sent_count, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20250311193603_add_contact_email_to_containers.rb b/db/migrate/20250311193603_add_contact_email_to_containers.rb new file mode 100644 index 00000000..817dbc26 --- /dev/null +++ b/db/migrate/20250311193603_add_contact_email_to_containers.rb @@ -0,0 +1,5 @@ +class AddContactEmailToContainers < ActiveRecord::Migration[7.2] + def change + add_column :containers, :contact_email, :string + end +end diff --git a/db/migrate/20250312173602_add_email_preferences_to_judging_rounds.rb b/db/migrate/20250312173602_add_email_preferences_to_judging_rounds.rb new file mode 100644 index 00000000..a5562bda --- /dev/null +++ b/db/migrate/20250312173602_add_email_preferences_to_judging_rounds.rb @@ -0,0 +1,6 @@ +class AddEmailPreferencesToJudgingRounds < ActiveRecord::Migration[7.2] + def change + add_column :judging_rounds, :include_average_ranking, :boolean, default: false + add_column :judging_rounds, :include_advancement_status, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index abae56cb..64c25b4d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_01_08_193135) do +ActiveRecord::Schema[7.2].define(version: 2025_03_12_173602) do create_table "action_text_rich_texts", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name", null: false t.text "body", size: :long @@ -147,6 +147,7 @@ t.bigint "visibility_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "contact_email" t.index ["department_id"], name: "index_containers_on_department_id" t.index ["visibility_id"], name: "index_containers_on_visibility_id" end @@ -269,6 +270,9 @@ t.integer "min_external_comment_words", default: 0, null: false t.text "special_instructions" t.integer "required_entries_count", default: 0, null: false + t.integer "emails_sent_count", default: 0, null: false + t.boolean "include_average_ranking", default: false + t.boolean "include_advancement_status", default: false t.index ["contest_instance_id", "round_number"], name: "index_judging_rounds_on_contest_instance_id_and_round_number", unique: true t.index ["contest_instance_id"], name: "index_judging_rounds_on_contest_instance_id" end diff --git a/lib/capistrano/tasks/redis.rake b/lib/capistrano/tasks/redis.rake new file mode 100644 index 00000000..76f6d15e --- /dev/null +++ b/lib/capistrano/tasks/redis.rake @@ -0,0 +1,24 @@ +namespace :redis do + desc 'Check if Redis is installed' + task :check do + on roles(:app) do + unless test('which redis-server') + error 'Redis is not installed. Please install Redis before deploying.' + exit 1 + end + end + end + + desc 'Install Redis' + task :install do + on roles(:app) do + execute :sudo, 'apt-get update' + execute :sudo, 'apt-get install -y redis-server' + execute :sudo, 'systemctl enable redis-server' + execute :sudo, 'systemctl start redis-server' + end + end +end + +# Hook into the deployment flow +before 'deploy:check', 'redis:check' diff --git a/spec/controllers/contest_instances_controller_spec.rb b/spec/controllers/contest_instances_controller_spec.rb new file mode 100644 index 00000000..afaf97f6 --- /dev/null +++ b/spec/controllers/contest_instances_controller_spec.rb @@ -0,0 +1,227 @@ +require 'rails_helper' + +RSpec.describe ContestInstancesController, type: :controller do + # Add existing specs if there are any... + + describe 'GET #email_preferences' do + let(:department) { create(:department) } + let(:user) { create(:user, :axis_mundi) } # Admin user with full privileges + let(:container) { create(:container, department: department) } + let(:contest_description) { create(:contest_description, container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } + let(:judging_round) { create(:judging_round, contest_instance: contest_instance, completed: true) } + + before do + sign_in user + end + + it 'renders the email_preferences template' do + get :email_preferences, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + + expect(response).to render_template(:email_preferences) + end + + it 'assigns the judging round' do + get :email_preferences, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + + expect(assigns(:judging_round)).to eq(judging_round) + end + + context 'with non-existent judging round' do + it 'redirects with an alert' do + get :email_preferences, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: 9999 # Non-existent round + } + + expect(response).to redirect_to(container_contest_description_contest_instance_path(container, contest_description, contest_instance)) + expect(flash[:alert]).to eq('Judging round not found.') + end + end + + context 'with incomplete judging round' do + let(:incomplete_round) { create(:judging_round, contest_instance: contest_instance, completed: false) } + + it 'redirects with an alert' do + get :email_preferences, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: incomplete_round.id + } + + expect(response).to redirect_to(container_contest_description_contest_instance_path(container, contest_description, contest_instance)) + expect(flash[:alert]).to eq('Cannot send results for an incomplete judging round.') + end + end + end + + describe 'POST #send_round_results' do + let(:department) { create(:department) } + let(:user) { create(:user, :axis_mundi) } # Admin user with full privileges + let(:container) { create(:container, department: department) } + let(:contest_description) { create(:contest_description, container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } + + before do + sign_in user + end + + context 'with valid judging round' do + let(:judging_round) { create(:judging_round, contest_instance: contest_instance, completed: true) } + let(:profile1) { create(:profile) } + let(:profile2) { create(:profile) } + let(:entry1) { create(:entry, contest_instance: contest_instance, profile: profile1) } + let(:entry2) { create(:entry, contest_instance: contest_instance, profile: profile2) } + let!(:entry_ranking1) { create(:entry_ranking, :with_assigned_judge, entry: entry1, judging_round: judging_round) } + let!(:entry_ranking2) { create(:entry_ranking, :with_assigned_judge, entry: entry2, judging_round: judging_round) } + + before do + # Add entries to the judging round + allow(judging_round).to receive(:entries).and_return([ entry1, entry2 ]) + allow(judging_round.entries).to receive(:uniq).and_return([ entry1, entry2 ]) + + # Configure ActiveJob to use inline adapter for testing + ActiveJob::Base.queue_adapter = :inline + end + + it 'sends emails for each entry' do + expect(ResultsMailer).to receive(:entry_evaluation_notification).with(entry1, judging_round).and_call_original + expect(ResultsMailer).to receive(:entry_evaluation_notification).with(entry2, judging_round).and_call_original + + expect { + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + }.to change { ActionMailer::Base.deliveries.count }.by(2) + end + + it 'increments the emails_sent_count for the judging round' do + expect { + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + }.to change { judging_round.reload.emails_sent_count }.by(1) + end + + it 'redirects with a success notice' do + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + + expect(response).to redirect_to(container_contest_description_contest_instance_path(container, contest_description, contest_instance)) + expect(flash[:notice]).to include("Successfully queued 2 evaluation result emails") + expect(flash[:notice]).to include("email batch #1") + end + + it 'updates email preferences when provided' do + # Initially both preferences should be true (default) + judging_round.update(include_average_ranking: true, include_advancement_status: true) + + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id, + include_average_ranking: "0", + include_advancement_status: "0" + } + + # After the request, both should be false + judging_round.reload + expect(judging_round.include_average_ranking).to be false + expect(judging_round.include_advancement_status).to be false + end + + it 'preserves email preferences when not provided' do + # Set initial values + judging_round.update(include_average_ranking: false, include_advancement_status: false) + + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + + # Values should remain unchanged + judging_round.reload + expect(judging_round.include_average_ranking).to be false + expect(judging_round.include_advancement_status).to be false + end + end + + context 'with non-existent judging round' do + it 'redirects with an alert' do + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: 9999 # Non-existent round + } + + expect(response).to redirect_to(container_contest_description_contest_instance_path(container, contest_description, contest_instance)) + expect(flash[:alert]).to eq('Judging round not found.') + end + end + + context 'with incomplete judging round' do + let(:judging_round) { create(:judging_round, contest_instance: contest_instance, completed: false) } + + it 'redirects with an alert' do + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + + expect(response).to redirect_to(container_contest_description_contest_instance_path(container, contest_description, contest_instance)) + expect(flash[:alert]).to eq('Cannot send results for an incomplete judging round.') + end + end + + context 'with unauthorized user' do + let(:regular_user) { create(:user) } + let(:judging_round) { create(:judging_round, contest_instance: contest_instance, completed: true) } + + before do + sign_out user + sign_in regular_user + end + + it 'does not allow access to the action' do + post :send_round_results, params: { + container_id: container.id, + contest_description_id: contest_description.id, + id: contest_instance.id, + round_id: judging_round.id + } + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to match(/not authorized/i) + end + end + end +end diff --git a/spec/factories/containers.rb b/spec/factories/containers.rb index 5e8eecb3..855bc2f0 100644 --- a/spec/factories/containers.rb +++ b/spec/factories/containers.rb @@ -3,6 +3,7 @@ # Table name: containers # # id :bigint not null, primary key +# contact_email :string(255) # name :string(255) # notes :text(65535) # created_at :datetime not null @@ -27,5 +28,6 @@ department visibility notes { "Notes for #{name}" } + contact_email { "contact@example.com" } end end diff --git a/spec/factories/judging_rounds.rb b/spec/factories/judging_rounds.rb index 73632fa9..37c8ad9c 100644 --- a/spec/factories/judging_rounds.rb +++ b/spec/factories/judging_rounds.rb @@ -5,7 +5,10 @@ # id :bigint not null, primary key # active :boolean default(FALSE), not null # completed :boolean default(FALSE), not null +# emails_sent_count :integer default(0), not null # end_date :datetime +# include_advancement_status :boolean default(FALSE) +# include_average_ranking :boolean default(FALSE) # min_external_comment_words :integer default(0), not null # min_internal_comment_words :integer default(0), not null # require_external_comments :boolean default(FALSE), not null diff --git a/spec/mailers/results_mailer_spec.rb b/spec/mailers/results_mailer_spec.rb new file mode 100644 index 00000000..505e5b6b --- /dev/null +++ b/spec/mailers/results_mailer_spec.rb @@ -0,0 +1,193 @@ +require 'rails_helper' + +RSpec.describe ResultsMailer, type: :mailer do + describe '#entry_evaluation_notification' do + # Create all required test data + let(:user) { create(:user, email: 'applicant@example.com', first_name: 'John', last_name: 'Applicant') } + let(:profile) { create(:profile, user: user) } + let(:department) { create(:department) } + let(:container) { create(:container, name: 'Test Container', department: department, contact_email: 'contact@example.com') } + let(:contest_description) { create(:contest_description, name: 'Test Contest', container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description, date_open: 2.months.ago, date_closed: 1.month.ago) } + let(:category) { create(:category, kind: 'Poetry') } + let(:entry) { create(:entry, title: 'Test Entry', profile: profile, contest_instance: contest_instance, category: category, pen_name: 'Writer Pen') } + # Create a second entry for the second ranking + let(:entry2) { create(:entry, title: 'Second Entry', profile: profile, contest_instance: contest_instance, category: category, pen_name: 'Writer Pen') } + let(:judging_round) { create(:judging_round, contest_instance: contest_instance, round_number: 1, completed: true, + start_date: 3.weeks.ago, end_date: 2.weeks.ago, + include_average_ranking: true, include_advancement_status: true) } + let(:judge) { create(:user, :with_judge_role) } + + # Create the judging assignment to link the judge to the contest + let!(:judging_assignment) { create(:judging_assignment, user: judge, contest_instance: contest_instance) } + # Assign judge to the round + let!(:round_judge_assignment) { create(:round_judge_assignment, user: judge, judging_round: judging_round) } + + # Create test rankings - using different entries + let!(:ranking1) { create(:entry_ranking, entry: entry, user: judge, judging_round: judging_round, rank: 1, external_comments: 'Great work!', selected_for_next_round: true) } + let!(:ranking2) { create(:entry_ranking, entry: entry2, user: judge, judging_round: judging_round, rank: 2, external_comments: 'Impressive submission.') } + + let(:mail) { described_class.entry_evaluation_notification(entry, judging_round) } + + it 'renders the headers' do + expect(mail.subject).to eq("Evaluation Results for \"Test Entry\" - Test Contest") + expect(mail.to).to eq([ 'applicant@example.com' ]) + expect(mail.from).to eq([ Rails.application.credentials.dig(:devise, :mailer_sender) || 'from@example.com' ]) + end + + it 'renders the entry details in the body' do + expect(mail.body.encoded).to include('Test Entry') + expect(mail.body.encoded).to include('Category: Poetry') + expect(mail.body.encoded).to include('Pen Name: Writer Pen') + end + + it 'includes the average ranking' do + # We'll use only ranking1 since it's the one for our entry + expect(mail.body.encoded).to include('average ranking of 1') + end + + it 'includes the external comments' do + expect(mail.body.encoded).to include('Great work!') + expect(mail.body.encoded).not_to include('Impressive submission.') # This is for entry2 + end + + it 'indicates the entry was selected for the next round' do + expect(mail.body.encoded).to include('Congratulations') + expect(mail.body.encoded).to include('has been selected') + end + + it 'includes the contact email' do + expect(mail.body.encoded).to include('contact@example.com') + end + + context 'when container has no contact email' do + # Use allow to bypass the validation rather than trying to create an invalid container + before do + # Mock the container's contact_email method to return nil or empty string + allow_any_instance_of(Container).to receive(:contact_email).and_return(nil) + end + + it 'falls back to the default contact email from credentials' do + default_email = Rails.application.credentials.dig(:mailer, :default_contact_email) + + if default_email + expect(mail.body.encoded).to include(default_email) + else + default_sender = Rails.application.credentials.dig(:devise, :mailer_sender) || 'contests@example.com' + expect(mail.body.encoded).to include(default_sender) + end + end + end + + context 'when entry was not selected for next round' do + # Use the same approach with the judge + before do + # Update the existing ranking to be not selected for next round + ranking1.update_column(:selected_for_next_round, false) + # Make sure preferences are enabled + judging_round.update(include_average_ranking: true, include_advancement_status: true) + end + + it 'indicates the entry was not selected' do + expect(mail.body.encoded).to include('regret to inform you') + expect(mail.body.encoded).to include('not selected') + end + end + + context 'when there are no external comments' do + before do + # Update the existing ranking to have no external comments + ranking1.update_column(:external_comments, '') + end + + it 'does not include the feedback section' do + expect(mail.body.encoded).not_to include('Feedback from Judges') + end + end + + context 'when it is the final round' do + # Make sure the final round dates come after the first round end date + let(:final_round) { create(:judging_round, contest_instance: contest_instance, round_number: 2, completed: true, + start_date: 1.week.ago, end_date: 2.days.ago, + include_average_ranking: true, include_advancement_status: true) } + + before do + # Assign the same judge to the final round + create(:round_judge_assignment, user: judge, judging_round: final_round) + # Create a ranking in the final round + create(:entry_ranking, entry: entry, user: judge, judging_round: final_round, rank: 1, selected_for_next_round: true) + # Allow the method call that checks for maximum round number + allow(contest_instance.judging_rounds).to receive(:maximum).with(:round_number).and_return(2) + # Use the final round for the mailer + @mail = described_class.entry_evaluation_notification(entry, final_round) + end + + it 'indicates the entry is a finalist' do + expect(@mail.body.encoded).to include('selected as a finalist') + end + end + + context 'with email preferences' do + context 'when include_average_ranking is false' do + before do + judging_round.update(include_average_ranking: false) + end + + it 'does not include the average ranking' do + expect(mail.body.encoded).not_to include('average ranking of') + end + end + + context 'when include_average_ranking is true' do + before do + judging_round.update(include_average_ranking: true) + end + + it 'includes the average ranking' do + expect(mail.body.encoded).to include('average ranking of') + end + end + + context 'when include_advancement_status is false' do + before do + judging_round.update(include_advancement_status: false) + end + + it 'does not include advancement status' do + expect(mail.body.encoded).not_to include('has been selected to advance') + expect(mail.body.encoded).not_to include('selected as a finalist') + expect(mail.body.encoded).not_to include('not selected to advance') + end + end + + context 'when include_advancement_status is true' do + before do + judging_round.update(include_advancement_status: true) + end + + it 'includes advancement status' do + # Since our test entry is selected for next round, it should include this message + expect(mail.body.encoded).to include('has been selected') + end + end + + context 'when both preferences are false' do + before do + judging_round.update(include_average_ranking: false, include_advancement_status: false) + end + + it 'excludes both sections' do + expect(mail.body.encoded).not_to include('average ranking of') + expect(mail.body.encoded).not_to include('has been selected') + expect(mail.body.encoded).not_to include('not selected') + end + + it 'still includes other content' do + expect(mail.body.encoded).to include('Test Entry') + expect(mail.body.encoded).to include('Category: Poetry') + expect(mail.body.encoded).to include('Great work!') + end + end + end + end +end diff --git a/spec/models/container_spec.rb b/spec/models/container_spec.rb index 73903c74..005c55bd 100644 --- a/spec/models/container_spec.rb +++ b/spec/models/container_spec.rb @@ -3,6 +3,7 @@ # Table name: containers # # id :bigint not null, primary key +# contact_email :string(255) # name :string(255) # notes :text(65535) # created_at :datetime not null @@ -100,7 +101,8 @@ name: 'Test Container', department:, visibility:, - creator: user + creator: user, + contact_email: 'test@example.com' ) end diff --git a/spec/models/judging_round_spec.rb b/spec/models/judging_round_spec.rb index 19b31bde..4085e232 100644 --- a/spec/models/judging_round_spec.rb +++ b/spec/models/judging_round_spec.rb @@ -5,7 +5,10 @@ # id :bigint not null, primary key # active :boolean default(FALSE), not null # completed :boolean default(FALSE), not null +# emails_sent_count :integer default(0), not null # end_date :datetime +# include_advancement_status :boolean default(FALSE) +# include_average_ranking :boolean default(FALSE) # min_external_comment_words :integer default(0), not null # min_internal_comment_words :integer default(0), not null # require_external_comments :boolean default(FALSE), not null @@ -207,4 +210,55 @@ end end end + + describe 'email tracking functionality' do + let(:contest_instance) { create(:contest_instance) } + let!(:first_round) do + create(:judging_round, + contest_instance: contest_instance, + round_number: 1, + start_date: contest_instance.date_closed + 1.day, + end_date: contest_instance.date_closed + 2.days) + end + + it 'defaults emails_sent_count to 0' do + expect(first_round.emails_sent_count).to eq(0) + end + + it 'increments emails_sent_count correctly' do + expect { + first_round.increment!(:emails_sent_count) + }.to change { first_round.reload.emails_sent_count }.from(0).to(1) + + expect { + first_round.increment!(:emails_sent_count) + }.to change { first_round.reload.emails_sent_count }.from(1).to(2) + end + + context 'with multiple rounds' do + let!(:second_round) do + create(:judging_round, + contest_instance: contest_instance, + round_number: 2, + start_date: first_round.end_date + 1.hour, + emails_sent_count: 0) + end + + before do + # Set first round to have some emails sent for testing + first_round.update!(emails_sent_count: 2) + end + + it 'tracks emails_sent_count independently for each round' do + expect(first_round.reload.emails_sent_count).to eq(2) + expect(second_round.emails_sent_count).to eq(0) + + second_round.increment!(:emails_sent_count) + expect(second_round.reload.emails_sent_count).to eq(1) + + # Ensure first round count remains unchanged + expect(first_round.reload.emails_sent_count).to eq(2) + end + end + end end diff --git a/spec/system/email_preferences_spec.rb b/spec/system/email_preferences_spec.rb new file mode 100644 index 00000000..9621dcca --- /dev/null +++ b/spec/system/email_preferences_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe 'Email Preferences', type: :system do + let(:department) { create(:department, name: 'Test Department') } + let(:admin_user) { create(:user, :axis_mundi) } + let(:container) { create(:container, name: 'Test Container', department: department, contact_email: 'admin@example.com') } + let(:contest_description) { create(:contest_description, name: 'Test Contest', container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description, date_open: 4.months.ago, date_closed: 3.months.ago) } + + # Create applicant user and entry + let(:applicant) { create(:user, email: 'applicant@example.com') } + let(:profile) { create(:profile, user: applicant) } + let(:entry) { create(:entry, title: 'Test Entry', profile: profile, contest_instance: contest_instance) } + + # Create judge and rankings + let(:judge) { + judge = create(:user, :with_judge_role) + create(:judging_assignment, user: judge, contest_instance: contest_instance, active: true) + judge + } + + # Create judging rounds + let(:judging_round) { create(:judging_round, + contest_instance: contest_instance, + round_number: 1, + start_date: 50.days.ago, + end_date: 30.days.ago, + completed: true) } + + before do + # Create rankings + create(:entry_ranking, + entry: entry, + judging_round: judging_round, + user: judge, + rank: 2, + external_comments: 'Good submission!', + selected_for_next_round: true) + + # Assign the judge to the round + create(:round_judge_assignment, judging_round: judging_round, user: judge) + + # Sign in as admin + login_as(admin_user, scope: :user) + + # Mock the mailer to avoid actually sending emails in tests + allow(ResultsMailer).to receive(:entry_evaluation_notification).and_return(double(deliver_now: true, deliver_later: true)) + end + + it 'displays the email preferences form', :js do + # Visit the email preferences page directly + visit email_preferences_container_contest_description_contest_instance_path( + container, + contest_description, + contest_instance, + round_id: judging_round.id + ) + + # Verify we're on the email preferences page + expect(page).to have_content('Email Results Preferences') + expect(page).to have_content('Round 1 Email Content Options') + + # Check that both checkboxes exist + expect(page).to have_field('include_average_ranking') + expect(page).to have_field('include_advancement_status') + end +end diff --git a/spec/system/judging_results_email_spec.rb b/spec/system/judging_results_email_spec.rb new file mode 100644 index 00000000..aabd4f9e --- /dev/null +++ b/spec/system/judging_results_email_spec.rb @@ -0,0 +1,130 @@ +require 'rails_helper' + +RSpec.describe 'Judging Results Email', type: :system do + let(:department) { create(:department, name: 'Test Department') } + let(:admin_user) { create(:user, :axis_mundi) } + let(:container) { create(:container, name: 'Test Container', department: department, contact_email: 'admin@example.com') } + let(:contest_description) { create(:contest_description, name: 'Test Contest', container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description, date_open: 4.months.ago, date_closed: 3.months.ago) } + + # Create applicant user and entry + let(:applicant) { create(:user, email: 'applicant@example.com') } + let(:profile) { create(:profile, user: applicant) } + let(:entry) { create(:entry, title: 'Test Entry', profile: profile, contest_instance: contest_instance) } + + # Create judge and rankings + let(:judge) { + judge = create(:user, :with_judge_role) + create(:judging_assignment, user: judge, contest_instance: contest_instance, active: true) + judge + } + + # Create judging rounds + let(:judging_round) { create(:judging_round, + contest_instance: contest_instance, + round_number: 1, + start_date: 50.days.ago, + end_date: 30.days.ago, + completed: true) } + + let(:incomplete_round) { create(:judging_round, + contest_instance: contest_instance, + round_number: 2, + start_date: 20.days.ago, + end_date: 10.days.ago, + completed: false) } + + before do + # Create rankings + create(:entry_ranking, + entry: entry, + judging_round: judging_round, + user: judge, + rank: 2, + external_comments: 'Good submission!', + selected_for_next_round: true) + + create(:entry_ranking, + entry: entry, + judging_round: incomplete_round, + user: judge) + + # Assign the judge to the rounds + create(:round_judge_assignment, judging_round: judging_round, user: judge) + create(:round_judge_assignment, judging_round: incomplete_round, user: judge) + + # Set up email counter + judging_round.update(emails_sent_count: 1) + + # Sign in as admin + login_as(admin_user, scope: :user) + + # Mock the mailer to avoid actually sending emails in tests + allow(ResultsMailer).to receive(:entry_evaluation_notification).and_return(double(deliver_now: true, deliver_later: true)) + end + + it 'shows completed round email counter and enables/disables links correctly', :js do + # Visit the contest instance page + visit container_contest_description_contest_instance_path(container, contest_description, contest_instance) + + # Click the Judging Results tab + execute_script("document.getElementById('judging-results-tab').click()") + sleep 1 + + # Verify we can see both rounds + expect(page).to have_content('Round 1') + expect(page).to have_content('Round 2') + + # Get all links with email text + links = page.all('a', text: /Email round \d+ results/) + + # Check there are 2 links (one for each round) + expect(links.size).to eq(2) + + # First link should be for round 1 and enabled + expect(links[0].text).to include('Email round 1 results') + expect(links[0]['disabled']).to be_nil + + # Second link should be for round 2 and disabled + expect(links[1].text).to include('Email round 2 results') + expect(links[1]['disabled']).to eq('disabled') + + # Check that the badge is displayed for round 1 + expect(page).to have_content('Emails sent: 1 time') + end + + it 'navigates to email preferences and sends emails', :js do + # Visit the contest instance page + visit container_contest_description_contest_instance_path(container, contest_description, contest_instance) + + # Click the Judging Results tab + execute_script("document.getElementById('judging-results-tab').click()") + sleep 1 + + # Initial badge count + expect(page).to have_content('Emails sent: 1 time') + + # Click the email link for round 1 + click_link 'Email round 1 results' + + # Verify we're on the email preferences page + expect(page).to have_content('Email Results Preferences') + expect(page).to have_content('Round 1 Email Content Options') + + # Submit the form with default preferences + accept_confirm do + click_button 'Send Emails' + end + + # Verify success message appears + expect(page).to have_content('Successfully queued 1 evaluation result emails') + + # Refresh the page to ensure we're seeing the latest data + visit current_path + execute_script("document.getElementById('judging-results-tab').click()") + sleep 1 + + # Verify counter increased + expect(page).to have_content('Emails sent: 2 times') + end +end