diff --git a/app/assets/stylesheets/forms.css.scss b/app/assets/stylesheets/forms.css.scss index fc7dd6d0..62613e8e 100644 --- a/app/assets/stylesheets/forms.css.scss +++ b/app/assets/stylesheets/forms.css.scss @@ -7,6 +7,13 @@ width: 100% !important; } +.chosen-inline { + .chosen-container { + min-width: unset !important; + width: unset !important; + } +} + .code-field { font-family: monospace; } diff --git a/app/assets/stylesheets/statistics.css.scss b/app/assets/stylesheets/statistics.css.scss index e636e9b0..f3f006eb 100644 --- a/app/assets/stylesheets/statistics.css.scss +++ b/app/assets/stylesheets/statistics.css.scss @@ -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: diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 2bb18096..b2242a01 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -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 diff --git a/app/controllers/external_users_controller.rb b/app/controllers/external_users_controller.rb index 537631f6..a7d39341 100644 --- a/app/controllers/external_users_controller.rb +++ b/app/controllers/external_users_controller.rb @@ -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 diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 86b402d8..74e8a477 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -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 diff --git a/app/models/submission.rb b/app/models/submission.rb index 60f865e9..dc0f2eae 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 73b21fe0..031dd464 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 5bd92946..a162c368 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -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 diff --git a/app/policies/exercise_policy.rb b/app/policies/exercise_policy.rb index a5fab334..083f6952 100644 --- a/app/policies/exercise_policy.rb +++ b/app/policies/exercise_policy.rb @@ -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 diff --git a/app/policies/external_user_policy.rb b/app/policies/external_user_policy.rb index 4257fa4b..c35b1eb5 100644 --- a/app/policies/external_user_policy.rb +++ b/app/policies/external_user_policy.rb @@ -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? diff --git a/app/views/exercises/_form.html.slim b/app/views/exercises/_form.html.slim index dcf31568..aaa4800b 100644 --- a/app/views/exercises/_form.html.slim +++ b/app/views/exercises/_form.html.slim @@ -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') diff --git a/app/views/exercises/external_users/statistics.html.slim b/app/views/exercises/external_users/statistics.html.slim index 5e8a79ae..61ac33b0 100644 --- a/app/views/exercises/external_users/statistics.html.slim +++ b/app/views/exercises/external_users/statistics.html.slim @@ -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') diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim index 832b13f9..f9d2b6a2 100644 --- a/app/views/exercises/show.html.slim +++ b/app/views/exercises/show.html.slim @@ -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?) diff --git a/app/views/exercises/statistics.html.slim b/app/views/exercises/statistics.html.slim index d4b7b1b2..2f80ee7e 100644 --- a/app/views/exercises/statistics.html.slim +++ b/app/views/exercises/statistics.html.slim @@ -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? diff --git a/app/views/external_users/show.html.slim b/app/views/external_users/show.html.slim index b3f72d6c..1f1ad756 100644 --- a/app/views/external_users/show.html.slim +++ b/app/views/external_users/show.html.slim @@ -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 diff --git a/app/views/external_users/statistics.html.slim b/app/views/external_users/statistics.html.slim index 368846a8..a7e78887 100644 --- a/app/views/external_users/statistics.html.slim +++ b/app/views/external_users/statistics.html.slim @@ -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') \ No newline at end of file diff --git a/config/locales/de.yml b/config/locales/de.yml index 208e573b..5d5ab495 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -35,6 +35,8 @@ de: hide_file_tree: Dateibaum verstecken instructions: Anweisungen maximum_score: Erreichbare Punktzahl + submission_deadline: Abgabefrist + late_submission_deadline: Verspätete Abgabefrist number_of_users: "# Nutzer" public: Öffentlich selection: Ausgewählt @@ -216,6 +218,8 @@ de: models: exercise: 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: dashboard: 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. no_execution_environment_selected: Bitte eine Ausführungsumgebung auswählen, bevor die Aufgabe aktiviert wird. 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: 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.' @@ -406,7 +413,7 @@ de: participants: Bearbeitende Nutzer users: '%{count} verschiedene Nutzer' user: Nutzer - score: Punktzahl + score: Maximale Punktzahl runs: Versuche worktime: Arbeitszeit average_worktime: Durchschnittliche Arbeitszeit @@ -426,12 +433,17 @@ de: no_data_yet: Bisher sind keine Daten verfügbar external_users: statistics: - no_data_available: Keine Daten verfügbar. + no_data_available: Keine Daten verfügbar oder fehlende Berechtigungen. time: Zeit cause: Grund score: Punktzahl tests: Unit Tests 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.' proxy_exercises: index: @@ -440,7 +452,7 @@ de: statistics: title: Statistiken für Externe Benutzer exercise: Übung - score: Bewertung + score: Maximale Punktzahl runs: Abgaben worktime: Arbeitszeit show: diff --git a/config/locales/en.yml b/config/locales/en.yml index d3d805bd..dd8edc97 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -35,6 +35,8 @@ en: hide_file_tree: Hide File Tree instructions: Instructions maximum_score: Maximum Score + submission_deadline: Submission Deadline + late_submission_deadline: Late Submission Deadline number_of_users: "# Users" public: Public selection: Selected @@ -216,6 +218,8 @@ en: models: exercise: 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: dashboard: 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. no_execution_environment_selected: Select an execution environment before publishing the exercise. 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: alert: 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 users: '%{count} distinct users' user: User - score: Score + score: Maximum Score runs: Runs worktime: Working Time average_worktime: Average Working Time @@ -426,12 +433,17 @@ en: no_data_yet: No data available yet external_users: statistics: - no_data_available: No data available. + no_data_available: No data available or insufficient permissions time: Time cause: Cause score: Score tests: Unit Test Results 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." proxy_exercises: index: @@ -440,7 +452,7 @@ en: statistics: title: External User Statistics exercise: Exercise - score: Score + score: Maximum Score runs: Submissions worktime: Working Time show: diff --git a/db/migrate/20200506093054_add_deadline_to_exercises.rb b/db/migrate/20200506093054_add_deadline_to_exercises.rb new file mode 100644 index 00000000..39526198 --- /dev/null +++ b/db/migrate/20200506093054_add_deadline_to_exercises.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 54992b77..bbc6bc60 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "plpgsql" @@ -154,6 +154,8 @@ ActiveRecord::Schema.define(version: 2020_03_26_115249) do t.integer "expected_difficulty", default: 1 t.uuid "uuid" t.boolean "unpublished", default: false + t.datetime "submission_deadline" + t.datetime "late_submission_deadline" t.index ["id"], name: "index_exercises_on_id" 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" end + add_foreign_key "request_for_comments", "submissions", name: "request_for_comments_submissions_id_fk" add_foreign_key "submissions", "study_groups" end diff --git a/spec/policies/external_user_policy_spec.rb b/spec/policies/external_user_policy_spec.rb index d668c305..5d1e468c 100644 --- a/spec/policies/external_user_policy_spec.rb +++ b/spec/policies/external_user_policy_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe ExternalUserPolicy do 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 it 'grants access to admins only' do expect(subject).to permit(FactoryBot.build(:admin), ExternalUser.new) @@ -13,4 +13,16 @@ describe ExternalUserPolicy do 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