app/models/course/assessment.rb
# frozen_string_literal: true
# Represents an assessment in Coursemology, as well as the enclosing module for associated models.
#
# An assessment is a collection of questions that can be asked.
class Course::Assessment < ApplicationRecord
acts_as_lesson_plan_item has_todo: true
acts_as_conditional
has_one_folder
# Concern must be included below acts_as_lesson_plan_item to override #can_user_start?
include Course::Assessment::TodoConcern
include Course::ClosingReminderConcern
include DuplicationStateTrackingConcern
include Course::Assessment::NewSubmissionConcern
after_initialize :set_defaults, if: :new_record?
before_validation :propagate_course, if: :new_record?
before_validation :assign_folder_attributes
after_commit :grade_with_new_test_cases, on: :update
before_save :save_tab
enum :randomization, { prepared: 0 }
validates :autograded, inclusion: { in: [true, false] }
validates :session_password, length: { maximum: 255 }, allow_nil: true
validates :tabbed_view, inclusion: { in: [true, false] }
validates :view_password, length: { maximum: 255 }, allow_nil: true
validates :creator, presence: true
validates :updater, presence: true
validates :tab, presence: true
belongs_to :tab, inverse_of: :assessments
belongs_to :monitor, class_name: 'Course::Monitoring::Monitor', optional: true
# `submissions` association must be put before `questions`, so that all answers will be deleted
# first when deleting the course. Otherwise due to the foreign key `question_id` in answers table,
# questions cannot be deleted.
has_many :submissions, inverse_of: :assessment, dependent: :destroy
has_many :question_assessments, class_name: 'Course::QuestionAssessment',
inverse_of: :assessment, dependent: :destroy
has_many :questions, through: :question_assessments do
include Course::Assessment::QuestionsConcern
end
has_many :multiple_response_questions,
through: :questions, inverse_through: :question, source: :actable,
source_type: 'Course::Assessment::Question::MultipleResponse'
has_many :text_response_questions,
through: :questions, inverse_through: :question, source: :actable,
source_type: 'Course::Assessment::Question::TextResponse'
has_many :programming_questions,
through: :questions, inverse_through: :question, source: :actable,
source_type: 'Course::Assessment::Question::Programming'
has_many :scribing_questions,
through: :questions, inverse_through: :question, source: :actable,
source_type: 'Course::Assessment::Question::Scribing'
has_many :voice_response_questions,
through: :questions, inverse_through: :question, source: :actable,
source_type: 'Course::Assessment::Question::VoiceResponse'
has_many :forum_post_response_questions,
through: :questions, inverse_through: :question, source: :actable,
source_type: 'Course::Assessment::Question::ForumPostResponse'
has_many :assessment_conditions, class_name: 'Course::Condition::Assessment',
inverse_of: :assessment, dependent: :destroy
has_many :question_groups, class_name: 'Course::Assessment::QuestionGroup',
inverse_of: :assessment, dependent: :destroy
has_many :question_bundles, class_name: 'Course::Assessment::QuestionBundle', through: :question_groups
has_many :question_bundle_questions, class_name: 'Course::Assessment::QuestionBundleQuestion',
through: :question_bundles
has_many :question_bundle_assignments, class_name: 'Course::Assessment::QuestionBundleAssignment',
inverse_of: :assessment, dependent: :destroy
has_one :duplication_traceable, class_name: 'DuplicationTraceable::Assessment',
inverse_of: :assessment, dependent: :destroy
has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback',
inverse_of: :assessment, dependent: :destroy
validate :tab_in_same_course
validate :selected_test_type_for_grading
scope :published, -> { where(published: true) }
# @!attribute [r] maximum_grade
# Gets the maximum grade allowed by this assessment. This is the sum of all questions'
# maximum grade.
# @return [Integer]
calculated :maximum_grade, (lambda do
Course::Assessment::Question.
select('coalesce(sum(caq.maximum_grade), 0)').
from(
"course_assessment_questions caq INNER JOIN course_question_assessments cqa ON \
cqa.assessment_id = course_assessments.id AND cqa.question_id = caq.id"
)
end)
# @!attribute [r] question_count
# Gets the number of questions in this assessment.
# @return [Integer]
calculated :question_count, (lambda do
Course::QuestionAssessment.unscope(:order).
select('coalesce(count(DISTINCT cqa.question_id), 0)').
joins('INNER JOIN course_question_assessments cqa ON cqa.assessment_id = course_assessments.id')
end)
# @!method self.ordered_by_date_and_title
# Orders the assessments by the starting date and title.
scope :ordered_by_date_and_title, (lambda do
joins(:lesson_plan_item).
merge(Course::LessonPlan::Item.ordered_by_date_and_title)
end)
# @!method with_submissions_by(creator)
# Includes the submissions by the provided user.
# @param [User] user The user to preload submissions for.
scope :with_submissions_by, (lambda do |user|
submissions = Course::Assessment::Submission.by_user(user).
where(assessment: distinct(false).pluck(:id)).ordered_by_date
all.to_a.tap do |result|
preloader = ActiveRecord::Associations::Preloader.new(records: result,
associations: :submissions,
scope: submissions)
preloader.call
end
end)
# Used by the with_actable_types scope in Course::LessonPlan::Item.
# Edit this to remove items for showing in the lesson plan.
#
# Here, actable_data contains the list of tab IDs to be removed.
scope :ids_showable_in_lesson_plan, (lambda do |actable_data|
# joining { lesson_plan_item }.
# where.not(tab_id: actable_data).
# selecting { lesson_plan_item.id }
unscoped.
joins(:lesson_plan_item).
where.not(tab_id: actable_data).
select(Course::LessonPlan::Item.arel_table[:id])
end)
scope :with_default_reference_time, (lambda do
joins(lesson_plan_item: :default_reference_time)
end)
delegate :source, :source=, to: :duplication_traceable, allow_nil: true
def self.use_relative_model_naming?
true
end
def to_partial_path
'course/assessment/assessments/assessment'
end
# Update assessment mode from params.
#
# @param [Hash] params Params with autograded mode from user.
def update_mode(params)
target_mode = params[:autograded]
return if target_mode == autograded || !allow_mode_switching?
case target_mode
when true
self.autograded = true
self.session_password = nil
self.view_password = nil
self.delayed_grade_publication = false
when false # Ignore the case when the params is empty.
self.autograded = false
self.skippable = false
end
end
# Update assessment randomization from params
#
# @param [Hash] Params with randomization boolean from user
def update_randomization(params)
self.randomization = params[:randomization] ? :prepared : nil
end
# Whether the assessment allows mode switching.
# Allow mode switching if:
# - The assessment don't have any submissions.
# - Switching from autograded mode to manually graded mode.
def allow_mode_switching?
submissions.count == 0 || autograded?
end
# @override ConditionalInstanceMethods#permitted_for!
def permitted_for!(_course_user)
end
# @override ConditionalInstanceMethods#precluded_for!
def precluded_for!(_course_user)
end
# @override ConditionalInstanceMethods#satisfiable?
def satisfiable?
published?
end
# The password to prevent from viewing the assessment.
def view_password_protected?
view_password.present?
end
# The password to prevent attempting submission from multiple sessions.
def session_password_protected?
session_password.present?
end
def files_downloadable?
questions.any?(&:files_downloadable?)
end
def csv_downloadable?
questions.any?(&:csv_downloadable?)
end
def initialize_duplicate(duplicator, other)
copy_attributes(other, duplicator)
target_tab = initialize_duplicate_tab(duplicator, other)
self.folder = duplicator.duplicate(other.folder)
folder.parent = target_tab.category.folder
self.question_assessments = duplicator.duplicate(other.question_assessments)
initialize_duplicate_conditions(duplicator, other)
self.monitor = duplicator.duplicate(other.monitor)
set_duplication_flag
end
def include_in_consolidated_email?(event)
email_enabled = course.email_enabled(:assessments, event, tab.category.id)
unless email_enabled # TO REMOVE - Monitoring for duplicate opening emails #4531
logger.debug(message: 'Duplicate emails debugging', course: course, assessment_id: id,
lesson_plan: lesson_plan_item, tab: tab, category_id: tab&.category&.id)
return false
end
email_enabled.regular || email_enabled.phantom
end
def graded_test_case_types
[].tap do |result|
result.push('public_test') if use_public
result.push('private_test') if use_private
result.push('evaluation_test') if use_evaluation
end
end
private
# Parents the assessment under its duplicated parent tab, if it exists.
#
# @return [Course::Assessment::Tab] The duplicated assessment's tab
def initialize_duplicate_tab(duplicator, other)
if duplicator.duplicated?(other.tab)
target_tab = duplicator.duplicate(other.tab)
else
target_category = duplicator.options[:destination_course].assessment_categories.first
target_tab = target_category.tabs.first
end
self.tab = target_tab
end
# Set up conditions that depend on this assessment and conditions that this assessment depends on.
def initialize_duplicate_conditions(duplicator, other)
duplicate_conditions(duplicator, other)
assessment_conditions << other.assessment_conditions.
select { |condition| duplicator.duplicated?(condition.conditional) }.
map { |condition| duplicator.duplicate(condition) }
end
# Sets the course of the lesson plan item to be the same as the one for the assessment.
def propagate_course
lesson_plan_item.course = tab.category.course
end
def assign_folder_attributes
# Folder attributes are handled during duplication by folder duplication code
return if duplicating?
folder.assign_attributes(name: title, course: course, parent: tab.category.folder,
start_at: start_at)
end
def set_defaults
self.published = false
self.autograded ||= false
end
def tab_in_same_course
return unless tab_id_changed?
errors.add(:tab, :not_in_same_course) unless tab.category.course == course
end
def selected_test_type_for_grading
errors.add(:no_test_type_chosen) unless use_public || use_private || use_evaluation
end
# Check for changes to graded test case booleans for autograded assessments.
def regrade_programming_answers?
(previous_changes.keys & ['use_private', 'use_public', 'use_evaluation']).any? && autograded?
end
# Re-grades all submissions to programming_questions after any change to
# test case booleans has been committed
def grade_with_new_test_cases
return unless regrade_programming_answers?
# Regrade all published submissions' programming answers and update exp points awarded
submissions.select(&:published?).each do |submission|
submission.resubmit_programming!
submission.save!
submission.mark!
submission.publish!
end
end
# Somehow autosaving more than 1 level of association doesn't work in Rails 5.2
def save_tab
tab.category.save if tab&.category && !tab.category.persisted?
tab.save if tab && !tab.persisted?
end
end