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; width: 100% !important;
} }
.chosen-inline {
.chosen-container {
min-width: unset !important;
width: unset !important;
}
}
.code-field { .code-field {
font-family: monospace; font-family: monospace;
} }

View File

@ -62,6 +62,18 @@ tr.highlight {
border-top: 2px solid rgba(222,0,0,1); 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: // StatisticsController:

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
class ExercisesController < ApplicationController class ExercisesController < ApplicationController
include CommonBehavior include CommonBehavior
include Lti include Lti
@ -5,16 +7,16 @@ class ExercisesController < ApplicationController
include SubmissionScoring include SubmissionScoring
include TimeHelper include TimeHelper
before_action :handle_file_uploads, only: [:create, :update] before_action :handle_file_uploads, only: %i[create update]
before_action :set_execution_environments, only: [:create, :edit, :new, :update] before_action :set_execution_environments, only: %i[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 :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_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] before_action :set_course_token, only: [:implement]
skip_before_action :verify_authenticity_token, only: [:import_exercise, :import_uuid_check, :export_external_confirm] skip_before_action :verify_authenticity_token, only: %i[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_authorized, only: %i[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_after_action :verify_policy_scoped, only: %i[import_exercise import_uuid_check export_external_confirm], raise: false
def authorize! def authorize!
authorize(@exercise || @exercises) authorize(@exercise || @exercises)
@ -31,13 +33,13 @@ class ExercisesController < ApplicationController
def experimental_courses def experimental_courses
{ {
java17: "702cbd2a-c84c-4b37-923a-692d7d1532d0", java17: '702cbd2a-c84c-4b37-923a-692d7d1532d0',
java1: "0ea88ea9-979a-44a3-b0e4-84ba58e5a05e" java1: '0ea88ea9-979a-44a3-b0e4-84ba58e5a05e'
} }
end end
def experimental_course?(course_token) def experimental_course?(course_token)
experimental_courses.has_value?(course_token) experimental_courses.value?(course_token)
end end
def batch_update def batch_update
@ -76,18 +78,18 @@ class ExercisesController < ApplicationController
def create def create
@exercise = Exercise.new(exercise_params) @exercise = Exercise.new(exercise_params)
collect_set_and_unset_exercise_tags collect_set_and_unset_exercise_tags
myparam = exercise_params.present? ? exercise_params : { } myparam = exercise_params.present? ? exercise_params : {}
checked_exercise_tags = @exercise_tags.select { | 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 } 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.factor = params[:tag_factors][et.tag_id.to_s][:factor]
et.exercise = @exercise et.exercise = @exercise
end end
myparam[:exercise_tags] = checked_exercise_tags myparam[:exercise_tags] = checked_exercise_tags
myparam.delete :tag_ids myparam.delete :tag_ids
removed_exercise_tags.map {|et| et.destroy} removed_exercise_tags.map(&:destroy)
authorize! authorize!
create_and_respond(object: @exercise) create_and_respond(object: @exercise)
@ -112,12 +114,12 @@ class ExercisesController < ApplicationController
def requests_for_comments def requests_for_comments
authorize! authorize!
@search = RequestForComment @search = RequestForComment
.with_last_activity .with_last_activity
.where(exercise: @exercise) .where(exercise: @exercise)
.ransack(params[:q]) .ransack(params[:q])
@request_for_comments = @search.result @request_for_comments = @search.result
.order('last_comment DESC') .order('last_comment DESC')
.paginate(page: params[:page]) .paginate(page: params[:page])
render 'request_for_comments/index' render 'request_for_comments/index'
end end
@ -210,7 +212,7 @@ class ExercisesController < ApplicationController
private :user_by_codeharbor_token private :user_by_codeharbor_token
def exercise_params 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 end
private :exercise_params private :exercise_params
@ -232,56 +234,55 @@ class ExercisesController < ApplicationController
private :handle_file_uploads private :handle_file_uploads
def implement 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? redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists?
user_solved_exercise = @exercise.has_user_solved(current_user) 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_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] if @embed_options[:disable_interventions]
@show_rfc_interventions = (not user_solved_exercise and not user_got_enough_interventions).to_s @show_rfc_interventions = false
@show_break_interventions = false @show_break_interventions = false
else else
@show_rfc_interventions = false @show_rfc_interventions = (!user_solved_exercise && !user_got_enough_interventions).to_s
@show_break_interventions = false @show_break_interventions = false
end end
@hide_rfc_button = @embed_options[:disable_rfc] @hide_rfc_button = @embed_options[:disable_rfc]
@search = Search.new @search = Search.new
@search.exercise = @exercise @search.exercise = @exercise
@submission = current_user.submissions.where(exercise_id: @exercise.id).order('created_at DESC').first @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) @files = (@submission ? @submission.collect_files : @exercise.files).select(&:visible).sort_by(&:name_with_extension)
@paths = collect_paths(@files) @paths = collect_paths(@files)
if current_user.respond_to? :external_id @user_id = if current_user.respond_to? :external_id
@user_id = current_user.external_id current_user.external_id
else else
@user_id = current_user.id current_user.id
end end
end end
def set_course_token def set_course_token
lti_parameters = LtiParameter.where(external_users_id: current_user.id, lti_parameters = LtiParameter.where(external_users_id: current_user.id,
exercises_id: @exercise.id).last exercises_id: @exercise.id).last
if lti_parameters 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 = @course_token =
unless lti_json.nil? if lti_json.nil?
if match = lti_json.match(/^.*courses\/([a-z0-9\-]+)\/sections/) ''
match.captures.first else
else if match = lti_json.match(%r{^.*courses/([a-z0-9\-]+)/sections})
"" match.captures.first
end
else else
"" ''
end end
end
else else
# no consumer, therefore implementation with internal user # no consumer, therefore implementation with internal user
@course_token = "702cbd2a-c84c-4b37-923a-692d7d1532d0" @course_token = '702cbd2a-c84c-4b37-923a-692d7d1532d0'
end end
end end
private :set_course_token private :set_course_token
@ -294,14 +295,15 @@ class ExercisesController < ApplicationController
def intervention def intervention
intervention = Intervention.find_by_name(params[:intervention_type]) 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( uei = UserExerciseIntervention.new(
user: current_user, exercise: @exercise, intervention: intervention, user: current_user, exercise: @exercise, intervention: intervention,
accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_user)) accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_user)
)
uei.save uei.save
render(json: {success: 'true'}) render(json: {success: 'true'})
else
render(json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"})
end end
end end
@ -310,9 +312,9 @@ class ExercisesController < ApplicationController
search = Search.new(user: current_user, exercise: @exercise, search: search_text) search = Search.new(user: current_user, exercise: @exercise, search: search_text)
begin search.save begin search.save
render(json: {success: 'true'}) render(json: {success: 'true'})
rescue rescue StandardError
render(json: {success: 'false', error: "could not save search: #{$!}"}) render(json: {success: 'false', error: "could not save search: #{$ERROR_INFO}"})
end end
end end
@ -386,9 +388,9 @@ class ExercisesController < ApplicationController
@search = policy_scope(Tag).ransack(params[:q]) @search = policy_scope(Tag).ransack(params[:q])
@tags = @search.result.order(:name) @tags = @search.result.order(:name)
checked_exercise_tags = @exercise.exercise_tags 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 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 end
private :collect_set_and_unset_exercise_tags private :collect_set_and_unset_exercise_tags
@ -401,27 +403,39 @@ class ExercisesController < ApplicationController
end end
def statistics def statistics
if(@external_user) if @external_user
authorize(@external_user, :statistics?) authorize(@external_user, :statistics?)
@submissions = Submission.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id).order("created_at") if policy(@exercise).detailed_statistics?
interventions = UserExerciseIntervention.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id) @submissions = Submission.where(user: @external_user, exercise_id: @exercise.id).in_study_group_of(current_user).order('created_at')
@all_events = (@submissions + interventions).sort_by { |a| a.created_at } interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id, @exercise.id)
@deltas = @all_events.map.with_index do |item, index| @all_events = (@submissions + interventions).sort_by(&:created_at)
delta = item.created_at - @all_events[index - 1].created_at if index > 0 @deltas = @all_events.map.with_index do |item, index|
if delta == nil or delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS then 0 else delta end delta = item.created_at - @all_events[index - 1].created_at if index > 0
end delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta
@working_times_until = [] end
@all_events.each_with_index do |_, index| @working_times_until = []
@working_times_until.push((format_time_difference(@deltas[0..index].inject(:+)) if index > 0)) @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 end
render 'exercises/external_users/statistics' render 'exercises/external_users/statistics'
else else
user_statistics = {} 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 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;" user_id;"
ApplicationRecord.connection.execute(query).each do |tuple| ApplicationRecord.connection.execute(query).each do |tuple|
user_statistics[tuple["user_id"].to_i] = tuple user_statistics[tuple['user_id'].to_i] = tuple
end end
render locals: { render locals: {
user_statistics: user_statistics user_statistics: user_statistics
@ -441,7 +455,7 @@ class ExercisesController < ApplicationController
end end
def transmit_lti_score 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) response = send_score(@submission.exercise_id, @submission.normalized_score, @submission.user_id)
if response[:status] == 'success' if response[:status] == 'success'
@ -458,17 +472,17 @@ class ExercisesController < ApplicationController
def update def update
collect_set_and_unset_exercise_tags collect_set_and_unset_exercise_tags
myparam = exercise_params myparam = exercise_params
checked_exercise_tags = @exercise_tags.select { | 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 } 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.factor = params[:tag_factors][et.tag_id.to_s][:factor]
et.exercise = @exercise et.exercise = @exercise
end end
myparam[:exercise_tags] = checked_exercise_tags myparam[:exercise_tags] = checked_exercise_tags
myparam.delete :tag_ids myparam.delete :tag_ids
removed_exercise_tags.map {|et| et.destroy} removed_exercise_tags.map(&:destroy)
update_and_respond(object: @exercise, params: myparam) update_and_respond(object: @exercise, params: myparam)
end end
@ -511,21 +525,21 @@ class ExercisesController < ApplicationController
clear_lti_session_data(@submission.exercise_id, @submission.user_id, session[:consumer_id]) clear_lti_session_data(@submission.exercise_id, @submission.user_id, session[:consumer_id])
respond_to do |format| respond_to do |format|
format.html {redirect_to(rfc)} format.html { redirect_to(rfc) }
format.json {render(json: {redirect: url_for(rfc)})} format.json { render(json: {redirect: url_for(rfc)}) }
end end
return return
end end
end end
else else
# redirect to feedback page if score is less than 100 percent # redirect to feedback page if score is less than 100 percent
if @exercise.needs_more_feedback? && !@embed_options[:disable_redirect_to_feedback] if @exercise.needs_more_feedback? && !@embed_options[:disable_redirect_to_feedback]
clear_lti_session_data(@submission.exercise_id, @submission.user_id, session[:consumer_id]) clear_lti_session_data(@submission.exercise_id, @submission.user_id, session[:consumer_id])
redirect_to_user_feedback redirect_to_user_feedback
else else
redirect_to_lti_return_path redirect_to_lti_return_path
end end
return return
end end
redirect_to_lti_return_path redirect_to_lti_return_path
end end
@ -547,12 +561,11 @@ class ExercisesController < ApplicationController
def study_group_dashboard def study_group_dashboard
authorize! authorize!
@study_group_id = params[:study_group_id] @study_group_id = params[:study_group_id]
@request_for_comments = RequestForComment. @request_for_comments = RequestForComment
where(exercise: @exercise).includes(:submission). .where(exercise: @exercise).includes(:submission)
where(submissions: {study_group_id: @study_group_id}). .where(submissions: {study_group_id: @study_group_id})
order(created_at: :desc) .order(created_at: :desc)
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id) @graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
end end
end end

View File

@ -6,7 +6,7 @@ class ExternalUsersController < ApplicationController
def index def index
@search = ExternalUser.ransack(params[:q]) @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! authorize!
end end
@ -41,6 +41,7 @@ class ExternalUsersController < ApplicationController
FROM submissions FROM submissions
WHERE user_id = #{@user.id} WHERE user_id = #{@user.id}
AND user_type = 'ExternalUser' 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, GROUP BY exercise_id,
user_id, user_id,
id id

View File

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

View File

@ -15,11 +15,22 @@ class Submission < ApplicationRecord
has_many :structured_errors has_many :structured_errors
has_many :comments, through: :files 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 delegate :execution_environment, to: :exercise
scope :final, -> { where(cause: 'submit') } scope :final, -> { where(cause: 'submit') }
scope :intermediate, -> { where.not(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 :cause, inclusion: {in: CAUSES}
validates :exercise_id, presence: true validates :exercise_id, presence: true
@ -29,6 +40,7 @@ class Submission < ApplicationRecord
def build_files_hash(files, attribute) def build_files_hash(files, attribute)
files.map(&attribute.to_proc).zip(files).to_h files.map(&attribute.to_proc).zip(files).to_h
end end
private :build_files_hash private :build_files_hash
def collect_files def collect_files
@ -42,7 +54,7 @@ class Submission < ApplicationRecord
end end
def normalized_score 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) if !score.nil? && !exercise.maximum_score.nil? && (exercise.maximum_score > 0)
score / exercise.maximum_score score / exercise.maximum_score
else else
@ -62,6 +74,32 @@ class Submission < ApplicationRecord
Submission.model_name.human Submission.model_name.human
end 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? def redirect_to_feedback?
((user_id + exercise.created_at.to_i) % 10 == 1) && exercise.needs_more_feedback? ((user_id + exercise.created_at.to_i) % 10 == 1) && exercise.needs_more_feedback?
end end
@ -71,6 +109,6 @@ class Submission < ApplicationRecord
end end
def unsolved_rfc 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
end end

View File

@ -19,6 +19,8 @@ class User < ApplicationRecord
scope :with_submissions, -> { where('id IN (SELECT user_id FROM submissions)') } 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| ROLES.each do |role|
define_method("#{role}?") { try(:role) == role } define_method("#{role}?") { try(:role) == role }
end end

View File

@ -33,9 +33,14 @@ class ApplicationPolicy
users_in_same_study_group = study_group.users users_in_same_study_group = study_group.users
elsif @record.respond_to? :users # e.g. study_group elsif @record.respond_to? :users # e.g. study_group
users_in_same_study_group = @record.users 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 study_groups = @record.user.study_groups
users_in_same_study_group = study_groups.collect(&:users).flatten 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 end
users_in_same_study_group.include? @user users_in_same_study_group.include? @user

View File

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

View File

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

View File

@ -16,6 +16,16 @@
= f.label(:instructions) = f.label(:instructions)
= f.hidden_field(:instructions) = f.hidden_field(:instructions)
.form-control.markdown .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 .form-check
label.form-check-label label.form-check-label
= f.check_box(:public, class: 'form-check-input') = 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 - current_submission = @submissions.first
- if current_submission - if current_submission
- initial_files = current_submission.files.to_a - 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 table.table
thead thead
tr tr
- ['.time', '.cause', '.score', '.tests', '.time_difference'].each do |title| th.header = t('.time')
th.header = t(title) th.header = t('.cause')
th.header = t('.score')
th.header = t('.tests')
th.header = t('.time_difference') if policy(@exercise).detailed_statistics?
tbody tbody
- @all_events.each_with_index do |this, index| - @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) - 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") td.clickable = this.created_at.strftime("%F %T")
- if this.is_a?(Submission) - if this.is_a?(Submission)
td = this.cause td = this.cause
td = this.score td = this.score
td td.align-middle
-this.testruns.each do |run| -this.testruns.each do |run|
- if run.passed - if run.passed
.unit-test-result.positive-result title=run.output .unit-test-result.positive-result title=run.output
- else - else
.unit-test-result.unknown-result title=run.output .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 - elsif this.is_a? UserExerciseIntervention
td = this.intervention.name td = this.intervention.name
td = td =
td = td =
td = @working_times_until[index] if index > 0 td = @working_times_until[index] if index > 0 if policy(@exercise).detailed_statistics?
p = t('.addendum', delta: StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS / 60) small
.d-none#wtimes data-working_times=ActiveSupport::JSON.encode(@working_times_until); b
div#progress_chart.col-lg-12 = t('.legend')
.graph-functions-2 .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 - else
p = t('.no_data_available') 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.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.instructions', value: render_markdown(@exercise.instructions))
= row(label: 'exercise.maximum_score', value: @exercise.maximum_score) = 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.public', value: @exercise.public?)
= row(label: 'exercise.unpublished', value: @exercise.unpublished?) = row(label: 'exercise.unpublished', value: @exercise.unpublished?)
= row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?) = row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?)

View File

@ -25,12 +25,13 @@ h1 = @exercise
p = @exercise.average_working_time p = @exercise.average_working_time
- Hash[:internal_users => t('.internal_users'), :external_users => t('.external_users')].each_pair do |symbol, label| - Hash[:internal_users => t('.internal_users'), :external_users => t('.external_users')].each_pair do |symbol, label|
strong = label - if symbol==:internal_users && current_user.admin?
-if symbol==:external_users strong = label
-working_time_array = [] - if symbol==:external_users
- working_time_array = []
- @exercise.send(symbol).distinct().each do |user| - @exercise.send(symbol).distinct().each do |user|
-working_time = @exercise.average_working_time_for(user.id) or 0 - working_time = @exercise.average_working_time_for(user.id) or 0
-working_time_array.push working_time - working_time_array.push working_time
hr hr
.d-none#data data-working-time=ActiveSupport::JSON.encode(working_time_array) .d-none#data data-working-time=ActiveSupport::JSON.encode(working_time_array)
.working-time-graphs .working-time-graphs
@ -38,19 +39,26 @@ h1 = @exercise
hr hr
div#chart_2 div#chart_2
hr 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-responsive
table.table.table-striped.sortable table.table.table-striped.sortable
thead thead
tr tr
- ['.user', '.score', '.runs', '.worktime'].each do |title| th.header = t('.user')
th.header = t(title) th.header = t('.score')
th.header = t('.runs') if policy(@exercise).detailed_statistics?
th.header = t('.worktime') if policy(@exercise).detailed_statistics?
tbody 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} - if user_statistics[user.id] then us = user_statistics[user.id] else us = {"maximum_score" => nil, "runs" => nil}
- label = "#{user.displayname}" - label = "#{user.displayname}"
tr 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 = 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['maximum_score'] or 0
td = us['runs'] td = us['runs'] if policy(@exercise).detailed_statistics?
td = @exercise.average_working_time_for(user.id) or 0 td = @exercise.average_working_time_for(user.id) or 0 if policy(@exercise).detailed_statistics?

View File

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

View File

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

View File

@ -35,6 +35,8 @@ de:
hide_file_tree: Dateibaum verstecken hide_file_tree: Dateibaum verstecken
instructions: Anweisungen instructions: Anweisungen
maximum_score: Erreichbare Punktzahl maximum_score: Erreichbare Punktzahl
submission_deadline: Abgabefrist
late_submission_deadline: Verspätete Abgabefrist
number_of_users: "# Nutzer" number_of_users: "# Nutzer"
public: Öffentlich public: Öffentlich
selection: Ausgewählt selection: Ausgewählt
@ -216,6 +218,8 @@ de:
models: models:
exercise: exercise:
at_most_one_main_file: dürfen höchstens eine Hauptdatei enthalten at_most_one_main_file: dürfen höchstens eine Hauptdatei enthalten
late_submission_deadline_not_alone: darf nicht ohne eine reguläre Abgabefrist verwendet werden
late_submission_deadline_not_before_submission_deadline: darf nicht vor der reguläre Abgabefrist liegen
admin: admin:
dashboard: dashboard:
show: show:
@ -347,6 +351,9 @@ de:
unpublish_warning: Mit dieser Aktion wird die Aufgabe deaktiviert. Jeder Student, der versucht sie zu implementieren wird eine Fehlermeldung bekommen, bis die Aufgabe wieder aktiviert wurde. unpublish_warning: Mit dieser Aktion wird die Aufgabe deaktiviert. Jeder Student, der versucht sie zu implementieren wird eine Fehlermeldung bekommen, bis die Aufgabe wieder aktiviert wurde.
no_execution_environment_selected: Bitte eine Ausführungsumgebung auswählen, bevor die Aufgabe aktiviert wird. no_execution_environment_selected: Bitte eine Ausführungsumgebung auswählen, bevor die Aufgabe aktiviert wird.
none: Keine none: Keine
hints:
submission_deadline: Ein Zeitpunkt in UTC, zu dem die Abgabe geschlossen wird. Einreichungen nach der Abgabefrist werden als verspätet gekennzeichnet.
late_submission_deadline: Eine Gnadenfrist für Abgaben in UTC. Die verlängerte Abgabefrist soll nicht vor der eigentlichen Abgabefrist liegen. Nachdem die Gnadenfrist verstichen ist, werden keine neuen Einreichungen mehr akzeptiert.
implement: implement:
alert: alert:
text: 'Ihr Browser unterstützt nicht alle Funktionalitäten, die %{application_name} benötigt. Bitte nutzen Sie einen modernen Browser, um %{application_name} zu besuchen.' text: 'Ihr Browser unterstützt nicht alle Funktionalitäten, die %{application_name} benötigt. Bitte nutzen Sie einen modernen Browser, um %{application_name} zu besuchen.'
@ -406,7 +413,7 @@ de:
participants: Bearbeitende Nutzer participants: Bearbeitende Nutzer
users: '%{count} verschiedene Nutzer' users: '%{count} verschiedene Nutzer'
user: Nutzer user: Nutzer
score: Punktzahl score: Maximale Punktzahl
runs: Versuche runs: Versuche
worktime: Arbeitszeit worktime: Arbeitszeit
average_worktime: Durchschnittliche Arbeitszeit average_worktime: Durchschnittliche Arbeitszeit
@ -426,12 +433,17 @@ de:
no_data_yet: Bisher sind keine Daten verfügbar no_data_yet: Bisher sind keine Daten verfügbar
external_users: external_users:
statistics: statistics:
no_data_available: Keine Daten verfügbar. no_data_available: Keine Daten verfügbar oder fehlende Berechtigungen.
time: Zeit time: Zeit
cause: Grund cause: Grund
score: Punktzahl score: Punktzahl
tests: Unit Tests tests: Unit Tests
time_difference: 'Arbeitszeit bis hier*' time_difference: 'Arbeitszeit bis hier*'
legend: 'Legende:'
no_deadline: Keine Abgabefrist
before_deadline: Abgabe rechtzeitig
within_grace_period: Abgabe innerhalb der Gnadenfrist
after_late_deadline: Verspätete Abgabe
addendum: '* Differenzen von mehr als %{delta} Minuten werden ignoriert.' addendum: '* Differenzen von mehr als %{delta} Minuten werden ignoriert.'
proxy_exercises: proxy_exercises:
index: index:
@ -440,7 +452,7 @@ de:
statistics: statistics:
title: Statistiken für Externe Benutzer title: Statistiken für Externe Benutzer
exercise: Übung exercise: Übung
score: Bewertung score: Maximale Punktzahl
runs: Abgaben runs: Abgaben
worktime: Arbeitszeit worktime: Arbeitszeit
show: show:

View File

@ -35,6 +35,8 @@ en:
hide_file_tree: Hide File Tree hide_file_tree: Hide File Tree
instructions: Instructions instructions: Instructions
maximum_score: Maximum Score maximum_score: Maximum Score
submission_deadline: Submission Deadline
late_submission_deadline: Late Submission Deadline
number_of_users: "# Users" number_of_users: "# Users"
public: Public public: Public
selection: Selected selection: Selected
@ -216,6 +218,8 @@ en:
models: models:
exercise: exercise:
at_most_one_main_file: must include at most one main file at_most_one_main_file: must include at most one main file
late_submission_deadline_not_alone: must not be used without a regular submission deadline
late_submission_deadline_not_before_submission_deadline: must not be before the submission deadline passed
admin: admin:
dashboard: dashboard:
show: show:
@ -347,6 +351,9 @@ en:
unpublish_warning: This will unpublish the exercise. Any student trying to implement it will get an error message, until it is published again. unpublish_warning: This will unpublish the exercise. Any student trying to implement it will get an error message, until it is published again.
no_execution_environment_selected: Select an execution environment before publishing the exercise. no_execution_environment_selected: Select an execution environment before publishing the exercise.
none: None none: None
hints:
submission_deadline: A date and time in UTC to close the submission. Any submission obtained after the deadline will be considered late.
late_submission_deadline: A grace period for submissions in UTC. The late submission deadline should not be set or any timestamp before the original submission deadline. After the late submission deadline passed, any new submissions are prevented.
implement: implement:
alert: alert:
text: 'Your browser does not support features required for using %{application_name}. Please access %{application_name} using a modern browser.' text: 'Your browser does not support features required for using %{application_name}. Please access %{application_name} using a modern browser.'
@ -406,7 +413,7 @@ en:
participants: Participating Users participants: Participating Users
users: '%{count} distinct users' users: '%{count} distinct users'
user: User user: User
score: Score score: Maximum Score
runs: Runs runs: Runs
worktime: Working Time worktime: Working Time
average_worktime: Average Working Time average_worktime: Average Working Time
@ -426,12 +433,17 @@ en:
no_data_yet: No data available yet no_data_yet: No data available yet
external_users: external_users:
statistics: statistics:
no_data_available: No data available. no_data_available: No data available or insufficient permissions
time: Time time: Time
cause: Cause cause: Cause
score: Score score: Score
tests: Unit Test Results tests: Unit Test Results
time_difference: 'Working Time until here*' time_difference: 'Working Time until here*'
legend: 'Legend:'
no_deadline: No Deadline
before_deadline: On Time
within_grace_period: Within Grace Period
after_late_deadline: Too Late
addendum: "* Deltas longer than %{delta} minutes are ignored." addendum: "* Deltas longer than %{delta} minutes are ignored."
proxy_exercises: proxy_exercises:
index: index:
@ -440,7 +452,7 @@ en:
statistics: statistics:
title: External User Statistics title: External User Statistics
exercise: Exercise exercise: Exercise
score: Score score: Maximum Score
runs: Submissions runs: Submissions
worktime: Working Time worktime: Working Time
show: show:

View File

@ -0,0 +1,6 @@
class AddDeadlineToExercises < ActiveRecord::Migration[5.2]
def change
add_column :exercises, :submission_deadline, :datetime, null: true, default: nil
add_column :exercises, :late_submission_deadline, :datetime, null: true, default: nil
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_03_26_115249) do ActiveRecord::Schema.define(version: 2020_05_06_093054) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -154,6 +154,8 @@ ActiveRecord::Schema.define(version: 2020_03_26_115249) do
t.integer "expected_difficulty", default: 1 t.integer "expected_difficulty", default: 1
t.uuid "uuid" t.uuid "uuid"
t.boolean "unpublished", default: false t.boolean "unpublished", default: false
t.datetime "submission_deadline"
t.datetime "late_submission_deadline"
t.index ["id"], name: "index_exercises_on_id" t.index ["id"], name: "index_exercises_on_id"
end end
@ -423,5 +425,6 @@ ActiveRecord::Schema.define(version: 2020_03_26_115249) do
t.index ["user_type", "user_id"], name: "index_user_proxy_exercise_exercises_on_user_type_and_user_id" t.index ["user_type", "user_id"], name: "index_user_proxy_exercise_exercises_on_user_type_and_user_id"
end end
add_foreign_key "request_for_comments", "submissions", name: "request_for_comments_submissions_id_fk"
add_foreign_key "submissions", "study_groups" add_foreign_key "submissions", "study_groups"
end end

View File

@ -3,7 +3,7 @@ require 'rails_helper'
describe ExternalUserPolicy do describe ExternalUserPolicy do
subject { described_class } subject { described_class }
[:create?, :destroy?, :edit?, :index?, :new?, :show?, :update?].each do |action| [:create?, :destroy?, :edit?, :new?, :show?, :update?].each do |action|
permissions(action) do permissions(action) do
it 'grants access to admins only' do it 'grants access to admins only' do
expect(subject).to permit(FactoryBot.build(:admin), ExternalUser.new) expect(subject).to permit(FactoryBot.build(:admin), ExternalUser.new)
@ -13,4 +13,16 @@ describe ExternalUserPolicy do
end end
end end
end end
[:index?].each do |action|
permissions(action) do
it 'grants access to admins and teachers only' do
expect(subject).to permit(FactoryBot.build(:admin), ExternalUser.new)
expect(subject).to permit(FactoryBot.build(:teacher), ExternalUser.new)
[:external_user].each do |factory_name|
expect(subject).not_to permit(FactoryBot.build(factory_name), ExternalUser.new)
end
end
end
end
end end