Skip to content

Commit

Permalink
Merge pull request #243 from GSA/200_manage_evaluators_list
Browse files Browse the repository at this point in the history
[200] Build Manage Evaluators List
  • Loading branch information
emmabjj authored Nov 20, 2024
2 parents 02a68e1 + e41dd2b commit 65f6437
Show file tree
Hide file tree
Showing 22 changed files with 1,056 additions and 45 deletions.
3 changes: 0 additions & 3 deletions .envrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,5 @@ use nix
mkdir -p .nix-bundler
export BUNDLE_PATH=./.nix-bundler

# hook for rbenv locally
which rbenv &> /dev/null && eval "$(rbenv init - -zsh)"

# Login Env Vars
source .env_login
8 changes: 8 additions & 0 deletions app/assets/stylesheets/application.sass.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ dialog::backdrop {
border-color: black;
}

.usa-table.usa-table--borderless.gray-header {
thead {
th {
background-color: #dfe1e2;
}
}
}

#add-criteria-button.usa-button {
background-color: #4d8055;
}
69 changes: 69 additions & 0 deletions app/controllers/evaluators_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

class EvaluatorsController < ApplicationController
before_action -> { authorize_user('challenge_manager') }

# All routes are scoped to a Challenge Phase
before_action :set_challenge_phase

def index
@evaluator_invitations = @phase.evaluator_invitations
@existing_evaluators = @phase.evaluators
end

def create
result = evaluator_service.process_evaluator_invitation(
evaluator_invitation_params[:email],
evaluator_invitation_params
)

if result[:success]
redirect_to phase_evaluators_path(@phase), notice: result[:message]
else
flash.now[:alert] = result[:message]
@evaluator_invitations = @phase.evaluator_invitations
@existing_evaluators = @phase.evaluators
render :index
end
end

def destroy
result = evaluator_service.remove_evaluator(params[:evaluator_type], params[:id])

if result[:success]
flash[:notice] = result[:message]
render json: { success: true, message: result[:message] }
else
render json: { success: false, message: result[:message] }, status: :unprocessable_entity
end
end

def resend_invite
@evaluator_invitation = @phase.evaluator_invitations.find(params[:id])
result = evaluator_service.resend_invitation(@evaluator_invitation)
if result[:success]
redirect_to phase_evaluators_path(@phase),
notice: t('.success')
else
redirect_to phase_evaluators_path(@phase),
alert: t('.failure')
end
end

private

def set_challenge_phase
@phase = Phase.where(challenge: current_user.challenge_manager_challenges).find(params[:phase_id])
@challenge = @phase.challenge
end

def evaluator_service
@evaluator_service ||= EvaluatorManagementService.new(@challenge, @phase)
end

def evaluator_invitation_params
params.require(:evaluator_invitation).permit(
:first_name, :last_name, :email, :challenge_id, :phase_id, :last_invite_sent
)
end
end
22 changes: 22 additions & 0 deletions app/helpers/evaluators_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module EvaluatorsHelper
def user_status(evaluator)
if evaluator.is_a?(User)
evaluator.status == 'active' ? "Available" : "Awaiting Approval"
else
"Invite Sent"
end
end

def assigned_submissions_count(evaluator, challenge, phase)
if evaluator.is_a?(User)
evaluator.evaluator_submission_assignments.
joins(:submission).
where(submissions: { challenge:, phase: }).
count
else
0
end
end
end
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
# frozen_string_literal: true

module ManageSubmissionsHelper
module SubmissionsHelper
def eligible_for_evaluation?(submission)
submission.judging_status.in?(%w[selected winner])
end

def selected_to_advance?(submission)
submission.judging_status.in?(%w[winner])
end

def phase_has_recused_evaluator?(phase)
phase.evaluator_submission_assignments.recused.exists?
end
end
64 changes: 64 additions & 0 deletions app/javascript/controllers/delete_evaluator_modal_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["modal"]
static values = {
challengeId: String,
evaluatorId: String,
evaluatorType: String,
phaseId: String
}

connect() {
this.modalTarget.addEventListener('click', this.handleOutsideClick.bind(this))
}

disconnect() {
this.modalTarget.removeEventListener('click', this.handleOutsideClick.bind(this))
}

open(event) {
event.preventDefault()
this.evaluatorIdValue = event.currentTarget.dataset.evaluatorId
this.evaluatorTypeValue = event.currentTarget.dataset.evaluatorType
this.phaseIdValue = event.currentTarget.dataset.phaseId
this.modalTarget.showModal()
}

close() {
this.modalTarget.close()
}

handleOutsideClick(event) {
if (event.target === this.modalTarget) {
this.close()
}
}

confirm() {
this.deleteEvaluator()
}

deleteEvaluator(forceDelete = false) {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content

fetch(`/phases/${this.phaseIdValue}/evaluators/${this.evaluatorIdValue}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ evaluator_type: this.evaluatorTypeValue, phase_id: this.phaseIdValue, force_delete: forceDelete })
})
.then(response => {
if (!response.ok) { throw new Error('Network response was not ok') }
return response.json()
})
.then(data => {
if (data.success) {
this.close()
window.location.reload()
} else {
throw new Error(data.message || 'Failed to remove evaluator')
}
})
.catch(error => { alert(error.message || 'An error occurred while removing the evaluator') })
}
}
3 changes: 3 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ import EvaluationFormController from "./evaluation_form_controller";
import EvaluationCriteriaController from "./evaluation_criteria_controller";
application.register("evaluation-form", EvaluationFormController);
application.register("evaluation-criteria", EvaluationCriteriaController);

import DeleteEvaluatorModalController from "./delete_evaluator_modal_controller"
application.register("delete-evaluator-modal", DeleteEvaluatorModalController)
10 changes: 10 additions & 0 deletions app/models/challenge_phases_evaluator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,14 @@ class ChallengePhasesEvaluator < ApplicationRecord
belongs_to :challenge
belongs_to :phase
belongs_to :user

validate :user_has_valid_role

private

def user_has_valid_role
return if User::VALID_EVALUATOR_ROLES.include?(user.role)

errors.add(:user, "must have a valid evaluator role")
end
end
14 changes: 13 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
# recertification_expired_at :datetime
#
class User < ApplicationRecord
after_create :process_evaluator_invitations

VALID_EVALUATOR_ROLES = %w[evaluator solver challenge_manager].freeze

belongs_to :agency, optional: true

has_many :challenges, dependent: :destroy
Expand Down Expand Up @@ -130,7 +134,9 @@ def self.create_user_from_userinfo(userinfo)
end

def self.default_role_and_status_for_email(email)
if default_challenge_manager?(email)
if EvaluatorInvitation.exists?(email:)
%w[evaluator pending]
elsif default_challenge_manager?(email)
%w[challenge_manager pending]
else
%w[solver active]
Expand All @@ -140,4 +146,10 @@ def self.default_role_and_status_for_email(email)
def self.default_challenge_manager?(email)
/\.(gov|mil)$/.match?(email)
end

private

def process_evaluator_invitations
EvaluatorManagementService.accept_evaluator_invitation(self)
end
end
134 changes: 134 additions & 0 deletions app/services/evaluator_management_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# frozen_string_literal: true

class EvaluatorManagementService
def initialize(challenge, phase)
@challenge = challenge
@phase = phase
end

def process_evaluator_invitation(email, invitation_params)
user = User.find_by(email:)
user ? add_existing_user_as_evaluator(user) : handle_invitation(email, invitation_params)
end

def remove_evaluator(evaluator_type, evaluator_id)
case evaluator_type
when 'user'
remove_user_evaluator(evaluator_id)
when 'invitation'
remove_evaluator_invitation(evaluator_id)
else
{ success: false, message: 'Invalid evaluator type' }
end
end

def self.accept_evaluator_invitation(user)
invitations = EvaluatorInvitation.where(email: user.email)
invitations.each do |invite|
ChallengePhasesEvaluator.create(challenge: invite.challenge, phase: invite.phase, user:)
invite.destroy
end
{ success: true, message: I18n.t('evaluators.accept_evaluator_invitation.success') }
end

# TODO: Implement sending the actual invitation email here
def resend_invitation(invitation)
if invitation.update(last_invite_sent: Time.current)
{ success: true,
message: I18n.t('evaluators.process_evaluator_invitation.invitation_resent', email: invitation.email) }
else
{ success: false, message: I18n.t('evaluators.resend_invite.failure') }
end
end

private

def add_existing_user_as_evaluator(user)
if @phase.evaluators.include?(user)
return {
success: true,
message: I18n.t('evaluators.process_evaluator_invitation.already_added',
email: user.email)
}
end

unless User::VALID_EVALUATOR_ROLES.include?(user.role)
return {
success: false,
message: I18n.t('evaluators.process_evaluator_invitation.invalid_role',
email: user.email)
}
end

cpe = ChallengePhasesEvaluator.find_or_create_by(challenge: @challenge, phase: @phase, user:)

if cpe.persisted?
{
success: true,
message: I18n.t('evaluators.process_evaluator_invitation.add_success',
email: user.email)
}
else
{
success: false,
message: I18n.t('evaluators.process_evaluator_invitation.add_failure',
email: user.email)
}
end
end

def handle_invitation(email, invitation_params)
existing_invitation = @challenge.evaluator_invitations.find_by(email:, phase: @phase)
existing_invitation ? resend_invitation(existing_invitation) : create_new_invitation(invitation_params)
end

def create_new_invitation(invitation_params)
invitation = @challenge.evaluator_invitations.new(
invitation_params.merge(
phase: @phase,
last_invite_sent: Time.current
)
)
if invitation.save
{
success: true,
message: I18n.t(
'evaluators.process_evaluator_invitation.invitation_sent',
email: invitation_params[:email]
)
}
else
{
success: false,
message: invitation.errors.full_messages.join(", ")
}
end
end

def remove_user_evaluator(evaluator_id)
evaluator = User.find(evaluator_id)
cpe = ChallengePhasesEvaluator.find_by(challenge: @challenge, phase: @phase, user: evaluator)
if cpe.destroy
{ success: true, message: I18n.t('evaluators.remove_user_evaluator.success') }
else
{ success: false, message: I18n.t('evaluators.remove_user_evaluator.failure') }
end
rescue ActiveRecord::RecordNotFound
{ success: false, message: I18n.t('evaluators.remove_user_evaluator.evaluator_not_found') }
rescue StandardError => e
{ success: false, message: "Error: #{e.message}" }
end

def remove_evaluator_invitation(invitation_id)
invitation = @challenge.evaluator_invitations.find_by!(id: invitation_id, phase: @phase)
if invitation.destroy
{ success: true, message: I18n.t('evaluators.remove_evaluator_invitation.success') }
else
{ success: false, message: I18n.t('evaluators.remove_evaluator_invitation.failure') }
end
rescue ActiveRecord::RecordNotFound
{ success: false, message: I18n.t('evaluators.remove_evaluator_invitation.invitation_not_found') }
rescue StandardError => e
{ success: false, message: "Error: #{e.message}" }
end
end
Loading

0 comments on commit 65f6437

Please sign in to comment.