From c82ec68ef3b1ec345cc01b31294b24f51accb8dd Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 12:47:06 -0400 Subject: [PATCH 01/23] Refactor judge selection input in judging pool for improved usability - Replaced the judge selection dropdown with a dynamic search input field that allows users to type and search for judges by name or email. - Implemented a Turbo controller for real-time lookup and display of matching judges, enhancing the user experience by providing immediate feedback. - This change improves the interface by making judge selection more intuitive and efficient. --- .../_judging_pool.html.erb | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/views/judging_assignments/_judging_pool.html.erb b/app/views/judging_assignments/_judging_pool.html.erb index 115ea84f..fc35c85b 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 %>
From b5e4b87a0ef0420f97c4daf8a189256a43a43d80 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:29:25 -0400 Subject: [PATCH 02/23] Add judge lookup functionality for dynamic judge selection - Implemented a new `judge_lookup` action in the JudgingAssignmentsController to facilitate real-time searching of available judges by name or email. - The action retrieves judges who are not already assigned to the contest instance, enhancing the user experience by allowing for quick and efficient judge selection. - This change complements the recent refactor of the judge selection input, further improving usability in the judging pool. --- .../judging_assignments_controller.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 From 8dccdf95473183f8c1baddbe812351be458576aa Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:29:40 -0400 Subject: [PATCH 03/23] Add user lookup functionality for dynamic user search - Implemented a new `lookup` action in the UsersController to enable real-time searching of users by first name, last name, email, or UID. - The action returns a JSON response with a list of matching users, enhancing the user experience by allowing for quick and efficient user selection. - This addition complements existing dynamic selection features in the application, improving overall usability. --- app/controllers/users_controller.rb | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/controllers/users_controller.rb 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 From dd01ab99708b86e22d862a5b11c28b3cb0a3e6d0 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:30:05 -0400 Subject: [PATCH 04/23] Enhance UID lookup controller for dynamic user search - Added a `url` value to the UID lookup controller to allow for customizable fetch URLs, improving flexibility in user lookups. - Updated the fetch logic to handle both UID and query parameters dynamically, enhancing the search functionality. - Improved the suggestion display logic to fallback on multiple user attributes, ensuring a more informative user selection experience. --- app/javascript/controllers/uid_lookup_controller.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 +} From 9b502b739b256b3687875480deb826f9b82ab581 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:30:16 -0400 Subject: [PATCH 05/23] Refactor assignment form for improved UID lookup integration - Updated the assignment form to utilize a Turbo controller for UID lookup, enhancing the user experience with real-time user search capabilities. - Changed the hidden field for UID to use `hidden_field_tag` for better form handling. - Improved the structure of the form by nesting the UID lookup within a dedicated controller, ensuring cleaner code and better separation of concerns. --- .../containers/_assignment_form.html.erb | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 %> - From bacf8d625ea263b103f9b4864be89a7b3ef4d610 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:30:31 -0400 Subject: [PATCH 06/23] Add button for sending judging instructions in judges management view - Introduced a new button in the judges management partial to allow users to send judging instructions directly from the interface. - Enhanced the layout by ensuring proper alignment and spacing for improved user experience. - This addition complements existing judge management features, streamlining the process for contest organizers. --- app/views/contest_instances/_manage_judges.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/contest_instances/_manage_judges.html.erb b/app/views/contest_instances/_manage_judges.html.erb index 8f2d695f..d2a9f064 100644 --- a/app/views/contest_instances/_manage_judges.html.erb +++ b/app/views/contest_instances/_manage_judges.html.erb @@ -22,6 +22,7 @@
Judges Assigned: +
<% if round.judges.any? %> <% round.judges.each do |judge| %> @@ -36,7 +37,8 @@ No judges assigned to this round <% end %>
-
+
Send judging instructions +
From 68df3ecdae5c1aad9f6532f7d2e8b71e834ed7ad Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:33:57 -0400 Subject: [PATCH 07/23] Conditionally display the "Send judging instructions" button in judges management view - Updated the judges management partial to only show the "Send judging instructions" button when judges are assigned to the current round. - This change improves the user interface by preventing confusion when no judges are available, enhancing overall usability. --- app/views/contest_instances/_manage_judges.html.erb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/contest_instances/_manage_judges.html.erb b/app/views/contest_instances/_manage_judges.html.erb index d2a9f064..9631a581 100644 --- a/app/views/contest_instances/_manage_judges.html.erb +++ b/app/views/contest_instances/_manage_judges.html.erb @@ -37,8 +37,10 @@ No judges assigned to this round <% end %> -
Send judging instructions -
+ <% if round.judges.any? %> +
Send judging instructions +
+ <% end %> From e6870fde64278d21a95da1c8becc8b152e49170b Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:34:09 -0400 Subject: [PATCH 08/23] Update UID lookup integration in judging pool partial - Added a dynamic URL value to the UID lookup controller for enhanced flexibility in fetching judges. - Simplified the text in the judge creation form to improve clarity and focus on the assignment process. - These changes enhance the user experience by streamlining the judge selection and creation workflow. --- app/views/judging_assignments/_judging_pool.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/judging_assignments/_judging_pool.html.erb b/app/views/judging_assignments/_judging_pool.html.erb index fc35c85b..08e3d194 100644 --- a/app/views/judging_assignments/_judging_pool.html.erb +++ b/app/views/judging_assignments/_judging_pool.html.erb @@ -36,7 +36,7 @@

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| %> -
+
<%= label_tag :uid_display, 'Judge' %> <%= text_field_tag :uid_display, nil, @@ -63,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" %> From 3c1037fd12fb43ff7fac2a2dea8eef2d72f16fb0 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:34:17 -0400 Subject: [PATCH 09/23] Add user and judge lookup routes for enhanced autocomplete functionality - Introduced a new route for user lookup to facilitate autocomplete in assignment forms. - Nested judge lookup routes under contest instances to streamline the judging pool selection process. - These additions improve user experience by enabling dynamic searching for users and judges, enhancing overall usability in the application. --- config/routes.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/config/routes.rb b/config/routes.rb index e8ba039b..2cf4547f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -120,6 +120,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') && From 988b1d45217fd39b0f1bda8426b5b944e34fec29 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:34:29 -0400 Subject: [PATCH 10/23] Add tests for judge_lookup action in JudgingAssignmentsController - Introduced a new test suite for the judge_lookup action to ensure it correctly returns available judges not already assigned to a contest instance. - Added tests to verify filtering judges by query and ensuring the JSON response includes the expected keys (id and name). - These enhancements improve test coverage and ensure the reliability of the judge selection functionality. --- .../judging_assignments_controller_spec.rb | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) 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 From b2b979bf36bfa7bc2f9ee7f8cfa58bf4dbc41bb1 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 18:34:37 -0400 Subject: [PATCH 11/23] Add tests for user lookup action in UsersController - Introduced a new test suite for the lookup action to ensure it correctly returns users matching the query by name, email, or UID. - Added tests to verify the JSON response structure and limit the number of results returned. - These enhancements improve test coverage and ensure the reliability of the user search functionality. --- spec/controllers/users_controller_spec.rb | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 spec/controllers/users_controller_spec.rb 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 From eb5385d41ad61f76531857e2100cc8576aa25eae Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:32:39 -0400 Subject: [PATCH 12/23] Add send_instructions action in JudgingRoundsController - Introduced a new action to send judging instructions to assigned judges, enhancing communication and workflow for contest organizers. - Implemented error handling for email delivery failures, logging issues and providing user feedback on the success or failure of the operation. - This addition improves the overall usability of the judging rounds management by streamlining the process of notifying judges. --- app/controllers/judging_rounds_controller.rb | 34 +++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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] From c3410ceb5c5db0dcf10fa71a889934cf0abd0e86 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:33:03 -0400 Subject: [PATCH 13/23] Add JudgingInstructionsMailer for sending judging instructions - Introduced JudgingInstructionsMailer with a send_instructions method to facilitate the emailing of judging instructions to assigned judges. - The mailer retrieves necessary details such as judge email, contest description, and special instructions, enhancing communication for contest organizers. - This addition streamlines the process of notifying judges, improving overall workflow in the judging rounds management. --- app/mailers/judging_instructions_mailer.rb | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 app/mailers/judging_instructions_mailer.rb 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 From 5c64a57ceec0aa4afca70302e9c1032313d5ffc9 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:33:14 -0400 Subject: [PATCH 14/23] Update contact email fallback in ResultsMailer - Modified the contact email retrieval logic in ResultsMailer to use a specific support email address as the fallback. - This change ensures that users have a reliable point of contact for support, enhancing communication and user experience. --- app/mailers/results_mailer.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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) From 1daef054aee76172bc32e889b3e863e3a1e48831 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:33:26 -0400 Subject: [PATCH 15/23] Add normalize_email method to User model - Introduced a new method, normalize_email, to the User model for standardizing email addresses. - This method removes any '+' subaddressing from University of Michigan email addresses, ensuring consistent email formatting. - The addition enhances data integrity and improves user experience by providing a cleaner email representation. --- app/models/user.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From 6a972371bf1e4b9b1013693150b1d3f7493e0db8 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:33:37 -0400 Subject: [PATCH 16/23] Conditionally render "Send judging instructions" button in judges management view - Updated the judges management partial to display the "Send judging instructions" button only when judges are assigned to the current round. - This change enhances the user interface by reducing confusion when no judges are available, improving overall usability in the contest management workflow. --- .../contest_instances/_manage_judges.html.erb | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/views/contest_instances/_manage_judges.html.erb b/app/views/contest_instances/_manage_judges.html.erb index 9631a581..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') %> @@ -37,10 +49,6 @@ No judges assigned to this round <% end %>
- <% if round.judges.any? %> -
Send judging instructions -
- <% end %>
From 9c831e57b33d49c49313ea9909751f6c46efd02b Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:33:44 -0400 Subject: [PATCH 17/23] Add send_instructions view for JudgingInstructionsMailer Created a new view for the JudgingInstructionsMailer to format and display judging instructions for assigned judges. The view includes details such as contest name, judging period, requirements, and special instructions, enhancing communication and clarity for judges. This addition improves the overall user experience in the judging process. --- .../send_instructions.html.erb | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 app/views/judging_instructions_mailer/send_instructions.html.erb 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 +

+
From 066b841f1cd2d55b567c4711d9bf3e404871395d Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:33:52 -0400 Subject: [PATCH 18/23] Add text view for judging instructions in JudgingInstructionsMailer
Created a new text view for the JudgingInstructionsMailer to format and present detailed judging instructions for assigned judges. This view includes essential information such as contest name, judging period, requirements, and special instructions, thereby improving communication and clarity for judges. This addition enhances the overall user experience in the judging process. --- .../send_instructions.text.erb | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 app/views/judging_instructions_mailer/send_instructions.text.erb 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 From 8564ebb6cbb395cfb622a3aa4360e15ae22f13cc Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:33:59 -0400 Subject: [PATCH 19/23] Add send_instructions route to JudgingRoundsController Introduced a new route for the send_instructions action in the JudgingRoundsController, allowing for the sending of judging instructions to assigned judges. This addition enhances the functionality of the judging rounds management by providing a direct way to notify judges, improving overall communication and workflow in the contest process. --- config/routes.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/routes.rb b/config/routes.rb index 2cf4547f..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 From d97588bf91dc7c2f9d25e81daff84ad070673766 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:34:06 -0400 Subject: [PATCH 20/23] Configure ActionMailer default URL options in test environment - Added default URL options for ActionMailer in the test environment to ensure proper URL generation for email links during tests. - This change enhances the testing setup by providing a consistent host and port, improving the reliability of email-related tests. --- config/environments/test.rb | 1 + 1 file changed, 1 insertion(+) 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 From 7c26e90bc62245cab60a5df17e3920ae20e5d459 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:34:14 -0400 Subject: [PATCH 21/23] Add tests for JudgingInstructionsMailer Created a comprehensive test suite for the JudgingInstructionsMailer, specifically for the send_instructions method. The tests cover various scenarios, including email headers, body content, and fallback mechanisms for contact emails. This addition enhances the reliability of the email functionality and ensures that judges receive accurate and complete judging instructions, improving overall communication in the contest process. --- .../judging_instructions_mailer_spec.rb | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 spec/mailers/judging_instructions_mailer_spec.rb 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 From 5726315991f367dc8486b3f4df032368c0b895e2 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:34:18 -0400 Subject: [PATCH 22/23] Update default contact email in ResultsMailer tests - Replaced the dynamic retrieval of the default contact email with a hardcoded support email address in the ResultsMailer test suite. - This change simplifies the test setup and ensures consistency in the expected email content, enhancing the reliability of the tests related to email communication. --- spec/mailers/results_mailer_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From cbe082c50a331df6bd6857e91345add46e32f22c Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Mon, 30 Jun 2025 20:35:01 -0400 Subject: [PATCH 23/23] Add preview for JudgingInstructionsMailer Created a new preview class for the JudgingInstructionsMailer to facilitate the visualization of email content for the send_instructions method. This preview includes logic to generate sample data for judges and judging rounds, ensuring that the email displays accurate and relevant information. This addition enhances the development process by allowing for easier testing and verification of email formatting and content before sending. --- .../judging_instructions_mailer_preview.rb | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 test/mailers/previews/judging_instructions_mailer_preview.rb 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