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

@@ -7,6 +7,13 @@
width: 100% !important;
}
.chosen-inline {
.chosen-container {
min-width: unset !important;
width: unset !important;
}
}
.code-field {
font-family: monospace;
}

View File

@@ -62,6 +62,18 @@ tr.highlight {
border-top: 2px solid rgba(222,0,0,1);
}
.before_deadline {
background-color: #DAF7A6;
}
.within_grace_period {
background-color: #F7DC6F;
}
.after_late_deadline {
background-color: #EC7063;
}
/////////////////////////////////////////////////////////////////////////////////////////////
// StatisticsController:

View File

@@ -1,3 +1,5 @@
# frozen_string_literal: true
class ExercisesController < ApplicationController
include CommonBehavior
include Lti
@@ -5,16 +7,16 @@ class ExercisesController < ApplicationController
include SubmissionScoring
include TimeHelper
before_action :handle_file_uploads, only: [:create, :update]
before_action :set_execution_environments, only: [:create, :edit, :new, :update]
before_action :set_exercise_and_authorize, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload, :feedback, :requests_for_comments, :study_group_dashboard, :export_external_check, :export_external_confirm]
before_action :handle_file_uploads, only: %i[create update]
before_action :set_execution_environments, only: %i[create edit new update]
before_action :set_exercise_and_authorize, only: MEMBER_ACTIONS + %i[clone implement working_times intervention search run statistics submit reload feedback requests_for_comments study_group_dashboard export_external_check export_external_confirm]
before_action :set_external_user_and_authorize, only: [:statistics]
before_action :set_file_types, only: [:create, :edit, :new, :update]
before_action :set_file_types, only: %i[create edit new update]
before_action :set_course_token, only: [:implement]
skip_before_action :verify_authenticity_token, only: [:import_exercise, :import_uuid_check, :export_external_confirm]
skip_after_action :verify_authorized, only: [:import_exercise, :import_uuid_check, :export_external_confirm]
skip_after_action :verify_policy_scoped, only: [:import_exercise, :import_uuid_check, :export_external_confirm], raise: false
skip_before_action :verify_authenticity_token, only: %i[import_exercise import_uuid_check export_external_confirm]
skip_after_action :verify_authorized, only: %i[import_exercise import_uuid_check export_external_confirm]
skip_after_action :verify_policy_scoped, only: %i[import_exercise import_uuid_check export_external_confirm], raise: false
def authorize!
authorize(@exercise || @exercises)
@@ -31,13 +33,13 @@ class ExercisesController < ApplicationController
def experimental_courses
{
java17: "702cbd2a-c84c-4b37-923a-692d7d1532d0",
java1: "0ea88ea9-979a-44a3-b0e4-84ba58e5a05e"
java17: '702cbd2a-c84c-4b37-923a-692d7d1532d0',
java1: '0ea88ea9-979a-44a3-b0e4-84ba58e5a05e'
}
end
def experimental_course?(course_token)
experimental_courses.has_value?(course_token)
experimental_courses.value?(course_token)
end
def batch_update
@@ -76,18 +78,18 @@ class ExercisesController < ApplicationController
def create
@exercise = Exercise.new(exercise_params)
collect_set_and_unset_exercise_tags
myparam = exercise_params.present? ? exercise_params : { }
checked_exercise_tags = @exercise_tags.select { | et | myparam[:tag_ids].include? et.tag.id.to_s }
removed_exercise_tags = @exercise_tags.reject { | et | myparam[:tag_ids].include? et.tag.id.to_s }
myparam = exercise_params.present? ? exercise_params : {}
checked_exercise_tags = @exercise_tags.select { |et| myparam[:tag_ids].include? et.tag.id.to_s }
removed_exercise_tags = @exercise_tags.reject { |et| myparam[:tag_ids].include? et.tag.id.to_s }
for et in checked_exercise_tags
checked_exercise_tags.each do |et|
et.factor = params[:tag_factors][et.tag_id.to_s][:factor]
et.exercise = @exercise
end
myparam[:exercise_tags] = checked_exercise_tags
myparam.delete :tag_ids
removed_exercise_tags.map {|et| et.destroy}
removed_exercise_tags.map(&:destroy)
authorize!
create_and_respond(object: @exercise)
@@ -112,12 +114,12 @@ class ExercisesController < ApplicationController
def requests_for_comments
authorize!
@search = RequestForComment
.with_last_activity
.where(exercise: @exercise)
.ransack(params[:q])
.with_last_activity
.where(exercise: @exercise)
.ransack(params[:q])
@request_for_comments = @search.result
.order('last_comment DESC')
.paginate(page: params[:page])
.order('last_comment DESC')
.paginate(page: params[:page])
render 'request_for_comments/index'
end
@@ -210,7 +212,7 @@ class ExercisesController < ApplicationController
private :user_by_codeharbor_token
def exercise_params
params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :unpublished, :hide_file_tree, :allow_file_creation, :allow_auto_completion, :title, :expected_difficulty, files_attributes: file_attributes, :tag_ids => []).merge(user_id: current_user.id, user_type: current_user.class.name) if params[:exercise].present?
params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :submission_deadline, :late_submission_deadline, :public, :unpublished, :hide_file_tree, :allow_file_creation, :allow_auto_completion, :title, :expected_difficulty, files_attributes: file_attributes, tag_ids: []).merge(user_id: current_user.id, user_type: current_user.class.name) if params[:exercise].present?
end
private :exercise_params
@@ -232,56 +234,55 @@ class ExercisesController < ApplicationController
private :handle_file_uploads
def implement
redirect_to(@exercise, alert: t('exercises.implement.unpublished')) if @exercise.unpublished? && current_user.role != 'admin' && current_user.role != 'teacher' # TODO TESTESTEST
redirect_to(@exercise, alert: t('exercises.implement.unpublished')) if @exercise.unpublished? && current_user.role != 'admin' && current_user.role != 'teacher' # TODO: TESTESTEST
redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists?
user_solved_exercise = @exercise.has_user_solved(current_user)
count_interventions_today = UserExerciseIntervention.where(user: current_user).where("created_at >= ?", Time.zone.now.beginning_of_day).count
count_interventions_today = UserExerciseIntervention.where(user: current_user).where('created_at >= ?', Time.zone.now.beginning_of_day).count
user_got_intervention_in_exercise = UserExerciseIntervention.where(user: current_user, exercise: @exercise).size >= max_intervention_count_per_exercise
user_got_enough_interventions = count_interventions_today >= max_intervention_count_per_day or user_got_intervention_in_exercise
(user_got_enough_interventions = count_interventions_today >= max_intervention_count_per_day) || user_got_intervention_in_exercise
unless @embed_options[:disable_interventions]
@show_rfc_interventions = (not user_solved_exercise and not user_got_enough_interventions).to_s
if @embed_options[:disable_interventions]
@show_rfc_interventions = false
@show_break_interventions = false
else
@show_rfc_interventions = false
@show_rfc_interventions = (!user_solved_exercise && !user_got_enough_interventions).to_s
@show_break_interventions = false
end
@hide_rfc_button = @embed_options[:disable_rfc]
@search = Search.new
@search.exercise = @exercise
@submission = current_user.submissions.where(exercise_id: @exercise.id).order('created_at DESC').first
@files = (@submission ? @submission.collect_files : @exercise.files).select(&:visible).sort_by(&:name_with_extension)
@paths = collect_paths(@files)
if current_user.respond_to? :external_id
@user_id = current_user.external_id
else
@user_id = current_user.id
end
@user_id = if current_user.respond_to? :external_id
current_user.external_id
else
current_user.id
end
end
def set_course_token
lti_parameters = LtiParameter.where(external_users_id: current_user.id,
exercises_id: @exercise.id).last
exercises_id: @exercise.id).last
if lti_parameters
lti_json = lti_parameters.lti_parameters["launch_presentation_return_url"]
lti_json = lti_parameters.lti_parameters['launch_presentation_return_url']
@course_token =
unless lti_json.nil?
if match = lti_json.match(/^.*courses\/([a-z0-9\-]+)\/sections/)
match.captures.first
else
""
end
if lti_json.nil?
''
else
if match = lti_json.match(%r{^.*courses/([a-z0-9\-]+)/sections})
match.captures.first
else
""
''
end
end
else
# no consumer, therefore implementation with internal user
@course_token = "702cbd2a-c84c-4b37-923a-692d7d1532d0"
@course_token = '702cbd2a-c84c-4b37-923a-692d7d1532d0'
end
end
private :set_course_token
@@ -294,14 +295,15 @@ class ExercisesController < ApplicationController
def intervention
intervention = Intervention.find_by_name(params[:intervention_type])
unless intervention.nil?
if intervention.nil?
render(json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"})
else
uei = UserExerciseIntervention.new(
user: current_user, exercise: @exercise, intervention: intervention,
accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_user))
user: current_user, exercise: @exercise, intervention: intervention,
accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_user)
)
uei.save
render(json: {success: 'true'})
else
render(json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"})
end
end
@@ -310,9 +312,9 @@ class ExercisesController < ApplicationController
search = Search.new(user: current_user, exercise: @exercise, search: search_text)
begin search.save
render(json: {success: 'true'})
rescue
render(json: {success: 'false', error: "could not save search: #{$!}"})
render(json: {success: 'true'})
rescue StandardError
render(json: {success: 'false', error: "could not save search: #{$ERROR_INFO}"})
end
end
@@ -386,9 +388,9 @@ class ExercisesController < ApplicationController
@search = policy_scope(Tag).ransack(params[:q])
@tags = @search.result.order(:name)
checked_exercise_tags = @exercise.exercise_tags
checked_tags = checked_exercise_tags.collect{|e| e.tag}.to_set
checked_tags = checked_exercise_tags.collect(&:tag).to_set
unchecked_tags = Tag.all.to_set.subtract checked_tags
@exercise_tags = checked_exercise_tags + unchecked_tags.collect { |tag| ExerciseTag.new(exercise: @exercise, tag: tag)}
@exercise_tags = checked_exercise_tags + unchecked_tags.collect { |tag| ExerciseTag.new(exercise: @exercise, tag: tag) }
end
private :collect_set_and_unset_exercise_tags
@@ -401,27 +403,39 @@ class ExercisesController < ApplicationController
end
def statistics
if(@external_user)
if @external_user
authorize(@external_user, :statistics?)
@submissions = Submission.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id).order("created_at")
interventions = UserExerciseIntervention.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id)
@all_events = (@submissions + interventions).sort_by { |a| a.created_at }
@deltas = @all_events.map.with_index do |item, index|
delta = item.created_at - @all_events[index - 1].created_at if index > 0
if delta == nil or delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS then 0 else delta end
end
@working_times_until = []
@all_events.each_with_index do |_, index|
@working_times_until.push((format_time_difference(@deltas[0..index].inject(:+)) if index > 0))
if policy(@exercise).detailed_statistics?
@submissions = Submission.where(user: @external_user, exercise_id: @exercise.id).in_study_group_of(current_user).order('created_at')
interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id, @exercise.id)
@all_events = (@submissions + interventions).sort_by(&:created_at)
@deltas = @all_events.map.with_index do |item, index|
delta = item.created_at - @all_events[index - 1].created_at if index > 0
delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta
end
@working_times_until = []
@all_events.each_with_index do |_, index|
@working_times_until.push((format_time_difference(@deltas[0..index].inject(:+)) if index > 0))
end
else
latest_submissions = Submission.where(user: @external_user, exercise_id: @exercise.id).in_study_group_of(current_user).final.latest
relevant_submissions = latest_submissions.before_deadline.or(latest_submissions.within_grace_period).or(latest_submissions.after_late_deadline)
@submissions = relevant_submissions.sort_by(&:created_at)
@all_events = @submissions
end
render 'exercises/external_users/statistics'
else
user_statistics = {}
additional_filter = if policy(@exercise).detailed_statistics?
''
else
"AND study_group_id IN (#{current_user.study_groups.pluck(:id).join(', ')}) AND cause = 'submit'"
end
query = "SELECT user_id, MAX(score) AS maximum_score, COUNT(id) AS runs
FROM submissions WHERE exercise_id = #{@exercise.id} GROUP BY
FROM submissions WHERE exercise_id = #{@exercise.id} #{additional_filter} GROUP BY
user_id;"
ApplicationRecord.connection.execute(query).each do |tuple|
user_statistics[tuple["user_id"].to_i] = tuple
user_statistics[tuple['user_id'].to_i] = tuple
end
render locals: {
user_statistics: user_statistics
@@ -441,7 +455,7 @@ class ExercisesController < ApplicationController
end
def transmit_lti_score
::NewRelic::Agent.add_custom_attributes({ submission: @submission.id, normalized_score: @submission.normalized_score })
::NewRelic::Agent.add_custom_attributes({submission: @submission.id, normalized_score: @submission.normalized_score})
response = send_score(@submission.exercise_id, @submission.normalized_score, @submission.user_id)
if response[:status] == 'success'
@@ -458,17 +472,17 @@ class ExercisesController < ApplicationController
def update
collect_set_and_unset_exercise_tags
myparam = exercise_params
checked_exercise_tags = @exercise_tags.select { | et | myparam[:tag_ids].include? et.tag.id.to_s }
removed_exercise_tags = @exercise_tags.reject { | et | myparam[:tag_ids].include? et.tag.id.to_s }
checked_exercise_tags = @exercise_tags.select { |et| myparam[:tag_ids].include? et.tag.id.to_s }
removed_exercise_tags = @exercise_tags.reject { |et| myparam[:tag_ids].include? et.tag.id.to_s }
for et in checked_exercise_tags
checked_exercise_tags.each do |et|
et.factor = params[:tag_factors][et.tag_id.to_s][:factor]
et.exercise = @exercise
end
myparam[:exercise_tags] = checked_exercise_tags
myparam.delete :tag_ids
removed_exercise_tags.map {|et| et.destroy}
removed_exercise_tags.map(&:destroy)
update_and_respond(object: @exercise, params: myparam)
end
@@ -511,21 +525,21 @@ class ExercisesController < ApplicationController
clear_lti_session_data(@submission.exercise_id, @submission.user_id, session[:consumer_id])
respond_to do |format|
format.html {redirect_to(rfc)}
format.json {render(json: {redirect: url_for(rfc)})}
format.html { redirect_to(rfc) }
format.json { render(json: {redirect: url_for(rfc)}) }
end
return
end
end
else
# redirect to feedback page if score is less than 100 percent
if @exercise.needs_more_feedback? && !@embed_options[:disable_redirect_to_feedback]
clear_lti_session_data(@submission.exercise_id, @submission.user_id, session[:consumer_id])
redirect_to_user_feedback
else
redirect_to_lti_return_path
end
return
if @exercise.needs_more_feedback? && !@embed_options[:disable_redirect_to_feedback]
clear_lti_session_data(@submission.exercise_id, @submission.user_id, session[:consumer_id])
redirect_to_user_feedback
else
redirect_to_lti_return_path
end
return
end
redirect_to_lti_return_path
end
@@ -547,12 +561,11 @@ class ExercisesController < ApplicationController
def study_group_dashboard
authorize!
@study_group_id = params[:study_group_id]
@request_for_comments = RequestForComment.
where(exercise: @exercise).includes(:submission).
where(submissions: {study_group_id: @study_group_id}).
order(created_at: :desc)
@request_for_comments = RequestForComment
.where(exercise: @exercise).includes(:submission)
.where(submissions: {study_group_id: @study_group_id})
.order(created_at: :desc)
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
end
end

View File

@@ -6,7 +6,7 @@ class ExternalUsersController < ApplicationController
def index
@search = ExternalUser.ransack(params[:q])
@users = @search.result.includes(:consumer).paginate(page: params[:page])
@users = @search.result.in_study_group_of(current_user).includes(:consumer).paginate(page: params[:page])
authorize!
end
@@ -41,6 +41,7 @@ class ExternalUsersController < ApplicationController
FROM submissions
WHERE user_id = #{@user.id}
AND user_type = 'ExternalUser'
#{!current_user.admin? ? "AND study_group_id IN (#{current_user.study_groups.pluck(:id).join(', ')}) AND cause = 'submit'" : ''}
GROUP BY exercise_id,
user_id,
id

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

View File

@@ -15,11 +15,22 @@ class Submission < ApplicationRecord
has_many :structured_errors
has_many :comments, through: :files
belongs_to :external_users, -> { where(submissions: {user_type: 'ExternalUser'}).includes(:submissions) }, foreign_key: :user_id, class_name: 'ExternalUser', optional: true
belongs_to :internal_users, -> { where(submissions: {user_type: 'InternalUser'}).includes(:submissions) }, foreign_key: :user_id, class_name: 'InternalUser', optional: true
delegate :execution_environment, to: :exercise
scope :final, -> { where(cause: 'submit') }
scope :intermediate, -> { where.not(cause: 'submit') }
scope :before_deadline, -> { joins(:exercise).where('submissions.updated_at <= exercises.submission_deadline OR exercises.submission_deadline IS NULL') }
scope :within_grace_period, -> { joins(:exercise).where('(submissions.updated_at > exercises.submission_deadline) AND (submissions.updated_at <= exercises.late_submission_deadline OR exercises.late_submission_deadline IS NULL)') }
scope :after_late_deadline, -> { joins(:exercise).where('submissions.updated_at > exercises.late_submission_deadline') }
scope :latest, -> { order(updated_at: :desc).limit(1) }
scope :in_study_group_of, ->(user) { where(study_group_id: user.study_groups) unless user.admin? }
validates :cause, inclusion: {in: CAUSES}
validates :exercise_id, presence: true
@@ -29,6 +40,7 @@ class Submission < ApplicationRecord
def build_files_hash(files, attribute)
files.map(&attribute.to_proc).zip(files).to_h
end
private :build_files_hash
def collect_files
@@ -42,7 +54,7 @@ class Submission < ApplicationRecord
end
def normalized_score
::NewRelic::Agent.add_custom_attributes({ unnormalized_score: score })
::NewRelic::Agent.add_custom_attributes({unnormalized_score: score})
if !score.nil? && !exercise.maximum_score.nil? && (exercise.maximum_score > 0)
score / exercise.maximum_score
else
@@ -62,6 +74,32 @@ class Submission < ApplicationRecord
Submission.model_name.human
end
def before_deadline?
if exercise.submission_deadline.present?
updated_at <= exercise.submission_deadline
else
false
end
end
def within_grace_period?
if exercise.submission_deadline.present? && exercise.late_submission_deadline.present?
updated_at > exercise.submission_deadline && updated_at <= exercise.late_submission_deadline
else
false
end
end
def after_late_deadline?
if exercise.late_submission_deadline.present?
updated_at > exercise.late_submission_deadline
elsif exercise.submission_deadline.present?
updated_at > exercise.submission_deadline
else
false
end
end
def redirect_to_feedback?
((user_id + exercise.created_at.to_i) % 10 == 1) && exercise.needs_more_feedback?
end
@@ -71,6 +109,6 @@ class Submission < ApplicationRecord
end
def unsolved_rfc
RequestForComment.unsolved.where(exercise_id: exercise).where.not(question: nil).where(created_at: OLDEST_RFC_TO_SHOW.ago..Time.current).order("RANDOM()").find { | rfc_element |( (rfc_element.comments_count < MAX_COMMENTS_ON_RECOMMENDED_RFC) && (!rfc_element.question.empty?)) }
RequestForComment.unsolved.where(exercise_id: exercise).where.not(question: nil).where(created_at: OLDEST_RFC_TO_SHOW.ago..Time.current).order("RANDOM()").find { |rfc_element| ((rfc_element.comments_count < MAX_COMMENTS_ON_RECOMMENDED_RFC) && (!rfc_element.question.empty?)) }
end
end

View File

@@ -19,6 +19,8 @@ class User < ApplicationRecord
scope :with_submissions, -> { where('id IN (SELECT user_id FROM submissions)') }
scope :in_study_group_of, ->(user) { joins(:study_group_memberships).where(study_group_memberships: {study_group_id: user.study_groups}) unless user.admin? }
ROLES.each do |role|
define_method("#{role}?") { try(:role) == role }
end

View File

@@ -33,9 +33,14 @@ class ApplicationPolicy
users_in_same_study_group = study_group.users
elsif @record.respond_to? :users # e.g. study_group
users_in_same_study_group = @record.users
else # e.g. exercise
elsif @record.respond_to? :user # e.g. exercise
study_groups = @record.user.study_groups
users_in_same_study_group = study_groups.collect(&:users).flatten
elsif @record.respond_to? :study_groups # e.g. user
study_groups = @record.study_groups
users_in_same_study_group = study_groups.collect(&:users).flatten
else
return false
end
users_in_same_study_group.include? @user

View File

@@ -11,6 +11,14 @@ class ExercisePolicy < AdminOrAuthorPolicy
admin? || teacher_in_study_group?
end
def submission_statistics?
admin? || teacher_in_study_group?
end
def detailed_statistics?
admin?
end
[:clone?, :destroy?, :edit?, :update?, :export_external_check?, :export_external_confirm?].each do |action|
define_method(action) { admin? || teacher_in_study_group? || author? }
end

View File

@@ -1,6 +1,14 @@
class ExternalUserPolicy < AdminOnlyPolicy
def index?
admin? || teacher?
end
def show?
admin? || teacher_in_study_group?
end
def statistics?
admin?
admin? || teacher_in_study_group?
end
def tag_statistics?

View File

@@ -16,6 +16,16 @@
= f.label(:instructions)
= f.hidden_field(:instructions)
.form-control.markdown
.form-group
= f.label(:submission_deadline)
.chosen-inline
= f.datetime_select(:submission_deadline, include_blank: true)
.help-block.form-text == t('.hints.submission_deadline')
.form-group
= f.label(:late_submission_deadline)
.chosen-inline
= f.datetime_select(:late_submission_deadline, include_blank: true)
.help-block.form-text == t('.hints.late_submission_deadline')
.form-check
label.form-check-label
= f.check_box(:public, class: 'form-check-input')

View File

@@ -1,4 +1,4 @@
h1 = "#{@exercise} (external user #{link_to_if(policy(@external_user).show?, @external_user.displayname, @external_user)})"
h1 = "#{@exercise} (external user #{link_to_if(policy(@external_user).show?, @external_user.displayname, @external_user)})".html_safe
- current_submission = @submissions.first
- if current_submission
- initial_files = current_submission.files.to_a
@@ -36,32 +36,54 @@ h1 = "#{@exercise} (external user #{link_to_if(policy(@external_user).show?, @ex
table.table
thead
tr
- ['.time', '.cause', '.score', '.tests', '.time_difference'].each do |title|
th.header = t(title)
th.header = t('.time')
th.header = t('.cause')
th.header = t('.score')
th.header = t('.tests')
th.header = t('.time_difference') if policy(@exercise).detailed_statistics?
tbody
- @all_events.each_with_index do |this, index|
- highlight = (index > 0 and @deltas[index] == 0 and this.created_at.to_s != @all_events[index - 1].created_at.to_s)
tr data-id=this.id class=('highlight' if highlight)
- row_classes = ''
- row_classes += ' highlight' if highlight
- row_classes += ' before_deadline' if this.is_a?(Submission) && this.before_deadline?
- row_classes += ' within_grace_period' if this.is_a?(Submission) && this.within_grace_period?
- row_classes += ' after_late_deadline' if this.is_a?(Submission) && this.after_late_deadline?
tr data-id=this.id class=row_classes
td.clickable = this.created_at.strftime("%F %T")
- if this.is_a?(Submission)
td = this.cause
td = this.score
td
td.align-middle
-this.testruns.each do |run|
- if run.passed
.unit-test-result.positive-result title=run.output
- else
.unit-test-result.unknown-result title=run.output
td = @working_times_until[index] if index > 0
td = @working_times_until[index] if index > 0 if policy(@exercise).detailed_statistics?
- elsif this.is_a? UserExerciseIntervention
td = this.intervention.name
td =
td =
td = @working_times_until[index] if index > 0
p = t('.addendum', delta: StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS / 60)
.d-none#wtimes data-working_times=ActiveSupport::JSON.encode(@working_times_until);
div#progress_chart.col-lg-12
.graph-functions-2
td = @working_times_until[index] if index > 0 if policy(@exercise).detailed_statistics?
small
b
= t('.legend')
.container.px-0.border
.row.w-100.mx-0
.col-sm-3.py-2
= t('.no_deadline')
.col-sm-3.before_deadline.py-2
= t('.before_deadline')
.col-sm-3.within_grace_period.py-2
= t('.within_grace_period')
.col-sm-3.after_late_deadline.py-2
= t('.after_late_deadline')
- if current_user.try(:admin?)
p = t('.addendum', delta: StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS / 60)
.d-none#wtimes data-working_times=ActiveSupport::JSON.encode(@working_times_until);
div#progress_chart.col-lg-12
.graph-functions-2
- else
p = t('.no_data_available')

View File

@@ -25,6 +25,8 @@ h1
= row(label: 'exercise.execution_environment', value: link_to_if(@exercise.execution_environment && policy(@exercise.execution_environment).show?, @exercise.execution_environment, @exercise.execution_environment))
/= row(label: 'exercise.instructions', value: render_markdown(@exercise.instructions))
= row(label: 'exercise.maximum_score', value: @exercise.maximum_score)
= row(label: 'exercise.submission_deadline', value: @exercise.submission_deadline)
= row(label: 'exercise.late_submission_deadline', value: @exercise.late_submission_deadline)
= row(label: 'exercise.public', value: @exercise.public?)
= row(label: 'exercise.unpublished', value: @exercise.unpublished?)
= row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?)

View File

@@ -25,12 +25,13 @@ h1 = @exercise
p = @exercise.average_working_time
- Hash[:internal_users => t('.internal_users'), :external_users => t('.external_users')].each_pair do |symbol, label|
strong = label
-if symbol==:external_users
-working_time_array = []
- if symbol==:internal_users && current_user.admin?
strong = label
- if symbol==:external_users
- working_time_array = []
- @exercise.send(symbol).distinct().each do |user|
-working_time = @exercise.average_working_time_for(user.id) or 0
-working_time_array.push working_time
- working_time = @exercise.average_working_time_for(user.id) or 0
- working_time_array.push working_time
hr
.d-none#data data-working-time=ActiveSupport::JSON.encode(working_time_array)
.working-time-graphs
@@ -38,19 +39,26 @@ h1 = @exercise
hr
div#chart_2
hr
- if current_user.admin?
- submissions = Submission.where(user: @exercise.send(symbol).distinct, exercise: @exercise).in_study_group_of(current_user)
- if !policy(@exercise).detailed_statistics?
- submissions = submissions.final
- any_viewable_submission = submissions.first
- if any_viewable_submission
.table-responsive
table.table.table-striped.sortable
thead
tr
- ['.user', '.score', '.runs', '.worktime'].each do |title|
th.header = t(title)
th.header = t('.user')
th.header = t('.score')
th.header = t('.runs') if policy(@exercise).detailed_statistics?
th.header = t('.worktime') if policy(@exercise).detailed_statistics?
tbody
- @exercise.send(symbol).distinct().each do |user|
- users = symbol.to_s.classify.constantize.where(id: submissions.joins(symbol).group(:user_id).select(:user_id).distinct)
- users.each do |user|
- if user_statistics[user.id] then us = user_statistics[user.id] else us = {"maximum_score" => nil, "runs" => nil}
- label = "#{user.displayname}"
tr
td = link_to_if symbol==:external_users && policy(user).statistics?, label, {controller: "exercises", action: "statistics", external_user_id: user.id, id: @exercise.id}
td = us['maximum_score'] or 0
td = us['runs']
td = @exercise.average_working_time_for(user.id) or 0
td = us['runs'] if policy(@exercise).detailed_statistics?
td = @exercise.average_working_time_for(user.id) or 0 if policy(@exercise).detailed_statistics?

View File

@@ -1,7 +1,7 @@
h1 = @user.displayname
= row(label: 'external_user.name', value: @user.name)
= row(label: 'external_user.email', value: @user.email)
= row(label: 'external_user.email', value: @user.email) if current_user.admin?
= row(label: 'external_user.external_id') do
code
= @user.external_id
@@ -10,10 +10,11 @@ h1 = @user.displayname
h4.mt-4 = link_to(t('.exercise_statistics'), statistics_external_user_path(@user)) if policy(@user).statistics?
h4.mt-4 = t('.tag_statistics')
#loading
.spinner
= t('.loading_tag_statistics')
#no-elements
= t('.empty_tag_statistics')
#tag-grid
- if current_user.admin?
h4.mt-4 = t('.tag_statistics')
#loading
.spinner
= t('.loading_tag_statistics')
#no-elements
= t('.empty_tag_statistics')
#tag-grid

View File

@@ -1,19 +1,30 @@
h1 = t('.title')
- exercises = Exercise.where(:id => @user.submissions.group(:exercise_id).select(:exercise_id).distinct)
- submissions = Submission.where(user: @user).in_study_group_of(current_user)
- exercises = Exercise.where(id: submissions.joins(:exercise).group(:exercise_id).select(:exercise_id).distinct)
- if !policy(exercises.first).detailed_statistics?
- submissions = submissions.final
- any_viewable_submission = submissions.first
.table-responsive
table.table.table-striped.sortable
thead
tr
- ['.exercise', '.score', '.runs', '.worktime'].each do |title|
th.header = t(title)
tbody
- exercises.each do |exercise|
- if statistics[exercise.id]
- stats = statistics[exercise.id]
tr
td = link_to_if policy(exercise).show?, exercise, controller: "exercises", action: "statistics", external_user_id: @user.id, id: exercise.id
td = stats["maximum_score"] or 0
td = stats["runs"] or 0
td = stats["working_time"] or 0
- if any_viewable_submission && policy(any_viewable_submission).show_study_group?
.table-responsive
table.table.table-striped.sortable
thead
tr
th.header = t('.exercise')
th.header = t('.score')
th.header = t('.runs') if policy(exercises.first).detailed_statistics?
th.header = t('.worktime') if policy(exercises.first).detailed_statistics?
tbody
- exercises.each do |exercise|
// Grab any submission in context of study group (or all if admin). Then check for permission
- any_submission = submissions.where(exercise: exercise).first
- if any_submission && policy(any_submission).show_study_group? && statistics[exercise.id]
- stats = statistics[exercise.id]
tr
td = link_to exercise, controller: "exercises", action: "statistics", external_user_id: @user.id, id: exercise.id
td = stats["maximum_score"] or 0
td = stats["runs"] or 0 if policy(exercises.first).detailed_statistics?
td = stats["working_time"] or 0 if policy(exercises.first).detailed_statistics?
- else
= t('exercises.external_users.statistics.no_data_available')