
With this commit, we refactor the overall score handling of CodeOcean. Previously, "Score" and "Submit" were two distinct actions, requiring users to confirm the LTI transmission of their score (after assessing their submission). This yielded many questions and was unnecessary, since LTI parameters are no longer expiring after each use. Therefore, we can now transmit the current grade on each score run with the very same LTI parameters. As a consequence, the LTI consumer gets a more detailed history of the scores, enabling further analytical insights. For users, the previous "Submit" button got replaced with a notification that is shown as soon as the full score got reached. Then, learners can decide to "finalize" their work on the given exercise, which will initiate a redirect to a follow-up action (as defined in the RedirectBehavior). This RedirectBehavior has also been unified and simplified for better readability. As part of this refactoring, we rephrased the notifications and UX workflow of a) the LTI transmission, b) the finalization of an exercise (measured by reaching the full score) and c) the deadline handling (on time, within grace period, too late). Those information are now separately shown, potentially resulting in multiple notifications. As a side effect, they are much better maintainable, and the LTI transmission is more decoupled from this notification handling.
619 lines
24 KiB
Ruby
619 lines
24 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'nokogiri'
|
|
|
|
class Exercise < ApplicationRecord
|
|
include Context
|
|
include Creation
|
|
include DefaultValues
|
|
include TimeHelper
|
|
|
|
after_initialize :generate_token
|
|
after_initialize :set_default_values
|
|
|
|
belongs_to :execution_environment, optional: true
|
|
has_many :submissions
|
|
|
|
has_many :anomaly_notifications, as: :contributor, dependent: :destroy
|
|
has_and_belongs_to_many :proxy_exercises
|
|
has_many :user_proxy_exercise_exercises
|
|
has_many :exercise_collection_items, dependent: :delete_all
|
|
has_many :exercise_collections, through: :exercise_collection_items, inverse_of: :exercises
|
|
has_many :user_exercise_interventions
|
|
has_many :interventions, through: :user_exercise_interventions
|
|
has_many :exercise_tags
|
|
has_many :tags, through: :exercise_tags
|
|
accepts_nested_attributes_for :exercise_tags
|
|
has_many :user_exercise_feedbacks
|
|
has_many :pair_programming_exercise_feedbacks
|
|
has_many :exercise_tips
|
|
has_many :tips, through: :exercise_tips
|
|
|
|
has_many :external_users, source: :contributor, source_type: 'ExternalUser', through: :submissions
|
|
has_many :internal_users, source: :contributor, source_type: 'InternalUser', through: :submissions
|
|
has_many :programming_groups
|
|
has_many :pair_programming_waiting_users
|
|
has_many :request_for_comments
|
|
|
|
scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') }
|
|
scope :with_programming_groups, -> { where('id IN (SELECT exercise_id FROM programming_groups)') }
|
|
|
|
validate :valid_main_file?
|
|
validate :valid_submission_deadlines?
|
|
validates :description, presence: true
|
|
validates :execution_environment, presence: true, if: -> { !unpublished? }
|
|
validates :public, inclusion: [true, false]
|
|
validates :unpublished, inclusion: [true, false]
|
|
validates :title, presence: true
|
|
validates :token, presence: true, uniqueness: true
|
|
validates :uuid, uniqueness: {if: -> { uuid.present? }}
|
|
|
|
@working_time_statistics = nil
|
|
attr_reader :working_time_statistics
|
|
|
|
MAX_GROUP_EXERCISE_FEEDBACKS = 20
|
|
|
|
def average_percentage
|
|
if average_score && (maximum_score.to_d != BigDecimal('0.0')) && submissions.exists?(cause: 'submit')
|
|
(average_score / maximum_score * 100).round(2)
|
|
else
|
|
0
|
|
end
|
|
end
|
|
|
|
def finishers_percentage
|
|
if contributors.empty?
|
|
0
|
|
else
|
|
(100.0 / contributors.size * finishers_count).round(2)
|
|
end
|
|
end
|
|
|
|
def average_score
|
|
Submission.from(
|
|
submissions.group(:contributor_id, :contributor_type)
|
|
.select('MAX(score) as max_score')
|
|
).average(:max_score).to_f
|
|
end
|
|
|
|
def average_number_of_submissions
|
|
contributors.empty? ? 0 : submissions.count / contributors.size.to_f
|
|
end
|
|
|
|
def contributors
|
|
@contributors ||= internal_users.distinct + external_users.distinct + programming_groups.distinct
|
|
end
|
|
|
|
def time_maximum_score(contributor)
|
|
submissions
|
|
.where(contributor:, cause: %w[submit assess])
|
|
.where.not(score: nil)
|
|
.order(score: :desc, created_at: :asc)
|
|
.first&.created_at || Time.zone.at(0)
|
|
end
|
|
|
|
def user_working_time_query
|
|
"
|
|
SELECT contributor_id,
|
|
contributor_type,
|
|
SUM(working_time_new) AS working_time,
|
|
MAX(score) AS score
|
|
FROM
|
|
(SELECT contributor_id,
|
|
contributor_type,
|
|
score,
|
|
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_new
|
|
FROM
|
|
(SELECT contributor_id,
|
|
contributor_type,
|
|
score,
|
|
id,
|
|
(created_at - lag(created_at) over (PARTITION BY contributor_id, exercise_id
|
|
ORDER BY created_at)) AS working_time
|
|
FROM submissions
|
|
WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])}) AS foo) AS bar
|
|
GROUP BY contributor_id, contributor_type
|
|
"
|
|
end
|
|
|
|
def study_group_working_time_query(exercise_id, study_group_id, additional_filter)
|
|
"
|
|
WITH working_time_between_submissions AS (
|
|
SELECT submissions.contributor_id,
|
|
submissions.contributor_type,
|
|
score,
|
|
created_at,
|
|
(created_at - lag(created_at) over (PARTITION BY submissions.contributor_type, submissions.contributor_id, exercise_id
|
|
ORDER BY created_at)) AS working_time
|
|
FROM submissions
|
|
WHERE #{self.class.sanitize_sql(['exercise_id = ? and study_group_id = ?', exercise_id, study_group_id])} #{self.class.sanitize_sql(additional_filter)}),
|
|
working_time_with_deltas_ignored AS (
|
|
SELECT contributor_id,
|
|
contributor_type,
|
|
score,
|
|
sum(CASE WHEN score IS NOT NULL THEN 1 ELSE 0 END)
|
|
over (ORDER BY contributor_type, contributor_id, created_at ASC) AS change_in_score,
|
|
created_at,
|
|
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_filtered
|
|
FROM working_time_between_submissions
|
|
),
|
|
working_times_with_score_expanded AS (
|
|
SELECT contributor_id,
|
|
contributor_type,
|
|
created_at,
|
|
working_time_filtered,
|
|
first_value(score)
|
|
over (PARTITION BY contributor_type, contributor_id, change_in_score ORDER BY created_at ASC) AS corrected_score
|
|
FROM working_time_with_deltas_ignored
|
|
),
|
|
working_times_with_duplicated_last_row_per_score AS (
|
|
SELECT *
|
|
FROM working_times_with_score_expanded
|
|
UNION ALL
|
|
-- Duplicate last row per user and score and make it unique by setting another created_at timestamp.
|
|
-- In addition, the working time is set to zero in order to prevent getting a wrong time.
|
|
-- This duplication is needed, as we will shift the scores and working times by one and need to ensure not to loose any information.
|
|
SELECT DISTINCT ON (contributor_type, contributor_id, corrected_score) contributor_id,
|
|
contributor_type,
|
|
created_at + INTERVAL '1us',
|
|
'00:00:00' as working_time_filtered,
|
|
corrected_score
|
|
FROM working_times_with_score_expanded
|
|
),
|
|
working_times_with_score_not_null_and_shifted AS (
|
|
SELECT contributor_id,
|
|
contributor_type,
|
|
coalesce(lag(corrected_score) over (PARTITION BY contributor_type, contributor_id ORDER BY created_at ASC),
|
|
0) AS shifted_score,
|
|
created_at,
|
|
working_time_filtered
|
|
FROM working_times_with_duplicated_last_row_per_score
|
|
),
|
|
working_times_to_be_sorted AS (
|
|
SELECT contributor_id,
|
|
contributor_type,
|
|
shifted_score AS score,
|
|
MIN(created_at) AS start_time,
|
|
SUM(working_time_filtered) AS working_time_per_score,
|
|
SUM(SUM(working_time_filtered)) over (PARTITION BY contributor_type, contributor_id) AS total_working_time
|
|
FROM working_times_with_score_not_null_and_shifted
|
|
GROUP BY contributor_id, contributor_type, score
|
|
),
|
|
working_times_with_index AS (
|
|
SELECT (dense_rank() over (ORDER BY total_working_time, contributor_type, contributor_id ASC) - 1) AS index,
|
|
contributor_id,
|
|
contributor_type,
|
|
score,
|
|
start_time,
|
|
working_time_per_score,
|
|
total_working_time
|
|
FROM working_times_to_be_sorted)
|
|
SELECT index,
|
|
contributor_id,
|
|
contributor_type,
|
|
name,
|
|
score,
|
|
start_time,
|
|
working_time_per_score,
|
|
total_working_time
|
|
FROM working_times_with_index
|
|
JOIN external_users ON contributor_type = 'ExternalUser' AND contributor_id = external_users.id
|
|
UNION ALL
|
|
SELECT index,
|
|
contributor_id,
|
|
contributor_type,
|
|
name,
|
|
score,
|
|
start_time,
|
|
working_time_per_score,
|
|
total_working_time
|
|
FROM working_times_with_index
|
|
JOIN internal_users ON contributor_type = 'InternalUser' AND contributor_id = internal_users.id
|
|
UNION ALL
|
|
SELECT index,
|
|
contributor_id,
|
|
contributor_type,
|
|
concat('PG ', programming_groups.id::varchar) AS name,
|
|
score,
|
|
start_time,
|
|
working_time_per_score,
|
|
total_working_time
|
|
FROM working_times_with_index
|
|
JOIN programming_groups ON contributor_type = 'ProgrammingGroup' AND contributor_id = programming_groups.id
|
|
ORDER BY index, score ASC;
|
|
"
|
|
end
|
|
|
|
def teacher_defined_assessment?
|
|
files.any?(&:teacher_defined_assessment?)
|
|
end
|
|
|
|
def get_working_times_for_study_group(study_group_id, user = nil)
|
|
user_progress = []
|
|
additional_user_data = []
|
|
max_bucket = 100
|
|
maximum_score = self.maximum_score
|
|
|
|
additional_filter = if user.blank?
|
|
''
|
|
else
|
|
"AND contributor_id = #{user.id} AND contributor_type = '#{user.class.name}'"
|
|
end
|
|
|
|
results = self.class.connection.exec_query(study_group_working_time_query(id, study_group_id,
|
|
additional_filter)).each do |tuple|
|
|
bucket = if maximum_score > 0.0 && tuple['score'] <= maximum_score
|
|
(tuple['score'] / maximum_score * max_bucket).round
|
|
else
|
|
max_bucket # maximum_score / maximum_score will always be 1
|
|
end
|
|
|
|
user_progress[bucket] ||= []
|
|
additional_user_data[bucket] ||= []
|
|
additional_user_data[max_bucket + 1] ||= []
|
|
|
|
user_progress[bucket][tuple['index']] = format_time_difference(tuple['working_time_per_score'])
|
|
additional_user_data[bucket][tuple['index']] = {start_time: tuple['start_time'], score: tuple['score']}
|
|
additional_user_data[max_bucket + 1][tuple['index']] =
|
|
{id: tuple['contributor_id'], type: tuple['contributor_type'], name: ERB::Util.html_escape(tuple['name'])}
|
|
end
|
|
|
|
if results.size.positive?
|
|
first_index = results[0]['index']
|
|
last_index = results[results.size - 1]['index']
|
|
buckets = last_index - first_index
|
|
user_progress.each do |timings_array|
|
|
timings_array[buckets] = nil if timings_array.present? && timings_array.length != buckets + 1
|
|
end
|
|
end
|
|
|
|
{user_progress:, additional_user_data:}
|
|
end
|
|
|
|
def get_quantiles(quantiles)
|
|
result = self.class.connection.exec_query("
|
|
WITH working_time AS
|
|
(
|
|
SELECT contributor_id,
|
|
id,
|
|
exercise_id,
|
|
Max(score) AS max_score,
|
|
(created_at - Lag(created_at) OVER (partition BY contributor_id, exercise_id ORDER BY created_at)) AS working_time
|
|
FROM submissions
|
|
WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])}
|
|
AND contributor_type IN ('ExternalUser', 'ProgrammingGroup')
|
|
GROUP BY contributor_id,
|
|
id,
|
|
exercise_id), max_points AS
|
|
(
|
|
SELECT context_id AS ex_id,
|
|
Sum(weight) AS max_points
|
|
FROM files
|
|
WHERE context_type = 'Exercise'
|
|
AND #{self.class.sanitize_sql(['context_id = ?', id])}
|
|
AND role IN ('teacher_defined_test', 'teacher_defined_linter')
|
|
GROUP BY context_id),
|
|
-- filter for rows containing max points
|
|
time_max_score AS
|
|
(
|
|
SELECT *
|
|
FROM working_time W1,
|
|
max_points MS
|
|
WHERE w1.exercise_id = ex_id
|
|
AND w1.max_score = ms.max_points),
|
|
-- find row containing the first time max points
|
|
first_time_max_score AS
|
|
(
|
|
SELECT id,
|
|
contributor_id,
|
|
exercise_id,
|
|
max_score,
|
|
working_time,
|
|
rn
|
|
FROM (
|
|
SELECT id,
|
|
contributor_id,
|
|
exercise_id,
|
|
max_score,
|
|
working_time,
|
|
Row_number() OVER(partition BY contributor_id, exercise_id ORDER BY id ASC) AS rn
|
|
FROM time_max_score) T
|
|
WHERE rn = 1), times_until_max_points AS
|
|
(
|
|
SELECT w.id,
|
|
w.contributor_id,
|
|
w.exercise_id,
|
|
w.max_score,
|
|
w.working_time,
|
|
m.id AS reachedmax_at
|
|
FROM working_time W,
|
|
first_time_max_score M
|
|
WHERE w.contributor_id = m.contributor_id
|
|
AND w.exercise_id = m.exercise_id
|
|
AND w.id <= m.id),
|
|
-- if user never makes it to max points, take all times
|
|
all_working_times_until_max AS (
|
|
(
|
|
SELECT id,
|
|
contributor_id,
|
|
exercise_id,
|
|
max_score,
|
|
working_time
|
|
FROM times_until_max_points)
|
|
UNION ALL
|
|
(
|
|
SELECT id,
|
|
contributor_id,
|
|
exercise_id,
|
|
max_score,
|
|
working_time
|
|
FROM working_time W1
|
|
WHERE NOT EXISTS
|
|
(
|
|
SELECT 1
|
|
FROM first_time_max_score F
|
|
WHERE f.contributor_id = w1.contributor_id
|
|
AND f.exercise_id = w1.exercise_id))), filtered_times_until_max AS
|
|
(
|
|
SELECT contributor_id,
|
|
exercise_id,
|
|
max_score,
|
|
CASE
|
|
WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0'
|
|
ELSE working_time
|
|
END AS working_time_new
|
|
FROM all_working_times_until_max ), result AS
|
|
(
|
|
SELECT e.external_id AS external_contributor_id,
|
|
f.contributor_id,
|
|
exercise_id,
|
|
Max(max_score) AS max_score,
|
|
Sum(working_time_new) AS working_time
|
|
FROM filtered_times_until_max f,
|
|
external_users e
|
|
WHERE f.contributor_id = e.id
|
|
GROUP BY e.external_id,
|
|
f.contributor_id,
|
|
exercise_id )
|
|
SELECT unnest(percentile_cont(#{self.class.sanitize_sql(['array[?]', quantiles])}) within GROUP (ORDER BY working_time))
|
|
FROM result
|
|
")
|
|
if result.count.positive?
|
|
quantiles.each_with_index.map {|_q, i| parse_duration(result[i]['unnest']).to_f }
|
|
else
|
|
quantiles.map {|_q| 0 }
|
|
end
|
|
end
|
|
|
|
def retrieve_working_time_statistics
|
|
@working_time_statistics = {'InternalUser' => {}, 'ExternalUser' => {}, 'ProgrammingGroup' => {}}
|
|
self.class.connection.exec_query(user_working_time_query).each do |tuple|
|
|
tuple = tuple.merge('working_time' => format_time_difference(tuple['working_time']))
|
|
@working_time_statistics[tuple['contributor_type']][tuple['contributor_id'].to_i] = tuple
|
|
end
|
|
end
|
|
|
|
def average_working_time
|
|
result = self.class.connection.exec_query("
|
|
SELECT avg(working_time) as average_time
|
|
FROM
|
|
(#{self.class.sanitize_sql(user_working_time_query)}) AS baz;
|
|
").first['average_time']
|
|
format_time_difference(result)
|
|
end
|
|
|
|
def average_working_time_for(user)
|
|
retrieve_working_time_statistics if @working_time_statistics.nil?
|
|
@working_time_statistics[user.class.name][user.id]['working_time']
|
|
end
|
|
|
|
def accumulated_working_time_for_only(contributor)
|
|
contributor_type = contributor.class.name
|
|
begin
|
|
result = self.class.connection.exec_query("
|
|
WITH WORKING_TIME AS
|
|
(SELECT contributor_id,
|
|
id,
|
|
exercise_id,
|
|
max(score) AS max_score,
|
|
(created_at - lag(created_at) OVER (PARTITION BY contributor_id, exercise_id
|
|
ORDER BY created_at)) AS working_time
|
|
FROM submissions
|
|
WHERE exercise_id = #{id} AND contributor_id = #{contributor.id} AND contributor_type = '#{contributor_type}'
|
|
GROUP BY contributor_id, id, exercise_id),
|
|
MAX_POINTS AS
|
|
(SELECT context_id AS ex_id, sum(weight) AS max_points FROM files WHERE context_type = 'Exercise' AND context_id = #{id} AND role IN ('teacher_defined_test', 'teacher_defined_linter') GROUP BY context_id),
|
|
|
|
-- filter for rows containing max points
|
|
TIME_MAX_SCORE AS
|
|
(SELECT *
|
|
FROM WORKING_TIME W1, MAX_POINTS MS
|
|
WHERE W1.exercise_id = ex_id AND W1.max_score = MS.max_points),
|
|
|
|
-- find row containing the first time max points
|
|
FIRST_TIME_MAX_SCORE AS
|
|
( SELECT id,contributor_id,exercise_id,max_score,working_time, rn
|
|
FROM (
|
|
SELECT id,contributor_id,exercise_id,max_score,working_time,
|
|
ROW_NUMBER() OVER(PARTITION BY contributor_id, exercise_id ORDER BY id ASC) AS rn
|
|
FROM TIME_MAX_SCORE) T
|
|
WHERE rn = 1),
|
|
|
|
TIMES_UNTIL_MAX_POINTS AS (
|
|
SELECT W.id, W.contributor_id, W.exercise_id, W.max_score, W.working_time, M.id AS reachedmax_at
|
|
FROM WORKING_TIME W, FIRST_TIME_MAX_SCORE M
|
|
WHERE W.contributor_id = M.contributor_id AND W.exercise_id = M.exercise_id AND W.id <= M.id),
|
|
|
|
-- if contributor never makes it to max points, take all times
|
|
ALL_WORKING_TIMES_UNTIL_MAX AS
|
|
((SELECT id, contributor_id, exercise_id, max_score, working_time FROM TIMES_UNTIL_MAX_POINTS)
|
|
UNION ALL
|
|
(SELECT id, contributor_id, exercise_id, max_score, working_time FROM WORKING_TIME W1
|
|
WHERE NOT EXISTS (SELECT 1 FROM FIRST_TIME_MAX_SCORE F WHERE F.contributor_id = W1.contributor_id AND F.exercise_id = W1.exercise_id))),
|
|
|
|
FILTERED_TIMES_UNTIL_MAX AS
|
|
(
|
|
SELECT contributor_id,exercise_id, max_score, CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_new
|
|
FROM ALL_WORKING_TIMES_UNTIL_MAX
|
|
)
|
|
SELECT e.external_id AS external_contributor_id, f.contributor_id, exercise_id, MAX(max_score) AS max_score, sum(working_time_new) AS working_time
|
|
FROM FILTERED_TIMES_UNTIL_MAX f, EXTERNAL_USERS e
|
|
WHERE f.contributor_id = e.id GROUP BY e.external_id, f.contributor_id, exercise_id
|
|
")
|
|
parse_duration(result.first['working_time']).to_f
|
|
rescue StandardError
|
|
0
|
|
end
|
|
end
|
|
|
|
def duplicate(attributes = {})
|
|
exercise = dup
|
|
exercise.attributes = attributes
|
|
exercise_tags.each {|et| exercise.exercise_tags << et.dup }
|
|
files.each {|file| exercise.files << file.dup }
|
|
exercise
|
|
end
|
|
|
|
def determine_file_role_from_proforma_file(task_node, file_node)
|
|
file_id = file_node.xpath('@id')
|
|
file_class = file_node.xpath('@class').first.value
|
|
comment = file_node.xpath('@comment').first.value
|
|
is_referenced_by_test = task_node.xpath("p:tests/p:test/p:filerefs/p:fileref[@id=#{file_id}]")
|
|
is_referenced_by_model_solution = task_node.xpath("p:model-solutions/p:model-solution/p:filerefs/p:fileref[@id=#{file_id}]")
|
|
if is_referenced_by_test && (file_class == 'internal')
|
|
return 'teacher_defined_test'
|
|
elsif is_referenced_by_model_solution && (file_class == 'internal')
|
|
return 'reference_implementation'
|
|
elsif (file_class == 'template') && (comment == 'main')
|
|
return 'main_file'
|
|
end
|
|
|
|
'regular_file'
|
|
end
|
|
|
|
def from_proforma_xml(xml_string)
|
|
# how to extract the proforma functionality into a different module in rails?
|
|
xml = Nokogiri::XML(xml_string)
|
|
xml.collect_namespaces
|
|
task_node = xml.xpath('/root/p:task')
|
|
description = task_node.xpath('p:description/text()')[0].content
|
|
self.attributes = {
|
|
title: task_node.xpath('p:meta-data/p:title/text()')[0].content,
|
|
description:,
|
|
instructions: description,
|
|
}
|
|
task_node.xpath('p:files/p:file').all? do |file|
|
|
file_name_split = file.xpath('@filename').first.value.split('.')
|
|
file_class = file.xpath('@class').first.value
|
|
role = determine_file_role_from_proforma_file(task_node, file)
|
|
feedback_message_nodes = task_node.xpath('p:tests/p:test/p:test-configuration/c:feedback-message/text()')
|
|
files.build({
|
|
name: file_name_split.first,
|
|
content: file.xpath('text()').first.content,
|
|
read_only: false,
|
|
hidden: file_class == 'internal',
|
|
role:,
|
|
feedback_message: role == 'teacher_defined_test' ? feedback_message_nodes.first.content : nil,
|
|
file_type: FileType.find_by(
|
|
file_extension: ".#{file_name_split.second}"
|
|
),
|
|
})
|
|
end
|
|
self.execution_environment_id = 1
|
|
end
|
|
|
|
def generate_token
|
|
self.token ||= SecureRandom.hex(4)
|
|
end
|
|
private :generate_token
|
|
|
|
def maximum_score(contributor = nil)
|
|
if contributor
|
|
submissions
|
|
.where(contributor:, cause: %w[submit assess])
|
|
.where.not(score: nil)
|
|
.order(score: :desc)
|
|
.first&.score || 0
|
|
else
|
|
@maximum_score ||= if files.loaded?
|
|
files.filter(&:teacher_defined_assessment?).pluck(:weight).sum
|
|
else
|
|
files.teacher_defined_assessments.sum(:weight)
|
|
end
|
|
end
|
|
end
|
|
|
|
def final_submission(contributor)
|
|
submissions.final.order(created_at: :desc).find_by(contributor:)
|
|
end
|
|
|
|
def solved_by?(contributor)
|
|
maximum_score(contributor).to_i == maximum_score.to_i
|
|
end
|
|
|
|
def finishers_count
|
|
Submission.from(submissions.where(score: maximum_score, cause: %w[submit assess remoteSubmit remoteAssess]).group(:contributor_id, :contributor_type).select(:contributor_id, :contributor_type), 'submissions').count
|
|
end
|
|
|
|
def set_default_values
|
|
set_default_values_if_present(public: false)
|
|
end
|
|
private :set_default_values
|
|
|
|
def to_s
|
|
title
|
|
end
|
|
|
|
def valid_main_file?
|
|
if files.count(&:main_file?) > 1
|
|
errors.add(:files,
|
|
I18n.t('activerecord.errors.models.exercise.at_most_one_main_file'))
|
|
end
|
|
end
|
|
private :valid_main_file?
|
|
|
|
def valid_submission_deadlines?
|
|
return true unless submission_deadline.present? || late_submission_deadline.present?
|
|
|
|
valid = true
|
|
if late_submission_deadline.present? && submission_deadline.blank?
|
|
errors.add(:late_submission_deadline,
|
|
I18n.t('activerecord.errors.models.exercise.late_submission_deadline_not_alone'))
|
|
valid = false
|
|
end
|
|
|
|
if submission_deadline.present? && late_submission_deadline.present? &&
|
|
late_submission_deadline < submission_deadline
|
|
errors.add(:late_submission_deadline,
|
|
I18n.t('activerecord.errors.models.exercise.late_submission_deadline_not_before_submission_deadline'))
|
|
valid = false
|
|
end
|
|
|
|
valid
|
|
end
|
|
private :valid_submission_deadlines?
|
|
|
|
def needs_more_feedback?
|
|
user_exercise_feedbacks.size <= MAX_GROUP_EXERCISE_FEEDBACKS
|
|
end
|
|
|
|
def last_submission_per_contributor
|
|
Submission.joins("JOIN (
|
|
SELECT
|
|
contributor_id,
|
|
contributor_type,
|
|
first_value(id) OVER (PARTITION BY contributor_id, contributor_type ORDER BY created_at DESC) AS fv
|
|
FROM submissions
|
|
WHERE #{Submission.sanitize_sql(['exercise_id = ?', id])}
|
|
) AS t ON t.fv = submissions.id").distinct
|
|
end
|
|
|
|
def self.ransackable_attributes(_auth_object = nil)
|
|
%w[title id internal_title]
|
|
end
|
|
|
|
def self.ransackable_associations(_auth_object = nil)
|
|
%w[execution_environment]
|
|
end
|
|
end
|