Introduced candidate voting and various tweaks

Merge branch 'develop'
This commit is contained in:
Mark Moser 2016-11-22 17:35:02 -06:00
commit 439c0bf553
58 changed files with 1064 additions and 31 deletions

View File

@ -75,7 +75,7 @@ guard :shell, all_on_start: true do
# TODO: Annoyingly, all files are linted twice on start/full runs. Why?
watch %r{app/assets/javascripts/*/.*} do |file|
system %(echo "ESLint:\033[32m #{file[0]}\033[0m")
system %(eslint #{file[0]})
system %(./node_modules/eslint/bin/eslint.js #{file[0]})
end
end

View File

@ -77,10 +77,14 @@ There are some convenience scripts included to make starting the container and r
* `./start-server.sh`
- starts up just rails server for viewing application
## Deploying a new version
* ssh into server
* cd into app root
* run deploy.sh
## TODOs and notes
* Question attachment path: http://dev.perficientxd.com/skill_assets/
* Question attachment path: https://dev.perficientdigital.com/skills-app-images/
* clean code
* [Confident Ruby](http://www.confidentruby.com/)
* [POODR](http://www.poodr.com/)

View File

@ -1,7 +1,8 @@
function handleAjaxResponse($el) {
function handleAjaxResponse($el, callback) {
var $header = $('header');
$el.on("ajax:success", function(e, data){
$header.after('<div class="success">' + data.message + '</div>');
callback(data);
}).on("ajax:error", function(e, xhr) {
if (xhr.status === 400){
$header.after('<div class="error">' + xhr.responseJSON.join('<br>') + '</div>');
@ -11,6 +12,25 @@ function handleAjaxResponse($el) {
});
}
function updateVotes(data){
$("[data-id=up-votes]").html(data.upCount);
$("[data-id=down-votes]").html(data.downCount);
$("[data-id=my-vote]").html(data.myVote);
}
function updateVeto(data){
$("[data-id=interview-request]").html(data.requestCopy);
$("[data-id=interview-decline]").html(data.declineCopy);
}
$(document).ready(function() {
$('[data-id=ajax-action]').each(function(){ handleAjaxResponse($(this)); });
});
$(document).ready(function() {
$('[data-id=vote-count]').each(function(){ handleAjaxResponse($(this), updateVotes); });
});
$(document).ready(function() {
$('[data-id=veto-status]').each(function(){ handleAjaxResponse($(this), updateVeto); });
});

View File

@ -0,0 +1,57 @@
.accordion {
margin-bottom: 0.75em;
[type="checkbox"]:checked + label,
[type="checkbox"]:checked ~ label:after,
[type="checkbox"]:not(:checked) + label,
[type="checkbox"]:not(:checked) ~ label:after,
[type="radio"]:checked + label,
[type="radio"]:checked ~ label:after,
[type="radio"]:not(:checked) + label,
[type="radio"]:not(:checked) ~ label:after {
content: "";
border: 0;
background-color: transparent;
}
[type="checkbox"]:hover:not(:disabled) + label:before,
[type="radio"]:hover:not(:disabled) + label:before,
[type="radio"]:not(:checked) ~ label:before,
[type="checkbox"]:not(:checked) ~ label:before,
[type="radio"]:checked ~ label:before,
[type="checkbox"]:checked ~ label:before {
background-color: transparent;
border: 0;
content: "+";
font-size: 1.3em;
line-height: 1;
}
[type="checkbox"]:hover:checked + label:before,
[type="radio"]:hover:checked + label:before,
[type="radio"]:checked ~ label:before,
[type="checkbox"]:checked ~ label:before {
background-color: transparent;
content: "-";
}
[type="radio"]:not(:checked) + label,
[type="checkbox"]:not(:checked) + label,
[type="radio"]:checked + label,
[type="checkbox"]:checked + label {
padding-left: 20px;
}
.accordion__copy {
display: none;
margin-top: 0.75em;
}
.accordion__toggle:checked ~ .accordion__copy {
background-color: $gray-lighter;
display: block;
padding: 2.5em 1.25em 1em 1.75em;
margin: -2.25em 0 2em -0.5em;
}
}

View File

@ -0,0 +1,31 @@
.admin-review {
counter-reset: question;
form {
margin-left: 2.3em;
position: relative;
&::before {
content: counter(question) ") ";
counter-increment: question;
font-size: 1.25em;
left: -1.8em;
position: absolute;
top: 0.4em;
}
}
}
.review_meta {
@media screen and (min-width: 768px) {
display: flex;
& > div { flex: 1 1 auto; }
}
.review_meta__votes,
.review_meta__vetos {
a { padding: 5px; }
}
}

View File

@ -22,6 +22,10 @@
display: block;
}
.code-results {
margin-bottom: -0.65em;
}
.results {
border: 1px solid $secondary-color;
clear: both;
@ -31,8 +35,15 @@
background-color: #fff;
}
fieldset:disabled .results {
border-color: #bbb;
fieldset:disabled {
.results {
border-color: #bbb;
}
.code-results,
.code-input label {
display: block;
}
}
iframe {

View File

@ -62,7 +62,7 @@ module Admin
def question_params
params.require(:question).permit(
:quiz_id, :question, :category, :input_type, :sort, :active, :input_options,
:quiz_id, :question, :category, :attachment, :input_type, :sort, :active, :input_options,
multi_choice: [], live_code: [:later, :html, :css, :js, :text]
)
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
module Admin
class VoteController < AdminController
def up
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.cast_yea_on(@candidate)
results = {
message: "Vote Counted",
upCount: @candidate.votes.yea.count,
downCount: @candidate.votes.nay.count,
myVote: "yea"
}
render json: results.to_json
end
def down
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.cast_nay_on(@candidate)
results = {
message: "Vote Counted",
upCount: @candidate.votes.yea.count,
downCount: @candidate.votes.nay.count,
myVote: "nay"
}
render json: results.to_json
end
def approve
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.approve_candidate(@candidate)
results = {
message: "Interview requested!",
requestCopy: "Requested",
declineCopy: "Decline Interview"
}
render json: results.to_json
end
def decline
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.decline_candidate(@candidate)
results = {
message: "Interview declined.",
requestCopy: "Request Interview",
declineCopy: "Declined"
}
render json: results.to_json
end
end
end

View File

@ -36,6 +36,7 @@ class QuizController < ApplicationController
def complete_and_email
if current_candidate.update_attributes(completed: true)
current_candidate.build_reviews
CandidateMailer.submitted(current_candidate).deliver_later
RecruiterMailer.candidate_submitted(current_candidate).deliver_later
ReviewerMailer.candidate_submission(current_candidate).deliver_later

View File

@ -4,6 +4,6 @@ class ReviewerMailer < ApplicationMailer
@candidate = candidate
recipients = candidate.quiz.reviewers.map(&:email)
mail to: recipients, subject: "Skills Assessment Results"
mail to: recipients, subject: "Skills Assessment Results - #{@candidate.test_hash}"
end
end

View File

@ -4,6 +4,8 @@ class Candidate < ApplicationRecord
has_many :questions, -> { order("sort") }, through: :quiz
has_many :answers
belongs_to :recruiter, class_name: "User"
has_many :votes, class_name: "ReviewerVote"
has_many :reviewers, through: :quiz
serialize :email, CryptSerializer
@ -15,6 +17,18 @@ class Candidate < ApplicationRecord
validates :email, uniqueness: true, presence: true, email_format: true
validates :test_hash, uniqueness: true, presence: true
enum review_status: {
pending: 0,
approved: 1,
declined: 2
}
def build_reviews
reviewers.each do |reviewer|
votes.find_or_create_by(user_id: reviewer.id)
end
end
def submitted_answers
answers.where(submitted: true)
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class ReviewerVote < ApplicationRecord
belongs_to :candidate
belongs_to :user
validates :user_id, uniqueness: { scope: :candidate_id }
enum vote: {
undecided: 0,
yea: 1,
nay: 2
}
enum veto: {
approved: 1,
rejected: 2
}
end

View File

@ -4,6 +4,9 @@ class User < ApplicationRecord
has_many :candidates, foreign_key: :recruiter_id
has_many :reviewer_to_quizzes
has_many :quizzes, through: :reviewer_to_quizzes
has_many :votes, class_name: 'ReviewerVote'
has_many :reviewees, through: :quizzes, source: :candidates
validates :email, presence: true, uniqueness: true
validates :name, presence: true
@ -15,6 +18,42 @@ class User < ApplicationRecord
save
end
# Voting
def cast_yea_on candidate
vote = votes.find_by(candidate_id: candidate.to_i)
vote.vote = :yea
vote.save
end
def cast_nay_on candidate
vote = votes.find_by(candidate_id: candidate.to_i)
vote.vote = :nay
vote.save
end
def approve_candidate candidate
candidate = Candidate.find(candidate.to_i)
vote = votes.find_by(candidate_id: candidate.to_i)
vote.veto = :approved
candidate.update_attribute(:review_status, :approved) if vote.save
end
def decline_candidate candidate
candidate = Candidate.find(candidate.to_i)
vote = votes.find_by(candidate_id: candidate.to_i)
vote.veto = :rejected
candidate.update_attribute(:review_status, :declined) if vote.save
end
def my_vote candidate
candidate = Candidate.find(candidate.to_i)
my_vote = votes.find_by(candidate_id: candidate.id)
my_vote.vote unless my_vote.nil?
end
# Roles
def admin?
'admin' == role
@ -45,7 +84,7 @@ class User < ApplicationRecord
end
def acts_as_reviewer?
%w(admin reviewer).include? role
%w(admin manager reviewer).include? role
end
private

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
class ReviewerVotePolicy < ApplicationPolicy
# Voting Policy
#
# Only Reviewers, Managers, and Admins, can cast a vote on a quiz result
#
# Reviewers can vote any quiz they are linked to
# Only Managers, and Admins, can veto a quiz result
def up?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_reviewer?
end
def down?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_reviewer?
end
def approve?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_manager?
end
def decline?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_manager?
end
class Scope < Scope
def resolve
return ReviewerVote.none if user.recruiter?
if user.reviewer?
scope.where(user_id: user.id)
else
scope
end
end
end
end

View File

@ -1,11 +1,11 @@
<%
content_for :main_class, "intro_tpl"
content_for :title, "Skills Assessment Admin"
%>
<h1>Admin Login</h1>
<%= form_for :auth, url: admin_login_path do |form| %>
<% if flash[:error].present? %>
<div class="form-group">
Need a <%= link_to "password reset", admin_reset_request_path %>?

View File

@ -1,5 +1,6 @@
<%
content_for :main_class, "intro_tpl"
content_for :title, "Skills Assessment Admin"
%>
<h1>Password Reset</h1>

View File

@ -1,5 +1,6 @@
<%
content_for :main_class, "intro_tpl"
content_for :title, "Skills Assessment Admin"
%>
<h1>Password Reset</h1>

View File

@ -1,3 +1,7 @@
<%
content_for :title, "Edit Candidate - Skills Assessment Admin"
%>
<main class="intro_tpl">
<h1>Edit: <%= @candidate.name %></h1>
<p><strong>Test ID: </strong><%= @candidate.test_hash %></p>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Candidates"
content_for :title, "Candidates - Skills Assessment Admin"
%>
<main class="summary_tpl">
<%= link_to(admin_new_candidate_path, { class: 'secondary-btn' }) do %>
@ -15,6 +16,7 @@
<th>Progress</th>
<th>Completed</th>
<th>Reminded</th>
<th>Interview Request</th>
</tr>
<% @candidates.each do |candidate| %>
@ -28,8 +30,9 @@
</td>
<td><%= candidate.experience %> years</td>
<td><%= candidate.status %></td>
<td><%= candidate.completed ? "Submitted" : "" %></td>
<td><%= candidate.completed ? link_to("Submitted", admin_result_path(candidate.test_hash)) : "" %></td>
<td><%= candidate.reminded ? "Yes" : "" %></td>
<td><%= candidate.review_status unless candidate.pending? %></td>
</tr>
<% end %>
</table>

View File

@ -1,3 +1,7 @@
<%
content_for :title, "New Candidate - Skills Assessment Admin"
%>
<main class="intro_tpl">
<h1>New Candidate</h1>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Edit: #{@user.name}"
content_for :title, "Profile - Skills Assessment Admin"
%>
<%= render partial: 'shared/form_model_errors', locals: {obj: @user} %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Profile"
content_for :title, "Profile - Skills Assessment Admin"
%>
<p>Name: <%= current_user.name %></p>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Questions"
content_for :title, "Edit Question - Skills Assessment Admin"
%>
<h1><%= @question.quiz.name %></h1>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Questions"
content_for :title, "Questions - Skills Assessment Admin"
%>
<% quizzes = @questions.group_by{ |q| q.quiz.name } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "New Question"
content_for :title, "New Question - Skills Assessment Admin"
%>
<%= render partial: 'form', locals: {question: @question, action: admin_create_question_path } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Question for #{@question.quiz.name}"
content_for :title, "Question - Skills Assessment Admin"
%>
<table cellspacing="0" cellpadding="0">

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Quizzes"
content_for :title, "Quizzes - Skills Assessment Admin"
%>
<%= render partial: 'admin/quiz/table_list', locals: { quizzes: @quizzes } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "New Quiz"
content_for :title, "New Quiz - Skills Assessment Admin"
%>
<%= render partial: 'form', locals: { quiz: @quiz, action: admin_create_quiz_path } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "#{@quiz.name}"
content_for :title, "Quiz - Skills Assessment Admin"
%>
<p><%= @quiz.name %></p>

View File

@ -0,0 +1,34 @@
<% # TODO: This needs to be extracted into a decorator, or something. It is only a quick hack solution. %>
<% if current_user.acts_as_reviewer? %>
<div class="review_meta__votes" data-id="vote-count">
<strong>Votes: </strong>
<%= link_to admin_up_vote_path(test_hash: @candidate.test_hash), remote: true do %>
Yea (<span data-id="up-votes"><%= @candidate.votes.yea.count %></span>)
<% end %>
<%= link_to admin_down_vote_path(test_hash: @candidate.test_hash), remote: true do %>
Nay (<span data-id="down-votes"><%= @candidate.votes.nay.count %></span>)
<% end %>
<small>(Your vote: <span data-id="my-vote"><%= current_user.my_vote(@candidate) %></span>)</small>
</div>
<% end %>
<% if current_user.acts_as_manager? %>
<div class="review_meta__vetos" data-id="veto-status">
<strong>Manager Vetos: </strong>
<%= link_to admin_approve_vote_path(test_hash: @candidate.test_hash), remote: true do %>
<span data-id="interview-request">
<%= @candidate.approved? ? "Requested" : "Request Interview" %>
</span>
<% end %>
<%= link_to admin_decline_vote_path(test_hash: @candidate.test_hash), remote: true do %>
<span data-id="interview-decline">
<%= @candidate.declined? ? "Declined" : "Decline Interview" %>
</span>
<% end %>
</div>
<% else %>
<strong>Candidate Interview Status: </strong><%= @candidate.review_status %>
<% end %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Completed Tests"
content_for :title, "Quiz Results - Skills Assessment Admin"
%>
<main class="summary_tpl">
<table cellspacing="0" cellpadding="0">
@ -7,6 +8,7 @@
<th>Test ID</th>
<th>Experience</th>
<th>Recruiter</th>
<th>Interview Request</th>
</tr>
<% @candidates.each do |candidate| %>
@ -14,6 +16,7 @@
<td><%= link_to candidate.test_hash, admin_result_path(candidate.test_hash) %></td>
<td><%= candidate.experience %> years</td>
<td><%= mail_to(candidate.recruiter.email) %></td>
<td><%= candidate.review_status unless candidate.pending? %></td>
</tr>
<% end %>
</table>

View File

@ -1,10 +1,19 @@
<main class="summary_tpl">
<%
content_for :title, "Quiz Review - Skills Assessment Admin"
%>
<main class="summary_tpl admin-review">
<h2 class="prft-heading">Quiz Review</h2>
<p>
<strong>Test ID:</strong> <%= @candidate.test_hash %><br />
<strong>Years of Experience:</strong> <%= @candidate.experience %><br />
<strong>Recruiter Email:</strong> <%= mail_to @candidate.recruiter.name, @candidate.recruiter.email %><br />
</p>
<div class="review_meta">
<div>
<strong>Test ID:</strong> <%= @candidate.test_hash %><br />
<strong>Years of Experience:</strong> <%= @candidate.experience %><br />
<strong>Recruiter Email:</strong> <%= mail_to @candidate.recruiter.name, @candidate.recruiter.email %><br />
</div>
<div><%= render partial: 'voting' %></div>
</div>
<% @quiz.each do |question| %>
<%= form_for(:answer, url: '#never-post', html:{id: 'summary-form'}) do |form| %>

View File

@ -15,12 +15,20 @@
<%= form.select :role, admin_role_options(user.role), include_blank: false %>
</div>
<%= form.collection_check_boxes(:quiz_ids, Quiz.all, :id, :name, {}, {class: 'checkbox'}) do | quiz | %>
<div class="form-group-multiples">
<%= quiz.check_box( checked: user.quizzes.include?(quiz.object)) %>
<%= quiz.label %>
</div>
<% end %>
<div class="form-group">
<div><strong>Quiz Review List</strong></div>
<p>
Quizzes this user should be reviewing the results of.<br />
Admins and Recruiters should not have any checked, unless they are expected
to participate in the technical review for that quiz.
</p>
<%= form.collection_check_boxes(:quiz_ids, Quiz.all, :id, :name, {}, {class: 'checkbox'}) do | quiz | %>
<div class="form-group-multiples">
<%= quiz.check_box( checked: user.quizzes.include?(quiz.object)) %>
<%= quiz.label %>
</div>
<% end %>
</div>
<%= form.submit %>
<% end %>

View File

@ -1,3 +1,5 @@
<% content_for :title, "Skills Assessment" %>
<main class="intro_tpl">
<h1 class="prft-heading">Oops!</h1>
<p>

View File

@ -1,3 +1,4 @@
<% content_for :title, "Saved! - Skills Assessment" %>
<main class="styleguide_tpl">
<p>
Your test results have been saved. You can visit again later with your Test ID to complete

View File

@ -1,3 +1,4 @@
<% content_for :title, "Thank You - Skills Assessment" %>
<main class="styleguide_tpl">
<h1>Thank you!</h1>
<p>

View File

@ -45,6 +45,17 @@
</noscript>
</div>
<div class="accordion" id="accordion<%= question.question_id %>" style="display: none;">
<input type="checkbox" class="accordion__toggle" id="accordion-toggle-live-coder" />
<label class="accordion__label" for="accordion-toggle-live-coder">How to use the live coder</label>
<p class="accordion__copy">
This is our own nifty creation, and it works similarly to CodePen. To use: type any HTML, CSS,
or JS inside their corresponding boxes, and watch the Results window below the boxes update
with your changes. Once youre happy with your code and how it renders in the Results window,
move on to the next question!
</p>
</div>
<div id="answer<%= question.question_id %>" data-id="live-coder-answer" style="display: none;">
<label for="answer_answer_hash_text">Enter answer here</label>
<%= text_area_tag 'answer[answer_hash][text]', value_text, { disabled: true, data: {last: answers['text']}} %>
@ -64,6 +75,7 @@
<%= text_area_tag 'answer[answer_hash][js]', value_js, { disabled: true, data: {id: 'code-js', last: answers['js']}, class: 'code-answer code-js' } %>
</div>
<label class="code-results">Results</label>
<div class="results" data-id="results"></div>
</div>
@ -71,6 +83,7 @@
<% # removes the no-js message %>
document.getElementById("nojs<%= question.question_id %>").style.display = "none";
document.getElementById("answer<%= question.question_id %>").style.display = "";
document.getElementById("accordion<%= question.question_id %>").style.display = "";
<% # we want the coders disabled until JS is confirmed, so form post is easier to validate %>
var coders = document.querySelectorAll("[data-id=live-coder-answer] textarea");

View File

@ -1,5 +1,5 @@
<%
content_for :title, "Skills Assessment"
content_for :title, "Summary - Skills Assessment"
content_for :footer_title, "Skills Assessment"
content_for :progress, @status.progress.to_s
content_for_javascript_once 'summary-edit' do

View File

@ -50,6 +50,11 @@ Rails.application.routes.draw do
get "/admin/results", to: "admin/result#index", as: :admin_results
get "/admin/result/:test_hash", to: "admin/result#view", as: :admin_result
get "admin/vote/:test_hash/up", to: "admin/vote#up", as: :admin_up_vote
get "admin/vote/:test_hash/down", to: "admin/vote#down", as: :admin_down_vote
get "admin/vote/:test_hash/approve", to: "admin/vote#approve", as: :admin_approve_vote
get "admin/vote/:test_hash/decline", to: "admin/vote#decline", as: :admin_decline_vote
get "/admin", to: "admin/dashboard#show", as: :admin
#########################################################################################

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class CandidateReviewSystem < ActiveRecord::Migration[5.0]
def change
create_table :reviewer_votes do |t|
t.integer :candidate_id
t.integer :user_id
t.integer :vote, default: 0, null: false
t.integer :veto, default: 0, null: false
t.datetime :last_reminded
t.boolean :locked, default: false, null: false
t.timestamps
end
add_index :reviewer_votes, [:candidate_id, :user_id], unique: true
add_column :candidates, :review_status, :integer, default: 0, null: false
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class InitReviewerVotes < ActiveRecord::Migration[5.0]
def up
Candidate.where(completed: true).each(&:build_reviews)
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20160915164450) do
ActiveRecord::Schema.define(version: 20161120175737) do
create_table "answers", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
t.integer "candidate_id"
@ -33,9 +33,10 @@ ActiveRecord::Schema.define(version: 20160915164450) do
t.integer "recruiter_id"
t.boolean "completed"
t.boolean "reminded"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "quiz_id"
t.integer "review_status", default: 0, null: false
t.index ["quiz_id"], name: "index_candidates_on_quiz_id", using: :btree
t.index ["recruiter_id"], name: "index_candidates_on_recruiter_id", using: :btree
t.index ["test_hash"], name: "index_candidates_on_test_hash", unique: true, using: :btree
@ -73,6 +74,18 @@ ActiveRecord::Schema.define(version: 20160915164450) do
t.index ["quiz_id"], name: "index_reviewer_to_quizzes_on_quiz_id", using: :btree
end
create_table "reviewer_votes", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
t.integer "candidate_id"
t.integer "user_id"
t.integer "vote", default: 0, null: false
t.integer "veto", default: 0, null: false
t.datetime "last_reminded"
t.boolean "locked", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["candidate_id", "user_id"], name: "index_reviewer_votes_on_candidate_id_and_user_id", unique: true, using: :btree
end
create_table "users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
t.string "name"
t.string "email"

BIN
erd.pdf

Binary file not shown.

View File

@ -67,6 +67,18 @@ module Admin
assert_select 'p', 'foo bar baz'
end
test "should post attachment" do
question = questions(:fed1)
post admin_update_question_url(question.to_i), params: { question:
{ quiz_id: quizzes(:fed).to_i, attachment: 'https://dev.perficientdigital.com/logo.png' } }
assert_redirected_to admin_question_path(question.to_i)
get admin_question_path question.to_i
assert_select 'img' do
assert_select "[src=?]", "https://dev.perficientdigital.com/logo.png"
end
end
test "should fail to update question" do
question = questions(:fed9)
post admin_update_question_url(question.to_i), params: { question: { question: nil } }

View File

@ -19,5 +19,19 @@ module Admin
assert assigns(:quiz), "@quiz not present"
assert assigns(:status), "@status not present"
end
test "reviewer can view result for henry" do
auth_reviewer
get admin_result_url(candidates(:henry).test_hash)
assert_response :success
end
test "recruiter can view result for henry" do
auth_user users(:recruiter)
get admin_result_url(candidates(:henry).test_hash)
assert_response :success
end
end
end

View File

@ -1,4 +1,4 @@
# frozen_string_literal: true
# frozen_string_literal: true()
require 'test_helper'
module Admin

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
require 'test_helper'
module Admin
class VoteControllerTest < ActionDispatch::IntegrationTest
test "reviewer can up vote henry" do
auth_user users(:reviewer)
henry = candidates(:henry)
assert_difference("Candidate.find(#{henry.id}).votes.yea.count", 1) do
get admin_up_vote_url(henry.test_hash)
end
assert_response :success
end
test "reviewer can down vote henry" do
auth_user users(:reviewer)
henry = candidates(:henry)
assert_difference("Candidate.find(#{henry.id}).votes.nay.count", 1) do
get admin_down_vote_url(henry.test_hash)
end
assert_response :success
end
test "reviewer can change vote on henry" do
auth_user users(:reviewer)
henry = candidates(:henry)
get admin_up_vote_url(henry.test_hash)
assert_difference("Candidate.find(#{henry.id}).votes.yea.count", -1) do
assert_difference("Candidate.find(#{henry.id}).votes.nay.count", 1) do
get admin_down_vote_url(henry.test_hash)
end
end
assert_response :success
end
test "manager can approve henry" do
auth_user users(:manager)
henry = candidates(:henry)
get admin_approve_vote_url(henry.test_hash)
assert_equal 1, henry.votes.approved.count
assert_equal 'approved', Candidate.find(henry.to_i).review_status
assert_response :success
end
test "manager can decline henry" do
auth_user users(:manager)
henry = candidates(:henry)
get admin_decline_vote_url(henry.test_hash)
assert_equal 1, henry.votes.rejected.count
assert_equal 'declined', Candidate.find(henry.to_i).review_status
assert_response :success
end
end
end

View File

@ -404,3 +404,295 @@ juan10:
created_at: <%= DateTime.now() - 38.hours - 40.minutes %>
updated_at: <%= DateTime.now() - 38.hours - 20.minutes %>
stacy1:
candidate: stacy
question: Cras justo odio, dapibus ac facilisis in, egestas eget quam. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Nullam id dolor id nibh ultricies vehicula ut id elit.
answer: option 3
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 22.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 22.minutes %>
stacy2:
candidate: stacy
question: fed2
answer: 'indexOf()'
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 24.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 4.minutes %>
stacy3:
candidate: stacy
question: fed3
answer: {html: '<h1>Salmon</h1>', css: 'h1 {color: salmon;}', js: '', text: 'Gotta lotta GOOD things on sale, strangah.'}
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 26.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 6.minutes %>
stacy4:
candidate: stacy
question: fed4
answer: Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 28.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 28.minutes %>
stacy5:
candidate: stacy
question: fed5
answer: 'Dynamic listeners'
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 30.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 30.minutes %>
stacy6:
candidate: stacy
question: fed6
answer: Integer posuere erat a ante venenatis dapibus posuere velit aliquet.
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 32.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 12.minutes %>
stacy7:
candidate: stacy
question: fed7
answer: {html: '<p>This means <strong>jQuery</strong> needs to be available in live-coder!</p>', css: "strong {font-size: 1.6em;}\n.green {color: green;}", js: '$("strong").addClass("green");'}
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 34.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 14.minutes %>
stacy8:
candidate: stacy
question: fed8
answer:
other: Some generic user input
options:
- other
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 36.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 16.minutes %>
stacy9:
candidate: stacy
question: fed9
answer:
other: Brunch
options:
- Neither
- other
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 38.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 18.minutes %>
stacy10:
candidate: stacy
question: fed10
answer: ["Live long and prosper", "Who you calling Scruffy?"]
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 40.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 20.minutes %>
henry1:
candidate: henry
question: Cras justo odio, dapibus ac facilisis in, egestas eget quam. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Nullam id dolor id nibh ultricies vehicula ut id elit.
answer: option 3
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 22.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 22.minutes %>
henry2:
candidate: henry
question: fed2
answer: 'indexOf()'
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 24.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 4.minutes %>
henry3:
candidate: henry
question: fed3
answer: {html: '<h1>Salmon</h1>', css: 'h1 {color: salmon;}', js: '', text: 'Gotta lotta GOOD things on sale, strangah.'}
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 26.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 6.minutes %>
henry4:
candidate: henry
question: fed4
answer: Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 28.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 28.minutes %>
henry5:
candidate: henry
question: fed5
answer: 'Dynamic listeners'
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 30.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 30.minutes %>
henry6:
candidate: henry
question: fed6
answer: Integer posuere erat a ante venenatis dapibus posuere velit aliquet.
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 32.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 12.minutes %>
henry7:
candidate: henry
question: fed7
answer: {html: '<p>This means <strong>jQuery</strong> needs to be available in live-coder!</p>', css: "strong {font-size: 1.6em;}\n.green {color: green;}", js: '$("strong").addClass("green");'}
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 34.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 14.minutes %>
henry8:
candidate: henry
question: fed8
answer:
other: Some generic user input
options:
- other
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 36.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 16.minutes %>
henry9:
candidate: henry
question: fed9
answer:
other: Brunch
options:
- Neither
- other
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 38.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 18.minutes %>
henry10:
candidate: henry
question: fed10
answer: ["Live long and prosper", "Who you calling Scruffy?"]
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 40.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 20.minutes %>
wade1:
candidate: wade
question: Cras justo odio, dapibus ac facilisis in, egestas eget quam. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Nullam id dolor id nibh ultricies vehicula ut id elit.
answer: option 3
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 22.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 22.minutes %>
wade2:
candidate: wade
question: fed2
answer: 'indexOf()'
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 24.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 4.minutes %>
wade3:
candidate: wade
question: fed3
answer: {html: '<h1>Salmon</h1>', css: 'h1 {color: salmon;}', js: '', text: 'Gotta lotta GOOD things on sale, strangah.'}
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 26.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 6.minutes %>
wade4:
candidate: wade
question: fed4
answer: Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 28.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 28.minutes %>
wade5:
candidate: wade
question: fed5
answer: 'Dynamic listeners'
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 30.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 30.minutes %>
wade6:
candidate: wade
question: fed6
answer: Integer posuere erat a ante venenatis dapibus posuere velit aliquet.
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 32.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 12.minutes %>
wade7:
candidate: wade
question: fed7
answer: {html: '<p>This means <strong>jQuery</strong> needs to be available in live-coder!</p>', css: "strong {font-size: 1.6em;}\n.green {color: green;}", js: '$("strong").addClass("green");'}
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 34.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 14.minutes %>
wade8:
candidate: wade
question: fed8
answer:
other: Some generic user input
options:
- other
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 36.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 16.minutes %>
wade9:
candidate: wade
question: fed9
answer:
other: Brunch
options:
- Neither
- other
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 38.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 18.minutes %>
wade10:
candidate: wade
question: fed10
answer: ["Live long and prosper", "Who you calling Scruffy?"]
saved: 0
submitted: true
created_at: <%= DateTime.now() - 36.hours - 40.minutes %>
updated_at: <%= DateTime.now() - 36.hours - 20.minutes %>

View File

@ -59,6 +59,7 @@ richard: # Richard has completed AND submitted the test
completed: true
reminded: false
test_hash: 6NjnourLE6Y
review_status: 1
juan: # Juan has chosen "finish later" for live coders
name: Juan Campbell
@ -68,4 +69,46 @@ juan: # Juan has chosen "finish later" for live coders
quiz: fed
completed: false
reminded: true
test_hash: <%= CryptSerializer.dump 'qKQo0l4dyol
test_hash: qKQo0l4dyol
stacy: # Stacy has completed AND submitted the test
name: Stacy Scott
email: <%= CryptSerializer.dump 'stacy.scott@mailinator.com' %>
experience: 7-9
recruiter: recruiter
quiz: fed
completed: true
reminded: false
test_hash: s6oFExZliYYFx
review_status: 2
henry: # Henry has completed AND submitted the test
name: Henry Butler
email: <%= CryptSerializer.dump 'henry.butler@mailinator.com' %>
experience: 4-6
recruiter: recruiter
quiz: fed
completed: true
reminded: false
test_hash: egPomAuVDeCEp
wade: # Wade has completed AND submitted the test
name: Wade Armstrong
email: <%= CryptSerializer.dump 'wade.armstrong@mailinator.com' %>
experience: 0-3
recruiter: recruiter
quiz: fed
completed: true
reminded: false
test_hash: BkSkpapJnkz2N
gustov: # Gustov is NOT for FED
name: Gustov
email: <%= CryptSerializer.dump 'gustov@mailinator.com' %>
experience: 0-3
recruiter: recruiter
quiz: admin
completed: false
reminded: false
test_hash: kp6tfghjyapJnkz2N

View File

@ -55,7 +55,7 @@ fed5:
fed6:
quiz: fed
question: Comment on how realistic the following image is.
attachment: "http://dev.perficientxd.com/skill_assets/commets_css.jpg"
attachment: "https://dev.perficientdigital.com/skills-app-images/commets_css.jpg"
category: CSS
input_type: text
input_options:

View File

@ -7,3 +7,7 @@ one:
two:
user: reviewer2
quiz: fed
three:
user: manager
quiz: fed

68
test/fixtures/reviewer_votes.yml vendored Normal file
View File

@ -0,0 +1,68 @@
gustov:
candidate: gustov
user_id: 12341234
manager_richard:
candidate: richard
user: manager
vote: 1
reviewer_richard:
candidate: richard
user: reviewer
vote: 1
reviewer2_richard:
candidate: richard
user: reviewer2
vote: 1
manager_stacy:
candidate: stacy
user: manager
vote: 2
reviewer_stacy:
candidate: stacy
user: reviewer
vote: 2
reviewer2_stacy:
candidate: stacy
user: reviewer2
vote: 2
manager_henry:
candidate: henry
user: manager
vote: 0
veto: 2
reviewer_henry:
candidate: henry
user: reviewer
reviewer2_henry:
candidate: henry
user: reviewer2
manager_wade:
candidate: wade
user: manager
vote: 2
veto: 2
reviewer_wade:
candidate: wade
user: reviewer
vote: 2
reviewer2_wade:
candidate: wade
user: reviewer2

View File

@ -6,6 +6,7 @@ class ReviewerMailerTest < ActionMailer::TestCase
candidate = candidates :dawn
mail = ReviewerMailer.candidate_submission candidate
assert_match "Results", mail.subject
assert_match candidate.test_hash, mail.subject
assert_equal candidate.quiz.reviewers.map(&:email), mail.to
assert_equal [ENV["default_mail_from"]], mail.from
assert_match candidate.test_hash, mail.body.encoded

View File

@ -24,4 +24,11 @@ class CandidateTest < ActiveSupport::TestCase
refute_equal email, enc_email
end
test "can build reviewer records" do
candidate = candidates(:dawn)
candidate.build_reviews
assert_equal 3, candidate.votes.count
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
require 'test_helper'
class ReviewerVoteTest < ActiveSupport::TestCase
test "the truth" do
assert ReviewerVoteTest
end
test "richard has 3 votes" do
richard = candidates(:richard)
assert_equal 3, richard.votes.size
end
test "manager has 4 votes" do
manager = users(:manager)
assert_equal 4, manager.votes.size
end
test "richard has been approved" do
richard = candidates(:richard)
assert richard.approved?
refute richard.declined?
end
test "stacy has been declined" do
stacy = candidates(:stacy)
assert stacy.declined?
refute stacy.approved?
end
end

View File

@ -20,14 +20,14 @@ class UserTest < ActiveSupport::TestCase
refute user.reviewer?
end
test 'manager should act as manager' do
test 'manager should act as manager and reviewer' do
user = users(:manager)
assert user.acts_as_manager?
assert user.acts_as_reviewer?
refute user.acts_as_admin?
refute user.acts_as_recruiter?
refute user.acts_as_reviewer?
end
test 'manager should only be manager' do

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
require 'test_helper'
class ReviewerVotePolicyTest < PolicyAssertions::Test
test 'should require current_user' do
assert_raise Pundit::NotAuthorizedError do
ReviewerVotePolicy.new(nil, ReviewerVote.first).view?
end
end
test 'should allow admin to scope' do
scope = ReviewerVotePolicy::Scope.new(users(:admin), ReviewerVote).resolve
assert_equal ReviewerVote.count, scope.count
end
test 'should allow manager to scope' do
scope = ReviewerVotePolicy::Scope.new(users(:manager), ReviewerVote).resolve
assert_equal ReviewerVote.count, scope.count
end
test 'should allow reviewer to scope' do
scope = ReviewerVotePolicy::Scope.new(users(:reviewer), ReviewerVote).resolve
assert_equal users(:reviewer).votes.count, scope.count
end
test 'should NOT allow recruiter to scope' do
scope = ReviewerVotePolicy::Scope.new(users(:recruiter), ReviewerVote).resolve
assert_equal 0, scope.count
end
def test_up
assert_permit users(:manager), reviewer_votes(:manager_richard)
assert_permit users(:reviewer), reviewer_votes(:reviewer_richard)
assert_permit users(:admin), reviewer_votes(:manager_henry)
refute_permit users(:recruiter), reviewer_votes(:manager_henry)
refute_permit users(:reviewer), reviewer_votes(:gustov)
refute_permit users(:manager), reviewer_votes(:gustov)
end
def test_down
assert_permit users(:manager), reviewer_votes(:manager_richard)
assert_permit users(:reviewer), reviewer_votes(:reviewer_richard)
assert_permit users(:admin), reviewer_votes(:manager_henry)
refute_permit users(:recruiter), reviewer_votes(:manager_henry)
refute_permit users(:reviewer), reviewer_votes(:gustov)
refute_permit users(:manager), reviewer_votes(:gustov)
end
def approve
assert_permit users(:manager), reviewer_votes(:manager_richard)
assert_permit users(:admin), reviewer_votes(:manager_henry)
refute_permit users(:recruiter), reviewer_votes(:manager_henry)
refute_permit users(:reviewer), reviewer_votes(:reviewer_richard)
end
def decline
assert_permit users(:manager), reviewer_votes(:manager_richard)
assert_permit users(:admin), reviewer_votes(:manager_henry)
refute_permit users(:recruiter), reviewer_votes(:manager_henry)
refute_permit users(:reviewer), reviewer_votes(:reviewer_richard)
end
end