app/models/assignment.rb
###
###
### We have spent a lot of time on refactoring this file, PLEASE consult with Expertiza development team before putting code in.
###
###
class Assignment < ActiveRecord::Base
require 'analytic/assignment_analytic'
include AssignmentAnalytic
include ReviewAssignment
include QuizAssignment
include OnTheFlyCalc
has_paper_trail
# When an assignment is created, it needs to
# be created as an instance of a subclass of the Assignment (model) class;
# then Rails will "automatically' set the type field to the value that
# designates an assignment of the appropriate type.
belongs_to :course
belongs_to :instructor, class_name: 'User'
has_one :assignment_node, foreign_key: 'node_object_id', dependent: :destroy
has_many :participants, class_name: 'AssignmentParticipant', foreign_key: 'parent_id', dependent: :destroy
has_many :users, through: :participants
has_many :due_dates, class_name: 'AssignmentDueDate', foreign_key: 'parent_id', dependent: :destroy
has_many :teams, class_name: 'AssignmentTeam', foreign_key: 'parent_id', dependent: :destroy
has_many :invitations, class_name: 'Invitation', foreign_key: 'assignment_id', dependent: :destroy
has_many :assignment_questionnaires, dependent: :destroy
has_many :questionnaires, through: :assignment_questionnaires
has_many :sign_up_topics, foreign_key: 'assignment_id', dependent: :destroy
has_many :response_maps, foreign_key: 'reviewed_object_id', dependent: :destroy
has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewed_object_id', dependent: :destroy
has_many :plagiarism_checker_assignment_submissions, dependent: :destroy
has_many :assignment_badges, dependent: :destroy
has_many :badges, through: :assignment_badges
validates :name, presence: true
validates :name, uniqueness: {scope: :course_id}
validate :valid_num_review
REVIEW_QUESTIONNAIRES = {author_feedback: 0, metareview: 1, review: 2, teammate_review: 3}.freeze
# Review Strategy information.
RS_AUTO_SELECTED = 'Auto-Selected'.freeze
RS_INSTRUCTOR_SELECTED = 'Instructor-Selected'.freeze
REVIEW_STRATEGIES = [RS_AUTO_SELECTED, RS_INSTRUCTOR_SELECTED].freeze
DEFAULT_MAX_REVIEWERS = 3
DEFAULT_MAX_OUTSTANDING_REVIEWS = 2
def self.max_outstanding_reviews
DEFAULT_MAX_OUTSTANDING_REVIEWS
end
def team_assignment?
true
end
alias team_assignment team_assignment?
def topics?
@has_topics ||= !sign_up_topics.empty?
end
def calibrated?
self.is_calibrated
end
def self.set_courses_to_assignment(user)
@courses = Course.where(instructor_id: user.id).order(:name)
end
def self.remove_assignment_from_course(assignment)
oldpath = assignment.path rescue nil
assignment.course_id = nil
assignment.save
newpath = assignment.path rescue nil
FileHelper.update_file_location(oldpath, newpath)
end
def teams?
@has_teams ||= !self.teams.empty?
end
def valid_num_review
self.num_reviews = self.num_reviews_allowed
if self.num_reviews_allowed && self.num_reviews_allowed != -1 && self.num_reviews_allowed < self.num_reviews_required
self.errors.add(:message, "Num of reviews required cannot be greater than number of reviews allowed")
elsif self.num_metareviews_allowed && self.num_metareviews_allowed != -1 && self.num_metareviews_allowed < self.num_metareviews_required
self.errors.add(:message, "Number of Meta-Reviews required cannot be greater than number of meta-reviews allowed")
end
end
#--------------------metareview assignment begin
def assign_metareviewer_dynamically(meta_reviewer)
# The following method raises an exception if not successful which
# has to be captured by the caller (in review_mapping_controller)
response_map = response_map_to_metareview(meta_reviewer)
response_map.assign_metareviewer(meta_reviewer)
end
# Returns a review (response) to metareview if available, otherwise will raise an error
def response_map_to_metareview(metareviewer)
response_map_set = Array.new(review_mappings)
# Reject response maps without responses
response_map_set.reject! {|response_map| response_map.response.empty? }
raise 'There are no reviews to metareview at this time for this assignment.' if response_map_set.empty?
# Reject reviews where the meta_reviewer was the reviewer or the contributor
response_map_set.reject! do |response_map|
response_map.reviewee == metareviewer or response_map.reviewer == metareviewer
end
raise 'There are no more reviews to metareview for this assignment.' if response_map_set.empty?
# Metareviewer can only metareview each review once
response_map_set.reject! {|response_map| response_map.metareviewed_by?(metareviewer) }
raise 'You have already metareviewed all reviews for this assignment.' if response_map_set.empty?
# Reduce to the response maps with the least number of metareviews received
response_map_set.sort! {|a, b| a.metareview_response_maps.count <=> b.metareview_response_maps.count }
min_metareviews = response_map_set.first.metareview_response_maps.count
response_map_set.reject! {|response_map| response_map.metareview_response_maps.count > min_metareviews }
# Reduce the response maps to the reviewers with the least number of metareviews received
reviewers = {} # <reviewer, number of metareviews>
response_map_set.each do |response_map|
reviewer = response_map.reviewer
reviewers.member?(reviewer) ? reviewers[reviewer] += 1 : reviewers[reviewer] = 1
end
reviewers = reviewers.sort_by {|a| a[1] }
min_metareviews = reviewers.first[1]
reviewers.reject! {|reviewer| reviewer[1] == min_metareviews }
response_map_set.reject! {|response_map| reviewers.member?(response_map.reviewer) }
# Pick the response map whose most recent meta_reviewer was assigned longest ago
response_map_set.sort! {|a, b| a.metareview_response_maps.count <=> b.metareview_response_maps.count }
min_metareviews = response_map_set.first.metareview_response_maps.count
response_map_set.sort! {|a, b| a.metareview_response_maps.last.id <=> b.metareview_response_maps.last.id } if min_metareviews > 0
# The first review_map is the best to metareview
response_map_set.first
end
def metareview_mappings
mappings = []
self.review_mappings.each do |map|
m_map = MetareviewResponseMap.find_by(reviewed_object_id: map.id)
mappings << m_map unless m_map.nil?
end
mappings
end
#--------------------metareview assignment end
def dynamic_reviewer_assignment?
self.review_assignment_strategy == RS_AUTO_SELECTED
end
alias is_using_dynamic_reviewer_assignment? dynamic_reviewer_assignment?
def scores(questions)
scores = {}
scores[:participants] = {}
self.participants.each do |participant|
scores[:participants][participant.id.to_s.to_sym] = participant.scores(questions)
end
scores[:teams] = {}
index = 0
self.teams.each do |team|
scores[:teams][index.to_s.to_sym] = {}
scores[:teams][index.to_s.to_sym][:team] = team
if self.varying_rubrics_by_round?
grades_by_rounds = {}
total_score = 0
total_num_of_assessments = 0 # calculate grades for each rounds
(1..self.num_review_rounds).each do |i|
assessments = ReviewResponseMap.get_responses_for_team_round(team, i)
round_sym = ("review" + i.to_s).to_sym
grades_by_rounds[round_sym] = Answer.compute_scores(assessments, questions[round_sym])
total_num_of_assessments += assessments.size
total_score += grades_by_rounds[round_sym][:avg] * assessments.size.to_f unless grades_by_rounds[round_sym][:avg].nil?
end
# merge the grades from multiple rounds
scores[:teams][index.to_s.to_sym][:scores] = {}
scores[:teams][index.to_s.to_sym][:scores][:max] = -999_999_999
scores[:teams][index.to_s.to_sym][:scores][:min] = 999_999_999
scores[:teams][index.to_s.to_sym][:scores][:avg] = 0
(1..self.num_review_rounds).each do |i|
round_sym = ("review" + i.to_s).to_sym
if !grades_by_rounds[round_sym][:max].nil? && scores[:teams][index.to_s.to_sym][:scores][:max] < grades_by_rounds[round_sym][:max]
scores[:teams][index.to_s.to_sym][:scores][:max] = grades_by_rounds[round_sym][:max]
end
if !grades_by_rounds[round_sym][:min].nil? && scores[:teams][index.to_s.to_sym][:scores][:min] > grades_by_rounds[round_sym][:min]
scores[:teams][index.to_s.to_sym][:scores][:min] = grades_by_rounds[round_sym][:min]
end
end
if total_num_of_assessments != 0
scores[:teams][index.to_s.to_sym][:scores][:avg] = total_score / total_num_of_assessments
else
scores[:teams][index.to_s.to_sym][:scores][:avg] = nil
scores[:teams][index.to_s.to_sym][:scores][:max] = 0
scores[:teams][index.to_s.to_sym][:scores][:min] = 0
end
else
assessments = ReviewResponseMap.get_assessments_for(team)
scores[:teams][index.to_s.to_sym][:scores] = Answer.compute_scores(assessments, questions[:review])
end
index += 1
end
scores
end
def path
if self.course_id.nil? && self.instructor_id.nil?
raise 'The path cannot be created. The assignment must be associated with either a course or an instructor.'
end
path_text = ""
path_text = if !self.course_id.nil? && self.course_id > 0
Rails.root.to_s + '/pg_data/' + FileHelper.clean_path(self.instructor[:name]) + '/' +
FileHelper.clean_path(self.course.directory_path) + '/'
else
Rails.root.to_s + '/pg_data/' + FileHelper.clean_path(self.instructor[:name]) + '/'
end
path_text += FileHelper.clean_path(self.directory_path)
path_text
end
# Check whether review, metareview, etc.. is allowed
# The permissions of TopicDueDate is the same as AssignmentDueDate.
# Here, column is usually something like 'review_allowed_id'
def check_condition(column, topic_id = nil)
next_due_date = DueDate.get_next_due_date(self.id, topic_id)
return false if next_due_date.nil?
right_id = next_due_date.send column
right = DeadlineRight.find(right_id)
right && (right.name == 'OK' || right.name == 'Late')
end
# Determine if the next due date from now allows for submissions
def submission_allowed(topic_id = nil)
check_condition('submission_allowed_id', topic_id)
end
# Determine if the next due date from now allows to take the quizzes
def quiz_allowed(topic_id = nil)
check_condition("quiz_allowed_id", topic_id)
end
# Determine if the next due date from now allows for reviews
def can_review(topic_id = nil)
check_condition('review_allowed_id', topic_id)
end
# Determine if the next due date from now allows for metareviews
def metareview_allowed(topic_id = nil)
check_condition('review_of_review_allowed_id', topic_id)
end
def delete(force = nil)
begin
maps = ReviewResponseMap.where(reviewed_object_id: self.id)
maps.each {|map| map.delete(force) }
rescue StandardError
raise "There is at least one review response that exists for #{self.name}."
end
begin
maps = TeammateReviewResponseMap.where(reviewed_object_id: self.id)
maps.each {|map| map.delete(force) }
rescue StandardError
raise "There is at least one teammate review response that exists for #{self.name}."
end
self.invitations.each(&:destroy)
self.teams.each(&:delete)
self.participants.each(&:delete)
self.due_dates.each(&:destroy)
self.assignment_questionnaires.each(&:destroy)
# The size of an empty directory is 2
# Delete the directory if it is empty
directory = Dir.entries(Rails.root + '/pg_data/' + self.directory_path) rescue nil
if self.directory_path.present? and !directory.nil?
if directory.size == 2
Dir.delete(Rails.root + '/pg_data/' + self.directory_path)
else
raise 'The assignment directory is not empty.'
end
end
self.destroy
end
# Check to see if assignment is a microtask
def microtask?
self.microtask.nil? ? false : self.microtask
end
def has_badge?
self.has_badge.nil? ? false : self.has_badge
end
# add a new participant to this assignment
# manual addition
# user_name - the user account name of the participant to add
def add_participant(user_name, can_submit, can_review, can_take_quiz)
user = User.find_by(name: user_name)
if user.nil?
raise "The user account with the name #{user_name} does not exist. Please <a href='" +
url_for(controller: 'users', action: 'new') + "'>create</a> the user first."
end
participant = AssignmentParticipant.find_by(parent_id: self.id, user_id: user.id)
raise "The user #{user.name} is already a participant." if participant
new_part = AssignmentParticipant.create(parent_id: self.id,
user_id: user.id,
permission_granted: user.master_permission_granted,
can_submit: can_submit,
can_review: can_review,
can_take_quiz: can_take_quiz)
new_part.set_handle
end
def create_node
parent = CourseNode.find_by(node_object_id: self.course_id)
node = AssignmentNode.create(node_object_id: self.id)
node.parent_id = parent.id unless parent.nil?
node.save
end
# if current stage is submission or review, find the round number
# otherwise, return 0
def number_of_current_round(topic_id)
next_due_date = DueDate.get_next_due_date(self.id, topic_id)
return 0 if next_due_date.nil?
next_due_date.round ||= 0
end
# For varying rubric feature
def current_stage_name(topic_id = nil)
if self.staggered_deadline?
return (topic_id.nil? ? 'Unknown' : get_current_stage(topic_id))
end
due_date = find_current_stage(topic_id)
unless self.staggered_deadline?
if due_date != 'Finished' && !due_date.nil? && !due_date.deadline_name.nil?
return due_date.deadline_name
else
return get_current_stage(topic_id)
end
end
end
# check if this assignment has multiple review phases with different review rubrics
def varying_rubrics_by_round?
AssignmentQuestionnaire.where(assignment_id: self.id, used_in_round: 2).size >= 1
end
def link_for_current_stage(topic_id = nil)
if self.staggered_deadline?
return nil if topic_id.nil?
end
due_date = find_current_stage(topic_id)
if due_date.nil? or due_date == 'Finished' or due_date.is_a?(TopicDueDate)
return nil
else
due_date.description_url
end
end
def stage_deadline(topic_id = nil)
return 'Unknown' if topic_id.nil? and self.staggered_deadline?
due_date = find_current_stage(topic_id)
due_date.nil? || due_date == 'Finished' ? due_date : due_date.due_at.to_s
end
def num_review_rounds
due_dates = AssignmentDueDate.where(parent_id: self.id)
rounds = 0
due_dates.each do |due_date|
rounds = due_date.round if due_date.round > rounds
end
rounds
end
def find_current_stage(topic_id = nil)
next_due_date = DueDate.get_next_due_date(self.id, topic_id)
return 'Finished' if next_due_date.nil?
next_due_date
end
# Zhewei: this method is almost the same as 'stage_deadline'
def get_current_stage(topic_id = nil)
return 'Unknown' if topic_id.nil? and self.staggered_deadline?
due_date = find_current_stage(topic_id)
due_date.nil? || due_date == 'Finished' ? 'Finished' : DeadlineType.find(due_date.deadline_type_id).name
end
def review_questionnaire_id(round = nil)
# Get the round it's in from the next duedates
if round.nil?
next_due_date = DueDate.get_next_due_date(self.id)
round = next_due_date.try(:round)
end
# for program 1 like assignment, if same rubric is used in both rounds,
# the 'used_in_round' field in 'assignment_questionnaires' will be null,
# since one field can only store one integer
# if rev_q_ids is empty, Expertiza will try to find questionnaire whose type is 'ReviewQuestionnaire'.
rev_q_ids = if round.nil?
AssignmentQuestionnaire.where(assignment_id: self.id)
else
AssignmentQuestionnaire.where(assignment_id: self.id, used_in_round: round)
end
if rev_q_ids.empty?
AssignmentQuestionnaire.where(assignment_id: self.id).find_each do |aq|
rev_q_ids << aq if aq.questionnaire.type == "ReviewQuestionnaire"
end
end
review_questionnaire_id = nil
rev_q_ids.each do |rqid|
next if rqid.questionnaire_id.nil?
rtype = Questionnaire.find(rqid.questionnaire_id).type
if rtype == 'ReviewQuestionnaire'
review_questionnaire_id = rqid.questionnaire_id
break
end
end
review_questionnaire_id
end
def self.export_details(csv, parent_id, detail_options)
return csv unless detail_options.value?('true')
@assignment = Assignment.find(parent_id)
@answers = {} # Contains all answer objects for this assignment
# Find all unique response types
@uniq_response_type = ResponseMap.uniq.pluck(:type)
# Find all unique round numbers
@uniq_rounds = Response.uniq.pluck(:round)
# create the nested hash that holds all the answers organized by round # and response type
@uniq_rounds.each do |round_num|
@answers[round_num] = {}
@uniq_response_type.each do |res_type|
@answers[round_num][res_type] = []
end
end
@answers = generate_answer(@answers, @assignment)
# Loop through each round and response type and construct a new row to be pushed in CSV
@uniq_rounds.each do |round_num|
@uniq_response_type.each do |res_type|
round_type = check_empty_rounds(@answers, round_num, res_type)
csv << [round_type, '---', '---', '---', '---', '---', '---', '---'] unless round_type.nil?
@answers[round_num][res_type].each do |answer|
csv << csv_row(detail_options, answer)
end
end
end
end
# This method is used for export detailed contents. - Akshit, Kushagra, Vaibhav
def self.export_details_fields(detail_options)
fields = []
fields << 'Team ID / Author ID' if detail_options['team_id'] == 'true'
fields << 'Reviewee (Team / Student Name)' if detail_options['team_name'] == 'true'
fields << 'Reviewer' if detail_options['reviewer'] == 'true'
fields << 'Question / Criterion' if detail_options['question'] == 'true'
fields << 'Question ID' if detail_options['question_id'] == 'true'
fields << 'Answer / Comment ID' if detail_options['comment_id'] == 'true'
fields << 'Answer / Comment' if detail_options['comments'] == 'true'
fields << 'Score' if detail_options['score'] == 'true'
fields
end
def self.handle_nil(csv_field)
return ' ' if csv_field.nil?
csv_field
end
# Generates a single row based on the detail_options selected
def self.csv_row(detail_options, answer)
tcsv = []
@response = Response.find(answer.response_id)
map = ResponseMap.find(@response.map_id)
@reviewee = Team.find_by id: map.reviewee_id
@reviewee = Participant.find(map.reviewee_id).user if @reviewee.nil?
reviewer = Participant.find(map.reviewer_id).user
tcsv << handle_nil(@reviewee.id) if detail_options['team_id'] == 'true'
tcsv << handle_nil(@reviewee.name) if detail_options['team_name'] == 'true'
tcsv << handle_nil(reviewer.name) if detail_options['reviewer'] == 'true'
tcsv << handle_nil(answer.question.txt) if detail_options['question'] == 'true'
tcsv << handle_nil(answer.question.id) if detail_options['question_id'] == 'true'
tcsv << handle_nil(answer.id) if detail_options['comment_id'] == 'true'
tcsv << handle_nil(answer.comments) if detail_options['comments'] == 'true'
tcsv << handle_nil(answer.answer) if detail_options['score'] == 'true'
tcsv
end
# Populate answers will review information
def self.generate_answer(answers, assignment)
# get all response maps for this assignment
@response_maps_for_assignment = ResponseMap.find_by_sql(["SELECT * FROM response_maps WHERE reviewed_object_id = #{assignment.id}"])
# for each map, get the response & answer associated with it
@response_maps_for_assignment.each do |map|
@response_for_this_map = Response.find_by_sql(["SELECT * FROM responses WHERE map_id = #{map.id}"])
# for this response, get the answer associated with it
@response_for_this_map.each do |resp|
@answer = Answer.find_by_sql(["SELECT * FROM answers WHERE response_id = #{resp.id}"])
@answer.each do |ans|
answers[resp.round][map.type].push(ans)
end
end
end
answers
end
# Checks if there are rounds with no reviews
def self.check_empty_rounds(answers, round_num, res_type)
unless answers[round_num][res_type].empty?
round_type =
if round_num.nil?
"Round Nill - " + res_type
else
"Round " + round_num.to_s + " - " + res_type.to_s
end
return round_type
end
nil
end
# This method is used to set the headers for the csv like Assignment Name and Assignment Instructor
def self.export_headers(parent_id)
@assignment = Assignment.find(parent_id)
fields = []
fields << "Assignment Name: " + @assignment.name.to_s
fields << "Assignment Instructor: " + User.find(@assignment.instructor_id).name.to_s
fields
end
# This method is used for export contents of grade#view. -Zhewei
def self.export(csv, parent_id, options)
@assignment = Assignment.find(parent_id)
@questions = {}
questionnaires = @assignment.questionnaires
questionnaires.each do |questionnaire|
if @assignment.varying_rubrics_by_round?
round = AssignmentQuestionnaire.find_by(assignment_id: @assignment.id, questionnaire_id: @questionnaire.id).used_in_round
questionnaire_symbol = if round.nil?
questionnaire.symbol
else
(questionnaire.symbol.to_s + round.to_s).to_sym
end
else
questionnaire_symbol = questionnaire.symbol
end
@questions[questionnaire_symbol] = questionnaire.questions
end
@scores = @assignment.scores(@questions)
return csv if @scores[:teams].nil?
export_data(csv, @scores, options)
end
def self.export_data(csv, scores, options)
@scores = scores
(0..@scores[:teams].length - 1).each do |index|
team = @scores[:teams][index.to_s.to_sym]
first_participant = team[:team].participants[0] unless team[:team].participants[0].nil?
pscore = @scores[:participants][first_participant.id.to_s.to_sym]
tcsv = []
tcsv << team[:team].name
names_of_participants = ''
team[:team].participants.each do |p|
names_of_participants += p.fullname
names_of_participants += '; ' unless p == team[:team].participants.last
end
tcsv << names_of_participants
export_data_fields(options)
csv << tcsv
end
end
def self.export_data_fields(options)
if options['team_score'] == 'true'
team[:scores] ?
tcsv.push(team[:scores][:max], team[:scores][:min], team[:scores][:avg]) :
tcsv.push('---', '---', '---')
end
review_hype_mapping_hash = {review: 'submitted_score',
metareview: 'metareview_score',
feedback: 'author_feedback_score',
teammate: 'teammate_review_score'}
review_hype_mapping_hash.each do |review_type, score_name|
export_individual_data_fields(review_type, score_name)
end
tcsv.push(pscore[:total_score])
end
def self.export_individual_data_fields(review_type, score_name)
if pscore[review_type]
tcsv.push(pscore[review_type][:scores][:max], pscore[review_type][:scores][:min], pscore[review_type][:scores][:avg])
else
tcsv.push('---', '---', '---') if options[score_name]
end
end
# This method is used for export contents of grade#view. -Zhewei
def self.export_fields(options)
fields = []
fields << 'Team Name'
fields << 'Team Member(s)'
fields.push('Team Max', 'Team Min', 'Team Avg') if options['team_score'] == 'true'
fields.push('Submitted Max', 'Submitted Min', 'Submitted Avg') if options['submitted_score']
fields.push('Metareview Max', 'Metareview Min', 'Metareview Avg') if options['metareview_score']
fields.push('Author Feedback Max', 'Author Feedback Min', 'Author Feedback Avg') if options['author_feedback_score']
fields.push('Teammate Review Max', 'Teammate Review Min', 'Teammate Review Avg') if options['teammate_review_score']
fields.push('Final Score')
fields
end
def find_due_dates(type)
self.due_dates.select {|due_date| due_date.deadline_type_id == DeadlineType.find_by(name: type).id }
end
end