diff --git a/app/controllers/judging_assignments_controller.rb b/app/controllers/judging_assignments_controller.rb index 13487f4e..0fbc1967 100644 --- a/app/controllers/judging_assignments_controller.rb +++ b/app/controllers/judging_assignments_controller.rb @@ -135,6 +135,23 @@ def create_judge end end + def judge_lookup + @container = Container.find(params[:container_id]) + @contest_description = ContestDescription.find(params[:contest_description_id]) + @contest_instance = ContestInstance.find(params[:contest_instance_id]) + + assigned_ids = @contest_instance.judging_assignments.pluck(:user_id) + query = params[:q].to_s.strip + + available_judges = User.joins(:roles) + .where(roles: { kind: 'Judge' }) + .where.not(id: assigned_ids) + .where('users.first_name LIKE :q OR users.last_name LIKE :q OR users.email LIKE :q', q: "%#{query}%") + .limit(10) + + render json: available_judges.map { |u| { id: u.id, name: "#{u.first_name} #{u.last_name} (#{u.email})" } } + end + private def set_contest_instance diff --git a/app/controllers/judging_rounds_controller.rb b/app/controllers/judging_rounds_controller.rb index 200310b4..81622f1b 100644 --- a/app/controllers/judging_rounds_controller.rb +++ b/app/controllers/judging_rounds_controller.rb @@ -1,6 +1,6 @@ class JudgingRoundsController < ApplicationController before_action :set_contest_instance - before_action :set_judging_round, only: [ :show, :edit, :update, :destroy, :activate, :deactivate, :complete, :uncomplete, :update_rankings, :finalize_rankings ] + before_action :set_judging_round, only: [ :show, :edit, :update, :destroy, :activate, :deactivate, :complete, :uncomplete, :update_rankings, :finalize_rankings, :send_instructions ] before_action :authorize_contest_instance before_action :check_edit_warning, only: [ :edit, :update ] @@ -99,6 +99,38 @@ def uncomplete end end + def send_instructions + if @judging_round.judges.empty? + redirect_to container_contest_description_contest_instance_judging_assignments_path( + @container, @contest_description, @contest_instance + ), alert: 'No judges assigned to this round.' + return + end + + sent_count = 0 + failed_emails = [] + + @judging_round.round_judge_assignments.active.includes(:user).each do |assignment| + begin + JudgingInstructionsMailer.send_instructions(assignment).deliver_later + sent_count += 1 + rescue => e + failed_emails << assignment.user.email + Rails.logger.error "Failed to send judging instructions to #{assignment.user.email}: #{e.message}" + end + end + + if failed_emails.empty? + notice_message = "Judging instructions sent successfully to #{sent_count} judge(s)." + else + notice_message = "Sent instructions to #{sent_count} judge(s). Failed to send to: #{failed_emails.join(', ')}" + end + + redirect_to container_contest_description_contest_instance_judging_assignments_path( + @container, @contest_description, @contest_instance + ), notice: notice_message + end + def update_rankings rankings = params[:rankings] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 00000000..f491d08a --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,7 @@ +class UsersController < ApplicationController + def lookup + query = params[:q].to_s.strip + users = User.where('first_name LIKE :q OR last_name LIKE :q OR email LIKE :q OR uid LIKE :q', q: "%#{query}%").limit(10) + render json: users.map { |u| { id: u.id, name: "#{u.first_name} #{u.last_name} (#{u.email})", uid: u.uid } } + end +end diff --git a/app/javascript/controllers/uid_lookup_controller.js b/app/javascript/controllers/uid_lookup_controller.js index 3b280b43..a6e64e12 100644 --- a/app/javascript/controllers/uid_lookup_controller.js +++ b/app/javascript/controllers/uid_lookup_controller.js @@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["displayInput", "uidInput", "results"] + static values = { url: String } connect() { this.timeout = null @@ -13,7 +14,8 @@ export default class extends Controller { this.timeout = setTimeout(() => { const query = this.displayInputTarget.value.trim() if (query.length > 0) { - fetch(`/containers/lookup_user?uid=${encodeURIComponent(query)}`) + const url = this.hasUrlValue ? this.urlValue : '/containers/lookup_user' + fetch(`${url}?${url.includes('lookup_user') ? 'uid' : 'q'}=${encodeURIComponent(query)}`) .then(response => response.json()) .then(data => { this.showSuggestions(data) @@ -28,11 +30,11 @@ export default class extends Controller { this.clearSuggestions() users.forEach(user => { const option = document.createElement('div') - option.textContent = user.display_name_and_uid + option.textContent = user.display_name_and_uid || user.name || `${user.first_name} ${user.last_name} (${user.email})` option.classList.add('suggestion') option.addEventListener('click', () => { - this.displayInputTarget.value = user.display_name_and_uid - this.uidInputTarget.value = user.uid + this.displayInputTarget.value = user.display_name_and_uid || user.name || `${user.first_name} ${user.last_name} (${user.email})` + this.uidInputTarget.value = user.uid || user.id this.clearSuggestions() }) this.resultsTarget.appendChild(option) @@ -49,4 +51,4 @@ export default class extends Controller { alert('Please select a valid user.') } } -} \ No newline at end of file +} diff --git a/app/mailers/judging_instructions_mailer.rb b/app/mailers/judging_instructions_mailer.rb new file mode 100644 index 00000000..8fc2fb57 --- /dev/null +++ b/app/mailers/judging_instructions_mailer.rb @@ -0,0 +1,32 @@ +class JudgingInstructionsMailer < ApplicationMailer + def send_instructions(round_judge_assignment) + @round_judge_assignment = round_judge_assignment + @judge = @round_judge_assignment.user + @judging_round = @round_judge_assignment.judging_round + @contest_instance = @judging_round.contest_instance + @contest_description = @contest_instance.contest_description + @container = @contest_description.container + @judge_email = @judge.normalize_email + + # Get the special instructions from the judging round + @special_instructions = @judging_round.special_instructions.presence + + # Get the contact email for display in the email body + # Use container's contact_email if present, otherwise use the default reply-to email + @contact_email = @container.contact_email.presence || 'LSA Evaluate Support ' + + subject = "Judging Instructions for #{@contest_description.name} - Round #{@judging_round.round_number}" + + # Set mail options + mail_options = { + to: @judge_email, + subject: subject + } + + # Override reply_to with container's contact_email if present + # If not present, the default reply-to from ApplicationMailer will be used + mail_options[:reply_to] = @container.contact_email if @container.contact_email.present? + + mail(mail_options) + end +end diff --git a/app/mailers/results_mailer.rb b/app/mailers/results_mailer.rb index 91680d92..6c2b81dc 100644 --- a/app/mailers/results_mailer.rb +++ b/app/mailers/results_mailer.rb @@ -9,10 +9,7 @@ def entry_evaluation_notification(entry, round) @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' + @contact_email = @container.contact_email.presence || 'LSA Evaluate Support ' # Get all rankings for this entry in this round @rankings = EntryRanking.where(entry: @entry, judging_round: @round) diff --git a/app/models/user.rb b/app/models/user.rb index 0a4564b1..ddd77e8e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -107,6 +107,16 @@ def display_name_or_first_name_last_name (display_name.presence || "#{first_name} #{last_name}") end + def normalize_email + if email.end_with?('@umich.edu') && email.include?('+') + local_part, _domain = email.split('@') + username, original_domain = local_part.split('+') + "#{username}@#{original_domain}" + else + email + end + end + def display_initials_or_uid if display_name.present? display_name.split.map { |name| name[0].upcase }.join diff --git a/app/views/containers/_assignment_form.html.erb b/app/views/containers/_assignment_form.html.erb index 35c63e4e..187c80ff 100644 --- a/app/views/containers/_assignment_form.html.erb +++ b/app/views/containers/_assignment_form.html.erb @@ -18,16 +18,18 @@ <% end %> -
- <%= f.label :uid_display, 'User' %> - <%= text_field_tag :uid_display, nil, - class: 'form-control', - data: { - uid_lookup_target: 'displayInput', - action: 'input->uid-lookup#lookup' - } %> - <%= f.hidden_field :uid, data: { uid_lookup_target: 'uidInput' } %> -
+
+
+ <%= label_tag :uid_display, 'User' %> + <%= text_field_tag :uid_display, nil, + class: 'form-control', + data: { + uid_lookup_target: 'displayInput', + action: 'input->uid-lookup#lookup' + } %> + <%= hidden_field_tag 'assignment[uid]', nil, data: { uid_lookup_target: 'uidInput' } %> +
+
@@ -45,4 +47,3 @@ <%= f.submit 'Add User', class: "btn btn-primary my-2" %> <%= link_to "Cancel", container_path(container), class: "text-danger link_to", data: { turbo: false, method: :get } %> <% end %> - diff --git a/app/views/contest_instances/_manage_judges.html.erb b/app/views/contest_instances/_manage_judges.html.erb index 8f2d695f..8aff54b8 100644 --- a/app/views/contest_instances/_manage_judges.html.erb +++ b/app/views/contest_instances/_manage_judges.html.erb @@ -6,6 +6,18 @@
Round <%= round.round_number %>
+ <% if round.judges.any? %> + <%= button_to send_instructions_container_contest_description_contest_instance_judging_round_path( + @container, @contest_description, contest_instance, round + ), + method: :post, + class: "btn btn-sm btn-outline-success", + data: { + turbo_confirm: "Send judging instructions to #{pluralize(round.judges.count, 'judge')}?" + } do %> + Send judging instructions + <% end %> + <% end %> <%= round.active ? 'Active' : (round.completed ? 'Completed' : 'Pending') %> @@ -22,6 +34,7 @@
Judges Assigned: +
<% if round.judges.any? %> <% round.judges.each do |judge| %> @@ -36,7 +49,6 @@ No judges assigned to this round <% end %>
-
diff --git a/app/views/judging_assignments/_judging_pool.html.erb b/app/views/judging_assignments/_judging_pool.html.erb index 115ea84f..08e3d194 100644 --- a/app/views/judging_assignments/_judging_pool.html.erb +++ b/app/views/judging_assignments/_judging_pool.html.erb @@ -36,14 +36,20 @@

Start typing the name or email address of a judge to search for them system-wide. If you don't find the judge you're looking for, you can create a new judge using the "Create a new judge ..." form below.

<%= form_with(model: [@container, @contest_description, @contest_instance, JudgingAssignment.new], local: true) do |f| %> -
- <%= f.label :user_id, "Select Judge" %> - <%= f.select :user_id, - @available_judges.map { |u| ["#{u.first_name} #{u.last_name} (#{display_email(u.email)})", u.id] }, - { prompt: "Select a judge" }, - class: "form-control" %> +
+
+ <%= label_tag :uid_display, 'Judge' %> + <%= text_field_tag :uid_display, nil, + class: 'form-control', + data: { + uid_lookup_target: 'displayInput', + action: 'input->uid-lookup#lookup' + } %> + <%= hidden_field_tag 'judging_assignment[user_id]', nil, data: { uid_lookup_target: 'uidInput' } %> +
+
+ <%= f.submit "Assign Judge", class: "btn btn-primary mt-3" %>
- <%= f.submit "Assign Judge", class: "btn btn-primary mt-3" %> <% end %>
@@ -57,7 +63,7 @@
-

Use this form to create a new judge and assign them to the pool of judges for this contest instance. The new judge will receive an email with instructions on how to log in to the judging dashboard.

+

Use this form to create a new judge and assign them to the pool of judges for this contest instance.

<%= form_with(url: create_judge_container_contest_description_contest_instance_judging_assignments_path(@container, @contest_description, @contest_instance), local: true, html: { novalidate: true }) do |f| %>
<%= f.label :email, "Email Address" %> diff --git a/app/views/judging_instructions_mailer/send_instructions.html.erb b/app/views/judging_instructions_mailer/send_instructions.html.erb new file mode 100644 index 00000000..b61a573d --- /dev/null +++ b/app/views/judging_instructions_mailer/send_instructions.html.erb @@ -0,0 +1,87 @@ +
+

+ Judging Instructions for <%= @contest_description.name %> +

+ +

+ Dear <%= @judge.display_name_or_first_name_last_name %>, +

+ +

+ You have been assigned as a judge for Round <%= @judging_round.round_number %> of the <%= @contest_description.name %> contest. +

+ +
+

Judging Period

+

+ Start Date: <%= I18n.l(@judging_round.start_date, format: :long) %>
+ End Date: <%= I18n.l(@judging_round.end_date, format: :long) %> +

+
+ + <% if @judging_round.required_entries_count > 0 %> +
+

Requirements

+

+ Minimum entries to evaluate: <%= @judging_round.required_entries_count %> +

+ <% if @judging_round.require_internal_comments %> +

+ Internal comments: Required (minimum <%= @judging_round.min_internal_comment_words %> words) +

+ <% end %> + <% if @judging_round.require_external_comments %> +

+ External comments: Required (minimum <%= @judging_round.min_external_comment_words %> words)
+ Note: External comments will be shared with applicants +

+ <% end %> +
+ <% end %> + + <% if @special_instructions.present? %> +
+

Special Instructions

+
+ <%= simple_format(@special_instructions) %> +
+
+ <% end %> + +
+

Important: Email Account Access

+

+ Only the email account <%= @judge_email %> has access to the judging system. +

+

+ If you need to use a different email account, please contact us to adjust your access. +

+ <% unless @judge_email.end_with?('@umich.edu') %> +

+ For non-@umich.edu accounts: You'll need to set up a Friend account at + friend.weblogin.umich.edu +

+ <% end %> +
+ +
+ <%= link_to "Go to Judging Dashboard", judge_dashboard_index_url, + style: "display: inline-block; padding: 12px 30px; background-color: #00274C; color: white; text-decoration: none; border-radius: 5px; font-size: 16px;" %> +
+ +

+ If you have any questions about the judging process, please contact us at + <%= @contact_email %>. +

+ +

+ Thank you for your time and commitment to this contest. +

+ +
+ +

+ This email was sent from LSA Evaluate.
+ University of Michigan +

+
diff --git a/app/views/judging_instructions_mailer/send_instructions.text.erb b/app/views/judging_instructions_mailer/send_instructions.text.erb new file mode 100644 index 00000000..5a1c8454 --- /dev/null +++ b/app/views/judging_instructions_mailer/send_instructions.text.erb @@ -0,0 +1,53 @@ +JUDGING INSTRUCTIONS FOR <%= @contest_description.name.upcase %> +================================================================================ + +Dear <%= @judge.display_name_or_first_name_last_name %>, + +You have been assigned as a judge for Round <%= @judging_round.round_number %> of the <%= @contest_description.name %> contest. + +JUDGING PERIOD +-------------- +Start Date: <%= I18n.l(@judging_round.start_date, format: :long) %> +End Date: <%= I18n.l(@judging_round.end_date, format: :long) %> + +<% if @judging_round.required_entries_count > 0 %> +REQUIREMENTS +------------ +Minimum entries to evaluate: <%= @judging_round.required_entries_count %> +<% if @judging_round.require_internal_comments %> +Internal comments: Required (minimum <%= @judging_round.min_internal_comment_words %> words) +<% end %> +<% if @judging_round.require_external_comments %> +External comments: Required (minimum <%= @judging_round.min_external_comment_words %> words) +Note: External comments will be shared with applicants +<% end %> + +<% end %> +<% if @special_instructions.present? %> +SPECIAL INSTRUCTIONS +------------------- +<%= @special_instructions %> + +<% end %> +TO ACCESS THE JUDGING DASHBOARD: +------------------------------- +Please visit: <%= judge_dashboard_index_url %> + +IMPORTANT: EMAIL ACCOUNT ACCESS +------------------------------- +Only the email account <%= @judge_email %> has access to the judging system. + +If you need to use a different email account, please contact us to adjust your access. +<% unless @judge_email.end_with?('@umich.edu') %> + +For non-@umich.edu accounts: You'll need to set up a Friend account at +https://friend.weblogin.umich.edu/friend/ +<% end %> + +If you have any questions about the judging process, please contact us at <%= @contact_email %>. + +Thank you for your time and commitment to this contest. + +-------------------------------------------------------------------------------- +This email was sent from LSA Evaluate. +University of Michigan diff --git a/config/environments/test.rb b/config/environments/test.rb index a44ad823..56275067 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -44,6 +44,7 @@ # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # config.force_ssl = true diff --git a/config/routes.rb b/config/routes.rb index e8ba039b..0708e827 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -54,6 +54,7 @@ patch :deactivate patch :complete patch :uncomplete + post :send_instructions end post 'update_rankings', on: :member post 'finalize_rankings', on: :member @@ -120,6 +121,22 @@ resources :users_dashboard, only: %i[ index show ] + # For generic user lookup (autocomplete for assignment form) + get 'users/lookup', to: 'users#lookup', as: :user_lookup + + # For judge lookup (autocomplete for judging pool, nested under contest instance) + resources :containers do + resources :contest_descriptions do + resources :contest_instances do + resources :judging_assignments do + collection do + get :judge_lookup + end + end + end + end + end + # Place this at the very end of the file to catch all undefined routes match '*path', to: 'errors#not_found', via: :all, constraints: lambda { |req| req.path.exclude?('/rails/active_storage') && diff --git a/spec/controllers/judging_assignments_controller_spec.rb b/spec/controllers/judging_assignments_controller_spec.rb index b0b818f6..0ba831b7 100644 --- a/spec/controllers/judging_assignments_controller_spec.rb +++ b/spec/controllers/judging_assignments_controller_spec.rb @@ -112,4 +112,53 @@ end end end + + describe '#judge_lookup' do + let!(:available_judge) { create(:user, first_name: 'Alice', last_name: 'Smith', email: 'alice@example.com') } + let!(:other_judge) { create(:user, first_name: 'Bob', last_name: 'Jones', email: 'bob@example.com') } + let!(:non_judge) { create(:user, first_name: 'Carol', last_name: 'Brown', email: 'carol@example.com') } + + before do + available_judge.roles << judge_role + other_judge.roles << judge_role + # Assign other_judge to this contest_instance (should be excluded) + create(:judging_assignment, user: other_judge, contest_instance: contest_instance) + end + + it 'returns only available judges not already assigned' do + get :judge_lookup, params: { + container_id: container.id, + contest_description_id: contest_description.id, + contest_instance_id: contest_instance.id, + q: '' + }, format: :json + ids = JSON.parse(response.body).map { |u| u['id'] } + expect(ids).to include(available_judge.id) + expect(ids).not_to include(other_judge.id) + expect(ids).not_to include(non_judge.id) + end + + it 'filters judges by query' do + get :judge_lookup, params: { + container_id: container.id, + contest_description_id: contest_description.id, + contest_instance_id: contest_instance.id, + q: 'Alice' + }, format: :json + results = JSON.parse(response.body) + expect(results.length).to eq(1) + expect(results.first['name']).to include('Alice') + end + + it 'returns JSON with id and name' do + get :judge_lookup, params: { + container_id: container.id, + contest_description_id: contest_description.id, + contest_instance_id: contest_instance.id, + q: 'Alice' + }, format: :json + result = JSON.parse(response.body).first + expect(result.keys).to include('id', 'name') + end + end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb new file mode 100644 index 00000000..47184592 --- /dev/null +++ b/spec/controllers/users_controller_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +RSpec.describe UsersController, type: :controller do + describe '#lookup' do + let(:current_user) { create(:user) } + let!(:user1) { create(:user, first_name: 'Alice', last_name: 'Smith', email: 'alice@example.com', uid: 'alice1') } + let!(:user2) { create(:user, first_name: 'Bob', last_name: 'Jones', email: 'bob@example.com', uid: 'bobby') } + let!(:user3) { create(:user, first_name: 'Carol', last_name: 'Brown', email: 'carol@example.com', uid: 'carolb') } + + before do + sign_in current_user + end + + it 'returns users matching the query by name' do + get :lookup, params: { q: 'Alice' }, format: :json + results = JSON.parse(response.body) + expect(results).not_to be_empty + expect(results.first['name']).to include('Alice') + end + + it 'returns users matching the query by email' do + get :lookup, params: { q: 'bob@example.com' }, format: :json + results = JSON.parse(response.body) + expect(results).not_to be_empty + expect(results.first['name']).to include('Bob') + end + + it 'returns users matching the query by uid' do + get :lookup, params: { q: 'carolb' }, format: :json + results = JSON.parse(response.body) + expect(results).not_to be_empty + expect(results.first['name']).to include('Carol') + end + + it 'returns JSON with id, name, and uid' do + get :lookup, params: { q: 'Alice' }, format: :json + results = JSON.parse(response.body) + expect(results).not_to be_empty + expect(results.first.keys).to include('id', 'name', 'uid') + end + + it 'limits results to 10' do + create_list(:user, 15) do |user, i| + user.first_name = 'Test' + user.last_name = 'User' + user.email = "testuser#{i}@example.com" + user.uid = "testuser#{i}" + user.save! + end + get :lookup, params: { q: 'Test' }, format: :json + results = JSON.parse(response.body) + expect(results.length).to be <= 10 + end + end +end diff --git a/spec/mailers/judging_instructions_mailer_spec.rb b/spec/mailers/judging_instructions_mailer_spec.rb new file mode 100644 index 00000000..6d9c97fa --- /dev/null +++ b/spec/mailers/judging_instructions_mailer_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +RSpec.describe JudgingInstructionsMailer, type: :mailer do + describe '#send_instructions' do + let(:container) { create(:container, contact_email: 'contest_admin@umich.edu') } + let(:contest_description) { create(:contest_description, :active, container: container, name: 'Test Contest') } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } + let(:judging_round) do + create(:judging_round, + contest_instance: contest_instance, + round_number: 2, + special_instructions: 'Please evaluate entries based on creativity and originality.', + required_entries_count: 5, + require_internal_comments: true, + min_internal_comment_words: 50, + require_external_comments: true, + min_external_comment_words: 100) + end + let(:judge) { create(:user, :with_judge_role, email: 'judge@umich.edu', first_name: 'Jane', last_name: 'Doe') } + let(:round_judge_assignment) { create(:round_judge_assignment, :with_contest_assignment, user: judge, judging_round: judging_round) } + + let(:mail) { described_class.send_instructions(round_judge_assignment) } + + it 'renders the headers' do + expect(mail.subject).to eq("Judging Instructions for Test Contest - Round 2") + expect(mail.to).to eq(['judge@umich.edu']) + expect(mail.from).to include(Rails.application.credentials.dig(:sendgrid, :mailer_sender)) + end + + it 'uses the container contact email as reply-to when present' do + expect(mail.reply_to).to eq([ 'contest_admin@umich.edu' ]) + end + + it 'falls back to default reply-to when container contact email is not present' do + # Create a new container without contact_email by overriding validation + container_without_email = container + container_without_email.contact_email = '' + container_without_email.save(validate: false) + contest_description.update!(container: container_without_email) + + # Need to regenerate the mail with the updated container + mail_without_contact = described_class.send_instructions(round_judge_assignment) + # When no container email is set, it should use ApplicationMailer's default + expect(mail_without_contact.reply_to).to eq([ 'lsa-evaluate-support@umich.edu' ]) + end + + describe 'email body' do + it 'includes judge name' do + expect(mail.body.encoded).to include('Jane Doe') + end + + it 'includes contest name and round number' do + expect(mail.body.encoded).to include('Test Contest') + expect(mail.body.encoded).to include('Round 2') + end + + it 'includes judging period dates' do + expect(mail.body.encoded).to include(I18n.l(judging_round.start_date, format: :long)) + expect(mail.body.encoded).to include(I18n.l(judging_round.end_date, format: :long)) + end + + it 'includes special instructions' do + expect(mail.body.encoded).to include('Please evaluate entries based on creativity and originality.') + end + + it 'includes requirements' do + expect(mail.body.encoded).to include('5') # required_entries_count + expect(mail.body.encoded).to include('50') # min_internal_comment_words + expect(mail.body.encoded).to include('100') # min_external_comment_words + end + + it 'includes link to judging dashboard' do + expect(mail.body.encoded).to include(judge_dashboard_index_url) + end + + it 'includes contact email' do + expect(mail.body.encoded).to include('contest_admin@umich.edu') + end + + it 'shows default contact email when container email is not present' do + container.contact_email = '' + container.save(validate: false) + contest_description.reload + + mail_without_contact = described_class.send_instructions(round_judge_assignment) + expect(mail_without_contact.body.encoded).to include('lsa-evaluate-support@umich.edu') + end + end + + context 'when special instructions are not present' do + before do + judging_round.update!(special_instructions: nil) + end + + it 'does not include the special instructions section' do + # Check both parts of the multipart email + html_part = mail.html_part ? mail.html_part.body.to_s : mail.body.to_s + text_part = mail.text_part ? mail.text_part.body.to_s : mail.body.to_s + + expect(html_part).not_to include('Special Instructions') + expect(text_part).not_to include('SPECIAL INSTRUCTIONS') + end + end + + context 'when comment requirements are not present' do + before do + judging_round.update!(require_internal_comments: false, require_external_comments: false) + end + + it 'does not include comment requirements' do + expect(mail.body.encoded).not_to include('Internal comments:') + expect(mail.body.encoded).not_to include('External comments:') + end + end + end +end diff --git a/spec/mailers/results_mailer_spec.rb b/spec/mailers/results_mailer_spec.rb index 6b02f3b2..073ffcf0 100644 --- a/spec/mailers/results_mailer_spec.rb +++ b/spec/mailers/results_mailer_spec.rb @@ -68,12 +68,12 @@ end it 'falls back to the default contact email from credentials' do - default_email = Rails.application.credentials.dig(:mailer, :default_contact_email) + default_email = 'LSA Evaluate Support ' if default_email expect(mail.body.encoded).to include(default_email) else - default_sender = Rails.application.credentials.dig(:devise, :mailer_sender) || 'contests@example.com' + default_sender = Rails.application.credentials.dig(:devise, :mailer_sender) expect(mail.body.encoded).to include(default_sender) end end diff --git a/test/mailers/previews/judging_instructions_mailer_preview.rb b/test/mailers/previews/judging_instructions_mailer_preview.rb new file mode 100644 index 00000000..5313086b --- /dev/null +++ b/test/mailers/previews/judging_instructions_mailer_preview.rb @@ -0,0 +1,125 @@ +class JudgingInstructionsMailerPreview < ActionMailer::Preview + def send_instructions + # Find or create sample data for preview + round_judge_assignment = RoundJudgeAssignment.joins(:user, :judging_round) + .joins(user: :roles) + .where(roles: { kind: 'Judge' }) + .first + + if round_judge_assignment.nil? + # Create sample data if none exists + judge = User.joins(:roles).where(roles: { kind: 'Judge' }).first || create_sample_judge + judging_round = JudgingRound.includes(:contest_instance).first || create_sample_judging_round + + round_judge_assignment = RoundJudgeAssignment.create!( + user: judge, + judging_round: judging_round + ) + end + + # Ensure the judging round has special instructions for preview + if round_judge_assignment.judging_round.special_instructions.blank? + round_judge_assignment.judging_round.update!( + special_instructions: sample_special_instructions + ) + end + + JudgingInstructionsMailer.send_instructions(round_judge_assignment) + end + + private + + def create_sample_judge + judge = User.create!( + email: "sample_judge@umich.edu", + first_name: "Sample", + last_name: "Judge", + uid: "sample_judge", + password: "password123", + password_confirmation: "password123" + ) + + # Add judge role + judge_role = Role.find_or_create_by!(kind: 'Judge', description: 'Judge') + judge.roles << judge_role + + judge + end + + def create_sample_judging_round + contest_instance = ContestInstance.first || create_sample_contest_instance + + JudgingRound.create!( + contest_instance: contest_instance, + round_number: 1, + start_date: 1.day.from_now, + end_date: 7.days.from_now, + required_entries_count: 5, + require_internal_comments: true, + min_internal_comment_words: 50, + require_external_comments: true, + min_external_comment_words: 100, + special_instructions: sample_special_instructions + ) + end + + def create_sample_contest_instance + contest_description = ContestDescription.first || create_sample_contest_description + + ContestInstance.create!( + contest_description: contest_description, + date_open: 1.month.ago, + date_closed: 1.day.ago, + created_by: "admin@umich.edu", + active: true + ) + end + + def create_sample_contest_description + container = Container.first || create_sample_container + + ContestDescription.create!( + name: "Sample Writing Contest", + container: container, + active: true + ) + end + + def create_sample_container + Container.create!( + name: "Sample Container", + contact_email: "contests@umich.edu" + ) + end + + def sample_special_instructions + <<~INSTRUCTIONS + Thank you for agreeing to judge this contest. Here are some important guidelines: + + 1. **Evaluation Criteria:** + - Originality and creativity (25%) + - Quality of writing and style (25%) + - Adherence to contest theme (25%) + - Overall impact and engagement (25%) + + 2. **Ranking Guidelines:** + - Please rank entries from 1 (best) to N (number of entries you review) + - Avoid giving the same rank to multiple entries + - Consider the target audience when evaluating + + 3. **Comment Requirements:** + - Internal comments are for contest administrators only + - External comments will be shared with contestants + - Please be constructive and encouraging in external comments + + 4. **Conflict of Interest:** + - If you recognize an entry or have any conflict of interest, please notify the contest administrator immediately + + 5. **Confidentiality:** + - Please do not discuss entries with other judges during the evaluation period + - All entries should be treated as confidential + + If you have any questions, please don't hesitate to reach out to the contest administrator. + INSTRUCTIONS + end +end