Add submission deadline to exercises and allow teachers to view their submissions

This commit is contained in:
Sebastian Serth
2020-05-07 17:45:53 +02:00
parent 4c571c4fb2
commit 914eeb6035
21 changed files with 456 additions and 245 deletions

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true
require 'nokogiri'
require File.expand_path('../../../lib/active_model/validations/boolean_presence_validator', __FILE__)
require File.expand_path('../../lib/active_model/validations/boolean_presence_validator', __dir__)
class Exercise < ApplicationRecord
include Context
@ -25,11 +27,12 @@ class Exercise < ApplicationRecord
has_many :external_users, source: :user, source_type: 'ExternalUser', through: :submissions
has_many :internal_users, source: :user, source_type: 'InternalUser', through: :submissions
alias_method :users, :external_users
alias users external_users
scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') }
validate :valid_main_file?
validate :valid_submission_deadlines?
validates :description, presence: true
validates :execution_environment, presence: true, if: -> { !unpublished? }
validates :public, boolean_presence: true
@ -44,7 +47,7 @@ class Exercise < ApplicationRecord
MAX_EXERCISE_FEEDBACKS = 20
def average_percentage
if average_score and maximum_score != 0.0 and submissions.exists?(cause: 'submit')
if average_score && (maximum_score != 0.0) && submissions.exists?(cause: 'submit')
(average_score / maximum_score * 100).round(2)
else
0
@ -68,11 +71,13 @@ class Exercise < ApplicationRecord
def average_number_of_submissions
user_count = internal_users.distinct.count + external_users.distinct.count
return user_count == 0 ? 0 : submissions.count() / user_count.to_f()
user_count == 0 ? 0 : submissions.count / user_count.to_f
end
def time_maximum_score(user)
submissions.where(user: user).where("cause IN ('submit','assess')").where("score IS NOT NULL").order("score DESC, created_at ASC").first.created_at rescue Time.zone.at(0)
submissions.where(user: user).where("cause IN ('submit','assess')").where('score IS NOT NULL').order('score DESC, created_at ASC').first.created_at
rescue StandardError
Time.zone.at(0)
end
def user_working_time_query
@ -100,7 +105,7 @@ class Exercise < ApplicationRecord
end
def study_group_working_time_query(exercise_id, study_group_id, additional_filter)
"""
''"
WITH working_time_between_submissions AS (
SELECT submissions.user_id,
submissions.user_type,
@ -193,7 +198,7 @@ class Exercise < ApplicationRecord
FROM working_times_with_index
JOIN internal_users ON user_type = 'InternalUser' AND user_id = internal_users.id
ORDER BY index, score ASC;
"""
"''
end
def get_working_times_for_study_group(study_group_id, user = nil)
@ -202,36 +207,34 @@ class Exercise < ApplicationRecord
max_bucket = 100
maximum_score = self.maximum_score
if user.blank?
additional_filter = ''
else
additional_filter = "AND user_id = #{user.id} AND user_type = '#{user.class.name}'"
end
additional_filter = if user.blank?
''
else
"AND user_id = #{user.id} AND user_type = '#{user.class.name}'"
end
results = self.class.connection.execute(study_group_working_time_query(id, study_group_id, additional_filter)).each do |tuple|
if maximum_score > 0.0 && tuple['score'] <= maximum_score
bucket = (tuple['score'] / maximum_score * max_bucket).round
else
bucket = max_bucket # maximum_score / maximum_score will always be 1
end
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']] = tuple["working_time_per_score"]
additional_user_data[bucket][tuple['index']] = {start_time: tuple["start_time"], score: tuple["score"]}
user_progress[bucket][tuple['index']] = 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['user_id'], type: tuple['user_type'], name: tuple['name']}
end
if results.ntuples > 0
first_index = results[0]['index']
last_index = results[results.ntuples-1]['index']
last_index = results[results.ntuples - 1]['index']
buckets = last_index - first_index
user_progress.each do |timings_array|
if timings_array.present? && timings_array.length != buckets + 1
timings_array[buckets] = nil
end
timings_array[buckets] = nil if timings_array.present? && timings_array.length != buckets + 1
end
end
@ -239,8 +242,8 @@ class Exercise < ApplicationRecord
end
def get_quantiles(quantiles)
quantiles_str = "[" + quantiles.join(",") + "]"
result = self.class.connection.execute("""
quantiles_str = '[' + quantiles.join(',') + ']'
result = self.class.connection.execute(''"
WITH working_time AS
(
SELECT user_id,
@ -346,13 +349,12 @@ class Exercise < ApplicationRecord
exercise_id )
SELECT unnest(percentile_cont(array#{quantiles_str}) within GROUP (ORDER BY working_time))
FROM result
""")
"'')
if result.count > 0
quantiles.each_with_index.map{|q,i| Time.parse(result[i]["unnest"]).seconds_since_midnight}
quantiles.each_with_index.map { |_q, i| Time.parse(result[i]['unnest']).seconds_since_midnight }
else
quantiles.map{|q| 0}
quantiles.map { |_q| 0 }
end
end
def retrieve_working_time_statistics
@ -363,72 +365,74 @@ class Exercise < ApplicationRecord
end
def average_working_time
self.class.connection.execute("""
self.class.connection.execute(''"
SELECT avg(working_time) as average_time
FROM
(#{user_working_time_query}) AS baz;
""").first['average_time']
"'').first['average_time']
end
def average_working_time_for(user_id)
if @working_time_statistics == nil
retrieve_working_time_statistics()
end
@working_time_statistics[user_id]["working_time"]
retrieve_working_time_statistics if @working_time_statistics.nil?
@working_time_statistics[user_id]['working_time']
end
def accumulated_working_time_for_only(user)
user_type = user.external_user? ? "ExternalUser" : "InternalUser"
Time.parse(self.class.connection.execute("""
WITH WORKING_TIME AS
(SELECT user_id,
id,
exercise_id,
max(score) AS max_score,
(created_at - lag(created_at) OVER (PARTITION BY user_id, exercise_id
ORDER BY created_at)) AS working_time
FROM submissions
WHERE exercise_id = #{id} AND user_id = #{user.id} AND user_type = '#{user_type}'
GROUP BY user_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 = 'teacher_defined_test' GROUP BY context_id),
user_type = user.external_user? ? 'ExternalUser' : 'InternalUser'
begin
Time.parse(self.class.connection.execute(''"
WITH WORKING_TIME AS
(SELECT user_id,
id,
exercise_id,
max(score) AS max_score,
(created_at - lag(created_at) OVER (PARTITION BY user_id, exercise_id
ORDER BY created_at)) AS working_time
FROM submissions
WHERE exercise_id = #{id} AND user_id = #{user.id} AND user_type = '#{user_type}'
GROUP BY user_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 = 'teacher_defined_test' 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),
-- 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,USER_id,exercise_id,max_score,working_time, rn
FROM (
SELECT id,USER_id,exercise_id,max_score,working_time,
ROW_NUMBER() OVER(PARTITION BY user_id, exercise_id ORDER BY id ASC) AS rn
FROM TIME_MAX_SCORE) T
WHERE rn = 1),
-- find row containing the first time max points
FIRST_TIME_MAX_SCORE AS
( SELECT id,USER_id,exercise_id,max_score,working_time, rn
FROM (
SELECT id,USER_id,exercise_id,max_score,working_time,
ROW_NUMBER() OVER(PARTITION BY user_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.user_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.user_id = M.user_id AND W.exercise_id = M.exercise_id AND W.id <= M.id),
TIMES_UNTIL_MAX_POINTS AS (
SELECT W.id, W.user_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.user_id = M.user_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, user_id, exercise_id, max_score, working_time FROM TIMES_UNTIL_MAX_POINTS)
UNION ALL
(SELECT id, user_id, exercise_id, max_score, working_time FROM WORKING_TIME W1
WHERE NOT EXISTS (SELECT 1 FROM FIRST_TIME_MAX_SCORE F WHERE F.user_id = W1.user_id AND F.exercise_id = W1.exercise_id))),
-- if user never makes it to max points, take all times
ALL_WORKING_TIMES_UNTIL_MAX AS
((SELECT id, user_id, exercise_id, max_score, working_time FROM TIMES_UNTIL_MAX_POINTS)
UNION ALL
(SELECT id, user_id, exercise_id, max_score, working_time FROM WORKING_TIME W1
WHERE NOT EXISTS (SELECT 1 FROM FIRST_TIME_MAX_SCORE F WHERE F.user_id = W1.user_id AND F.exercise_id = W1.exercise_id))),
FILTERED_TIMES_UNTIL_MAX AS
(
SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
FROM ALL_WORKING_TIMES_UNTIL_MAX
)
SELECT e.external_id AS external_user_id, f.user_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.user_id = e.id GROUP BY e.external_id, f.user_id, exercise_id
""").first["working_time"]).seconds_since_midnight rescue 0
FILTERED_TIMES_UNTIL_MAX AS
(
SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
FROM ALL_WORKING_TIMES_UNTIL_MAX
)
SELECT e.external_id AS external_user_id, f.user_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.user_id = e.id GROUP BY e.external_id, f.user_id, exercise_id
"'').first['working_time']).seconds_since_midnight
rescue StandardError
0
end
end
def duplicate(attributes = {})
@ -453,7 +457,8 @@ class Exercise < ApplicationRecord
return 'main_file'
elsif (file_class == 'internal') && (comment == 'main')
end
return 'regular_file'
'regular_file'
end
def from_proforma_xml(xml_string)
@ -467,23 +472,23 @@ class Exercise < ApplicationRecord
description: description,
instructions: description
}
task_node.xpath('p:files/p:file').all? { |file|
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()")
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: role,
feedback_message: (role == 'teacher_defined_test') ? feedback_message_nodes.first.content : nil,
file_type: FileType.where(
file_extension: ".#{file_name_split.second}"
).take
})
}
name: file_name_split.first,
content: file.xpath('text()').first.content,
read_only: false,
hidden: file_class == 'internal',
role: role,
feedback_message: role == 'teacher_defined_test' ? feedback_message_nodes.first.content : nil,
file_type: FileType.where(
file_extension: ".#{file_name_split.second}"
).take
})
end
self.execution_environment_id = 1
end
@ -495,7 +500,11 @@ class Exercise < ApplicationRecord
def maximum_score(user = nil)
if user
# FIXME: where(user: user) will not work here!
submissions.where(user: user).where("cause IN ('submit','assess')").where("score IS NOT NULL").order("score DESC").first.score || 0 rescue 0
begin
submissions.where(user: user).where("cause IN ('submit','assess')").where('score IS NOT NULL').order('score DESC').first.score || 0
rescue StandardError
0
end
else
files.teacher_defined_tests.sum(:weight)
end
@ -510,7 +519,7 @@ class Exercise < ApplicationRecord
end
def finishers
ExternalUser.joins(:submissions).where(submissions: {exercise_id: id, score: maximum_score, cause: %w(submit assess)}).distinct
ExternalUser.joins(:submissions).where(submissions: {exercise_id: id, score: maximum_score, cause: %w[submit assess]}).distinct
end
def set_default_values
@ -523,12 +532,22 @@ class Exercise < ApplicationRecord
end
def valid_main_file?
if files.main_files.count > 1
errors.add(:files, I18n.t('activerecord.errors.models.exercise.at_most_one_main_file'))
end
errors.add(:files, I18n.t('activerecord.errors.models.exercise.at_most_one_main_file')) if files.main_files.count > 1
end
private :valid_main_file?
def valid_submission_deadlines?
return unless submission_deadline.present? || late_submission_deadline.present?
errors.add(:late_submission_deadline, I18n.t('activerecord.errors.models.exercise.late_submission_deadline_not_alone')) if late_submission_deadline.present? && submission_deadline.blank?
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'))
end
end
private :valid_submission_deadlines?
def needs_more_feedback?
user_exercise_feedbacks.size <= MAX_EXERCISE_FEEDBACKS
end
@ -543,5 +562,4 @@ class Exercise < ApplicationRecord
WHERE exercise_id = #{id}
) AS t ON t.fv = submissions.id").distinct
end
end