diff --git a/app/controllers/phases_controller.rb b/app/controllers/phases_controller.rb index 4ec8550f..368d30f4 100644 --- a/app/controllers/phases_controller.rb +++ b/app/controllers/phases_controller.rb @@ -10,28 +10,25 @@ def index end def submissions - @submissions = @phase.submissions + @submissions = @phase.submissions.includes(evaluator_submission_assignments: [:evaluator, :evaluation]) set_submission_counts set_submission_statuses - apply_filters - apply_sorting + @submissions = SubmissionsSortAndFilterService.new( + @submissions, + params, + { + not_started: @not_started, + in_progress: @in_progress, + completed: @completed + } + ).sort_and_filter @filtered_count = @submissions.unscope(:group).distinct.count(:id) @submissions = paginate_submissions(@submissions) - respond_to do |format| - format.html do - if params[:partial] - render partial: 'submissions_table_rows', - locals: { submissions: @submissions }, - formats: [:html] - else - render :submissions - end - end - end + render_response end private @@ -41,6 +38,10 @@ def set_phase @challenge = @phase.challenge end + def evaluator_assignments? + EvaluatorSubmissionAssignment.exists?(submission_id: @submissions.select(:id)) + end + def set_submission_counts @submissions_count = @submissions.count @eligible_count = @submissions.eligible_for_evaluation.count @@ -62,65 +63,23 @@ def set_submission_statuses } end - def apply_filters - filter_by_eligibility - filter_by_status - end - - def filter_by_eligibility - return unless params[:eligible_for_evaluation] == 'true' || - params[:selected_to_advance] == 'true' - - @submissions = apply_eligibility_filter(@submissions) - end - - def filter_by_status - return unless params[:status] - - @submissions = apply_status_filter(@submissions) - end - - def apply_status_filter(submissions) - case params[:status] - when 'not_started' then @not_started - when 'in_progress' then @in_progress - when 'completed' then @completed - when 'recused' then filter_recused_submissions - else submissions - end - end - - def filter_recused_submissions - @submissions.joins(:evaluator_submission_assignments). - where(evaluator_submission_assignments: { status: :recused }) - end - - def apply_eligibility_filter(submissions) - if params[:selected_to_advance] == 'true' - submissions.where(judging_status: %w[winner]) - elsif params[:eligible_for_evaluation] == 'true' - submissions.where(judging_status: %w[selected winner]) - else - submissions - end - end - - def apply_sorting - case params[:sort] - when 'average_score_high_to_low' - @submissions = @submissions.order_by_average_score(:desc) - when 'average_score_low_to_high' - @submissions = @submissions.order_by_average_score(:asc) - when 'submission_id_high_to_low' - @submissions = @submissions.order(id: :desc) - when 'submission_id_low_to_high' - @submissions = @submissions.order(id: :asc) - end - end - def paginate_submissions(submissions) page = (params[:page] || 1).to_i per_page = 20 submissions.offset((page - 1) * per_page).limit(per_page) end + + def render_response + respond_to do |format| + format.html do + if params[:partial] + render partial: 'submissions_table_rows', + locals: { submissions: @submissions }, + formats: [:html] + else + render :submissions + end + end + end + end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 93d5f1bc..b813d627 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -8,11 +8,12 @@ class SubmissionsController < ApplicationController def show; end def update - if @submission.update!(submission_params) - flash.now[:success] = I18n.t("submission_updated") - render :show, submission: @submission - else - render :show, status: :unprocessable_entity, submission: @submission + respond_to do |format| + if @submission.update(submission_params) + handle_successful_update(format) + else + handle_failed_update(format) + end end end @@ -26,4 +27,17 @@ def submission_params def set_submission @submission = Submission.by_user(current_user).find(params[:id]) end + + def handle_successful_update(format) + format.html do + flash[:success] = I18n.t("submission_updated") + redirect_to submission_path(@submission) + end + format.json { render json: { submission: @submission } } + end + + def handle_failed_update(format) + format.html { render :show } + format.json { render json: { errors: @submission.errors }, status: :unprocessable_entity } + end end diff --git a/app/helpers/evaluations_helper.rb b/app/helpers/evaluations_helper.rb new file mode 100644 index 00000000..48ad2989 --- /dev/null +++ b/app/helpers/evaluations_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# View helpers for calculating evaluation & submission details. +module EvaluationsHelper + Score = Struct.new(:raw_score, :formatted_score, :display_score) + + # individual evaluator score + def evaluator_score(assignment) + score = display_score(assignment) + return Score.new(0, "0", "N/A") if score == 'N/A' + + Score.new(score, score.to_s, score.to_s) + end + + def average_score(submission) + score = submission.average_score + return Score.new(0, "0", "N/A") if score.zero? + + Score.new(score, score.to_s, score.to_s) + end +end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 48446227..7e86587e 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -16,11 +16,14 @@ application.register("evaluation-form", EvaluationFormController); import HotdogController from "./hotdog_controller"; application.register("hotdog", HotdogController); +import LoadMoreController from "./load_more_controller"; +application.register("load-more", LoadMoreController); + import SubmissionDetailsController from "./submission_details_controller"; application.register("submission-details", SubmissionDetailsController); -import LoadMoreController from "./load_more_controller"; -application.register("load-more", LoadMoreController); +import SubmissionJudgingStatusController from "./submission_judging_status_controller"; +application.register("submission-judging-status", SubmissionJudgingStatusController); import ModalController from "./modal_controller"; application.register("modal", ModalController); diff --git a/app/javascript/controllers/submission_judging_status_controller.js b/app/javascript/controllers/submission_judging_status_controller.js new file mode 100644 index 00000000..566c8c55 --- /dev/null +++ b/app/javascript/controllers/submission_judging_status_controller.js @@ -0,0 +1,45 @@ +// app/javascript/controllers/submission_judging_status_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["eligibilityForm", "advancementForm", "eligibilityCheckbox", "advancementCheckbox"] + + toggleEligibility(event) { + event.preventDefault() + const formData = new FormData(this.eligibilityFormTarget) + formData.set('submission[judging_status]', + event.target.checked ? 'selected' : 'not_selected' + ) + this.submitForm(formData, this.eligibilityFormTarget) + } + + toggleAdvancement(event) { + event.preventDefault() + const formData = new FormData(this.advancementFormTarget) + formData.set('submission[judging_status]', + event.target.checked ? 'winner' : 'selected' + ) + + if (event.target.checked) { + this.eligibilityCheckboxTarget.checked = true + } + this.submitForm(formData, this.advancementFormTarget) + } + + submitForm(formData, form) { + const csrfToken = document.querySelector('[name="csrf-token"]').content + + fetch(form.action, { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Accept': 'application/json' + }, + body: formData + }) + .catch(() => { + const checkbox = form.querySelector('input[type="checkbox"]') + checkbox.checked = !checkbox.checked + }) + } +} diff --git a/app/models/evaluator_submission_assignment.rb b/app/models/evaluator_submission_assignment.rb index 48e300cc..32070c7c 100644 --- a/app/models/evaluator_submission_assignment.rb +++ b/app/models/evaluator_submission_assignment.rb @@ -43,9 +43,12 @@ def self.ordered_by_status end def evaluation_status - return status.to_sym unless assigned? + return :recused if recused? + return :unassigned if unassigned? + return :recused_unassigned if recused_unassigned? + return assigned_evaluation_status if assigned? - assigned_evaluation_status + status&.to_sym end private diff --git a/app/models/submission.rb b/app/models/submission.rb index 1dcdaf94..9259d042 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -48,6 +48,10 @@ class Submission < ApplicationRecord # Validations validates :title, presence: true + validate :can_be_selected_to_advance, + if: -> { judging_status_change == %w[selected winner] } + validate :can_be_ineligible_for_evaluation, + if: -> { judging_status_change == %w[selected not_selected] } scope :by_user, lambda { |user| case user.role @@ -63,20 +67,7 @@ class Submission < ApplicationRecord } scope :eligible_for_evaluation, -> { where(judging_status: [:selected, :winner]) } - def eligible_for_evaluation? - selected? or winner? - end - - def selected_to_advance? - winner? - end - - def average_score - avg = evaluations.average(:total_score) - avg ? avg.round : 0 - end - - def self.order_by_average_score(direction) + scope :order_by_average_score, lambda { |direction| direction_sql = direction == :desc ? 'DESC' : 'ASC' joins( @@ -90,5 +81,49 @@ def self.order_by_average_score(direction) "submissions.id #{direction_sql}" ) ) + } + + def eligible_for_evaluation? + selected? or winner? + end + + def average_score + avg = evaluations.joins(:evaluator_submission_assignment). + where(evaluator_submission_assignments: { status: :assigned }). + where.not(completed_at: nil). + average(:total_score) + + avg ? avg.round : 0 + end + + def selected_to_advance? + winner? + end + + def evaluators_assigned? + evaluator_submission_assignments.exists?(status: [:assigned, :recused]) + end + + def evaluations_missing_or_incomplete? + !eligible_for_evaluation? || !all_evaluations_completed? || evaluator_submission_assignments.empty? + end + + private + + def all_evaluations_completed? + evaluator_submission_assignments. + all? { |assignment| assignment.evaluation_status == :completed } + end + + def can_be_selected_to_advance + return unless evaluations_missing_or_incomplete? + + errors.add(:judging_status, "can't be selected to advance until all evaluations are complete") + end + + def can_be_ineligible_for_evaluation + return unless evaluators_assigned? + + errors.add(:judging_status, "must remain eligible for evaluation when evaluators are assigned") end end diff --git a/app/services/submissions_sort_and_filter_service.rb b/app/services/submissions_sort_and_filter_service.rb new file mode 100644 index 00000000..2b970204 --- /dev/null +++ b/app/services/submissions_sort_and_filter_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# This service handles sort and filtering submissions. +class SubmissionsSortAndFilterService + def initialize(submissions, params, submission_statuses = {}) + @submissions = submissions + @params = params + @not_started = submission_statuses[:not_started] + @in_progress = submission_statuses[:in_progress] + @completed = submission_statuses[:completed] + end + + def sort_and_filter + apply_filters + apply_sorting + @submissions + end + + private + + def apply_filters + filter_by_eligibility + filter_by_status + end + + def filter_by_eligibility + return unless @params[:eligible_for_evaluation] == 'true' || + @params[:selected_to_advance] == 'true' + + @submissions = apply_eligibility_filter(@submissions) + end + + def filter_by_status + return unless @params[:status] + + @submissions = apply_status_filter(@submissions) + end + + def apply_status_filter(submissions) + case @params[:status] + when 'not_started' then @not_started + when 'in_progress' then @in_progress + when 'completed' then @completed + when 'recused' then filter_recused_submissions + else submissions + end + end + + def filter_recused_submissions + @submissions.joins(:evaluator_submission_assignments). + where(evaluator_submission_assignments: { status: :recused }) + end + + def apply_eligibility_filter(submissions) + if @params[:selected_to_advance] == 'true' + submissions.where(judging_status: %w[winner]) + elsif @params[:eligible_for_evaluation] == 'true' + submissions.where(judging_status: %w[selected winner]) + else + submissions + end + end + + def apply_sorting + case @params[:sort] + when 'average_score_high_to_low' + @submissions = @submissions.order_by_average_score(:desc) + when 'average_score_low_to_high' + @submissions = @submissions.order_by_average_score(:asc) + when 'submission_id_high_to_low' + @submissions = @submissions.order(id: :desc) + when 'submission_id_low_to_high' + @submissions = @submissions.order(id: :asc) + end + end +end diff --git a/app/views/phases/_submissions_table.html.erb b/app/views/phases/_submissions_table.html.erb index 67303e2e..3e6d6ce9 100644 --- a/app/views/phases/_submissions_table.html.erb +++ b/app/views/phases/_submissions_table.html.erb @@ -14,12 +14,12 @@
Submission ID | -Eligible for Evaluation | -Selected to Advance | -Assigned Evaluators | -Average Score | -View Submission | +Submission ID | +Eligible for Evaluation | +Selected to Advance | +Assigned Evaluators | +Average Score | +View Submission |
---|---|---|---|---|---|---|---|---|---|---|---|
- Submission ID <%= submission.id %> - | -
-
- <% if submission.eligible_for_evaluation? %>
-
-
+ <% else %>
+ No evaluators assigned to this submission
+ <% end %>
+
- <%= image_tag(
- "images/usa-icons/verified.svg",
- class: "usa-icon--size-3 margin-right-1",
- alt: ""
- )%>
- Eligible for Evaluation
-
- <% else %>
-
-
- <%= image_tag(
- "images/usa-icons/highlight_off.svg",
- class: "usa-icon--size-3 margin-right-1",
- alt: ""
- )%>
- Not Eligible for Evaluation
-
+
+ Submission ID <%= submission.id %>
+ |
+
+ |
+
+
+
+
+ <% if submission.eligible_for_evaluation? %>
+
+ <%= form_with(
+ model: submission,
+ url: submission_path(submission),
+ method: :patch,
+ id: "eligibility_form_#{submission.id}",
+ data: {
+ submission_judging_status_target: "eligibilityForm"
+ }) do |f| %>
+
+ <% end %>
+
+
+ <%= image_tag(
+ "images/usa-icons/verified.svg",
+ class: "usa-icon--size-3 margin-right-1",
+ alt: ""
+ )%>
+ Eligible for Evaluation
+
+ <% else %>
+
+ <%= image_tag(
+ "images/usa-icons/highlight_off.svg",
+ class: "usa-icon--size-3 margin-right-1",
+ alt: ""
+ )%>
+ Not Eligible for Evaluation
+
+ <% end %>
+
+ |
-
+
+ <%= form_with(
+ model: submission,
+ url: submission_path(submission),
+ method: :patch,
+ id: "advancement_form_#{submission.id}",
+ data: {
+ submission_judging_status_target: "advancementForm"
+ }) do |f| %>
+
<% end %>
-
-
- |
+
+
- <% if submission.selected_to_advance? %>
-
-
+
+ <% if submission.selected_to_advance? %>
+
- <%= image_tag(
- "images/usa-icons/star.svg",
- class: "usa-icon--size-3 margin-right-1",
- alt: ""
- )%>
- Selected to Advance
+
+
+ <%= image_tag(
+ "images/usa-icons/star.svg",
+ class: "usa-icon--size-3 margin-right-1",
+ alt: ""
+ )%>
+ Selected to Advance
+
+ <% else %>
+
+ <%= image_tag(
+ "images/usa-icons/star_outline.svg",
+ class: "usa-icon--size-3 margin-right-1",
+ alt: ""
+ )%>
+ Not Selected to Advance
+
+ <% end %>
+
+ <% active_assignments = submission.evaluator_submission_assignments.select { |a| ['assigned', 'recused'].include?(a.status) } %>
+ <% if active_assignments.any? %>
+ |
-
+ <% active_assignments.each do |assignment| %>
+
-
+
<% end %>
-
+
+ <%= "#{assignment.evaluator.first_name} #{assignment.evaluator.last_name}" %>
+
- <% else %>
-
-
- <%= image_tag(
- "images/usa-icons/star_outline.svg",
- class: "usa-icon--size-3 margin-right-1",
- alt: ""
- )%>
- Not Selected to Advance
-
+
+ <%= evaluator_score(assignment).formatted_score %>
+
+ <%= assignment.evaluation_status.to_s.titleize %>
+
+
+
- No evaluators assigned to this submission
- |
-
- <%= score %>
- |
-
- |
-
- <%= link_to submission_path(submission) do %>
-
- <% end %>
-
- |
+
+ + <%= average_score(submission).formatted_score %> + | + +
+
+ <%= link_to submission_path(submission) do %>
+
+ <% end %>
+
+ |
+