
With this commit, we refactor the overall score handling of CodeOcean. Previously, "Score" and "Submit" were two distinct actions, requiring users to confirm the LTI transmission of their score (after assessing their submission). This yielded many questions and was unnecessary, since LTI parameters are no longer expiring after each use. Therefore, we can now transmit the current grade on each score run with the very same LTI parameters. As a consequence, the LTI consumer gets a more detailed history of the scores, enabling further analytical insights. For users, the previous "Submit" button got replaced with a notification that is shown as soon as the full score got reached. Then, learners can decide to "finalize" their work on the given exercise, which will initiate a redirect to a follow-up action (as defined in the RedirectBehavior). This RedirectBehavior has also been unified and simplified for better readability. As part of this refactoring, we rephrased the notifications and UX workflow of a) the LTI transmission, b) the finalization of an exercise (measured by reaching the full score) and c) the deadline handling (on time, within grace period, too late). Those information are now separately shown, potentially resulting in multiple notifications. As a side effect, they are much better maintainable, and the LTI transmission is more decoupled from this notification handling.
566 lines
21 KiB
Ruby
566 lines
21 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class ExercisesController < ApplicationController
|
|
include CommonBehavior
|
|
include RedirectBehavior
|
|
include Lti
|
|
include SubmissionParameters
|
|
include TimeHelper
|
|
|
|
before_action :handle_file_uploads, only: %i[create update]
|
|
before_action :set_execution_environments, only: %i[index create edit new update]
|
|
before_action :set_exercise_and_authorize,
|
|
only: MEMBER_ACTIONS + %i[clone implement working_times intervention statistics reload feedback
|
|
study_group_dashboard export_external_check export_external_confirm
|
|
external_user_statistics]
|
|
before_action :collect_set_and_unset_exercise_tags, only: MEMBER_ACTIONS
|
|
before_action :set_external_user_and_authorize, only: [:external_user_statistics]
|
|
before_action :set_file_types, only: %i[create edit new update]
|
|
before_action :set_available_tips, only: %i[implement show new edit]
|
|
|
|
skip_before_action :verify_authenticity_token, only: %i[import_task import_uuid_check]
|
|
skip_after_action :verify_authorized, only: %i[import_task import_uuid_check]
|
|
skip_after_action :verify_policy_scoped, only: %i[import_task import_uuid_check], raise: false
|
|
|
|
rescue_from Pundit::NotAuthorizedError, with: :not_authorized_for_exercise
|
|
|
|
def authorize!
|
|
authorize(@exercise || @exercises)
|
|
end
|
|
|
|
private :authorize!
|
|
|
|
def max_intervention_count_per_day
|
|
3
|
|
end
|
|
|
|
def max_intervention_count_per_exercise
|
|
1
|
|
end
|
|
|
|
def batch_update
|
|
@exercises = Exercise.all
|
|
authorize!
|
|
@exercises = params[:exercises].values.map do |exercise_params|
|
|
exercise = Exercise.find(exercise_params.delete(:id))
|
|
exercise.update(exercise_params.permit(:public))
|
|
exercise
|
|
end
|
|
render(json: {exercises: @exercises})
|
|
end
|
|
|
|
def clone
|
|
exercise = @exercise.duplicate(public: false, token: nil, user: current_user)
|
|
exercise.send(:generate_token)
|
|
if exercise.save
|
|
redirect_to(exercise_path(exercise), notice: t('shared.object_cloned', model: Exercise.model_name.human))
|
|
else
|
|
flash[:danger] = t('shared.message_failure')
|
|
redirect_to(@exercise)
|
|
end
|
|
end
|
|
|
|
def collect_paths(files)
|
|
unique_paths = files.map(&:path).compact_blank.uniq
|
|
subpaths = unique_paths.map do |path|
|
|
Array.new((path.split('/').length + 1)) do |n|
|
|
path.split('/').shift(n).join('/')
|
|
end
|
|
end
|
|
subpaths.flatten.uniq
|
|
end
|
|
|
|
private :collect_paths
|
|
|
|
def index
|
|
@search = policy_scope(Exercise).ransack(params[:q])
|
|
@exercises = @search.result.includes(:execution_environment, :user, :files, :exercise_tags).order(:title).paginate(page: params[:page], per_page: per_page_param)
|
|
authorize!
|
|
end
|
|
|
|
def show
|
|
# Show exercise details for teachers and admins
|
|
end
|
|
|
|
def new
|
|
@exercise = Exercise.new
|
|
authorize!
|
|
collect_set_and_unset_exercise_tags
|
|
end
|
|
|
|
def feedback
|
|
authorize!
|
|
@feedbacks = @exercise
|
|
.user_exercise_feedbacks
|
|
.includes(:exercise, user: [:programming_groups])
|
|
.paginate(page: params[:page], per_page: per_page_param)
|
|
@submissions = @feedbacks.map do |feedback|
|
|
feedback.exercise.final_submission(feedback.user.programming_groups.select {|pg| pg.exercise = @exercise }.presence || feedback.user)
|
|
end
|
|
end
|
|
|
|
def export_external_check
|
|
codeharbor_check = ExerciseService::CheckExternal.call(uuid: @exercise.uuid,
|
|
codeharbor_link: current_user.codeharbor_link)
|
|
render json: {
|
|
message: codeharbor_check[:message],
|
|
actions: render_to_string(
|
|
partial: 'export_actions',
|
|
locals: {
|
|
exercise: @exercise,
|
|
uuid_found: codeharbor_check[:uuid_found],
|
|
update_right: codeharbor_check[:update_right],
|
|
error: codeharbor_check[:error],
|
|
exported: false,
|
|
}
|
|
),
|
|
}, status: :ok
|
|
end
|
|
|
|
def export_external_confirm
|
|
authorize!
|
|
@exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil?
|
|
|
|
error = ExerciseService::PushExternal.call(
|
|
zip: ProformaService::ExportTask.call(exercise: @exercise),
|
|
codeharbor_link: current_user.codeharbor_link
|
|
)
|
|
if error.nil?
|
|
render json: {
|
|
status: 'success',
|
|
message: t('exercises.export_codeharbor.successfully_exported', id: @exercise.id, title: @exercise.title),
|
|
actions: render_to_string(partial: 'export_actions',
|
|
locals: {exercise: @exercise, exported: true, error:}),
|
|
}
|
|
@exercise.save
|
|
else
|
|
render json: {
|
|
status: 'fail',
|
|
message: t('exercises.export_codeharbor.export_failed', id: @exercise.id, title: @exercise.title, error:),
|
|
actions: render_to_string(partial: 'export_actions',
|
|
locals: {exercise: @exercise, exported: true, error:}),
|
|
}
|
|
end
|
|
end
|
|
|
|
def import_uuid_check
|
|
user = user_from_api_key
|
|
return render json: {}, status: :unauthorized if user.nil?
|
|
|
|
uuid = params[:uuid]
|
|
exercise = Exercise.find_by(uuid:)
|
|
|
|
return render json: {uuid_found: false} if exercise.nil?
|
|
return render json: {uuid_found: true, update_right: false} unless ExercisePolicy.new(user, exercise).update?
|
|
|
|
render json: {uuid_found: true, update_right: true}
|
|
end
|
|
|
|
def import_task
|
|
tempfile = Tempfile.new('codeharbor_import.zip')
|
|
tempfile.write request.body.read.force_encoding('UTF-8')
|
|
tempfile.rewind
|
|
|
|
user = user_from_api_key
|
|
return render json: {}, status: :unauthorized if user.nil?
|
|
|
|
ActiveRecord::Base.transaction do
|
|
exercise = ::ProformaService::Import.call(zip: tempfile, user:)
|
|
exercise.save!
|
|
render json: {}, status: :created
|
|
end
|
|
rescue ProformaXML::ExerciseNotOwned
|
|
render json: {}, status: :unauthorized
|
|
rescue ProformaXML::ProformaError
|
|
render json: t('exercises.import_codeharbor.import_errors.invalid'), status: :bad_request
|
|
rescue StandardError => e
|
|
Sentry.capture_exception(e)
|
|
render json: t('exercises.import_codeharbor.import_errors.internal_error'), status: :internal_server_error
|
|
end
|
|
|
|
def user_from_api_key
|
|
authorization_header = request.headers['Authorization']
|
|
api_key = authorization_header&.split(' ')&.second
|
|
user_by_codeharbor_token(api_key)
|
|
end
|
|
|
|
private :user_from_api_key
|
|
|
|
def user_by_codeharbor_token(api_key)
|
|
link = CodeharborLink.find_by(api_key:)
|
|
link&.user
|
|
end
|
|
|
|
private :user_by_codeharbor_token
|
|
|
|
def exercise_params
|
|
@exercise_params ||= 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,
|
|
:internal_title,
|
|
:expected_difficulty,
|
|
:tips,
|
|
files_attributes: file_attributes,
|
|
tag_ids: []
|
|
).merge(
|
|
user: current_user
|
|
)
|
|
end
|
|
end
|
|
|
|
private :exercise_params
|
|
|
|
def exercise_params_with_tags
|
|
myparam = exercise_params.presence || {}
|
|
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.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
|
|
myparam.delete :tips
|
|
removed_exercise_tags.map(&:destroy)
|
|
myparam
|
|
end
|
|
private :exercise_params_with_tags
|
|
|
|
def handle_file_uploads
|
|
if exercise_params
|
|
exercise_params[:files_attributes].try(:each) do |_index, file_attributes|
|
|
if file_attributes[:content].respond_to?(:read)
|
|
if FileType.find_by(id: file_attributes[:file_type_id]).try(:binary?)
|
|
file_attributes[:native_file] = file_attributes[:content]
|
|
file_attributes[:content] = nil
|
|
else
|
|
file_attributes[:content] = file_attributes[:content].read.detect_encoding!.encode.delete("\x00")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
private :handle_file_uploads
|
|
|
|
def handle_exercise_tips(tips_params)
|
|
return unless tips_params
|
|
|
|
begin
|
|
exercise_tips = JSON.parse(tips_params)
|
|
# Order is important to ensure no foreign key restraints are violated during delete
|
|
previous_exercise_tips = ExerciseTip.where(exercise: @exercise).select(:id).order(rank: :desc).ids
|
|
remaining_exercise_tips = update_exercise_tips exercise_tips, nil, 1
|
|
# Destroy initializes each object and then calls a *single* SQL DELETE
|
|
ExerciseTip.destroy(previous_exercise_tips - remaining_exercise_tips)
|
|
rescue JSON::ParserError => e
|
|
flash[:danger] = "JSON error: #{e.message}"
|
|
redirect_to(edit_exercise_path(@exercise))
|
|
end
|
|
end
|
|
|
|
private :handle_exercise_tips
|
|
|
|
def update_exercise_tips(exercise_tips, parent_exercise_tip_id, rank)
|
|
result = []
|
|
exercise_tips.each do |exercise_tip|
|
|
exercise_tip.symbolize_keys!
|
|
current_exercise_tip = ExerciseTip.find_or_initialize_by(id: exercise_tip[:id],
|
|
exercise: @exercise,
|
|
tip_id: exercise_tip[:tip_id])
|
|
current_exercise_tip.parent_exercise_tip_id = parent_exercise_tip_id
|
|
current_exercise_tip.rank = rank
|
|
rank += 1
|
|
unless current_exercise_tip.save
|
|
flash[:danger] = current_exercise_tip.errors.full_messages.join('. ')
|
|
redirect_to(edit_exercise_path(@exercise)) and break
|
|
end
|
|
|
|
children = update_exercise_tips exercise_tip[:children], current_exercise_tip.id, rank
|
|
rank += children.length
|
|
|
|
result << current_exercise_tip.id
|
|
result += children
|
|
end
|
|
result
|
|
end
|
|
|
|
private :update_exercise_tips
|
|
|
|
def implement # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
if session[:pg_id] && current_contributor.exercise != @exercise
|
|
# we are acting on behalf of a programming group
|
|
if current_user.admin?
|
|
session.delete(:pg_id)
|
|
session.delete(:pair_programming)
|
|
@current_contributor = current_user
|
|
else
|
|
return redirect_back(
|
|
fallback_location: implement_exercise_path(current_contributor.exercise),
|
|
alert: t('exercises.implement.existing_programming_group', exercise: current_contributor.exercise.title)
|
|
)
|
|
end
|
|
elsif session[:pg_id].blank? && (pg = current_user.programming_groups.find_by(exercise: @exercise)) && pg.submissions.where(study_group_id: current_user.current_study_group_id).any?
|
|
# we are just acting on behalf of a single user who has already worked on this exercise as part of a programming group **in the context of the current study group**
|
|
session[:pg_id] = pg.id
|
|
@current_contributor = pg
|
|
elsif session[:pg_id].blank? && session[:pair_programming] == 'mandatory'
|
|
return redirect_back(fallback_location: new_exercise_programming_group_path(@exercise))
|
|
elsif session[:pg_id].blank? && session[:pair_programming] == 'optional' && current_user.submissions.where(study_group_id: current_user.current_study_group_id, exercise: @exercise).none?
|
|
Event.find_or_create_by(category: 'pp_work_alone', user: current_user, exercise: @exercise, data: nil, file_id: nil)
|
|
current_user.pair_programming_waiting_users&.find_by(exercise: @exercise)&.update(status: :worked_alone)
|
|
end
|
|
|
|
user_solved_exercise = @exercise.solved_by?(current_contributor)
|
|
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) || user_got_intervention_in_exercise
|
|
|
|
if @embed_options[:disable_interventions]
|
|
@show_rfc_interventions = false
|
|
@show_break_interventions = false
|
|
@show_tips_interventions = false
|
|
else
|
|
show_intervention = (!user_solved_exercise && !user_got_enough_interventions).to_s
|
|
if @tips.present? && Java21Study.show_tips_intervention?(current_user, @exercise)
|
|
@show_tips_interventions = show_intervention
|
|
@show_break_interventions = false
|
|
@show_rfc_interventions = false
|
|
elsif Java21Study.show_break_intervention?(current_user, @exercise)
|
|
@show_tips_interventions = false
|
|
@show_break_interventions = show_intervention
|
|
@show_rfc_interventions = false
|
|
else
|
|
@show_tips_interventions = false
|
|
@show_break_interventions = false
|
|
@show_rfc_interventions = show_intervention
|
|
end
|
|
end
|
|
|
|
@embed_options[:disable_score] = true unless @exercise.teacher_defined_assessment?
|
|
|
|
@hide_rfc_button = @embed_options[:disable_rfc]
|
|
|
|
@submission = current_contributor.submissions.order(created_at: :desc).find_by(exercise: @exercise)
|
|
@files = (@submission ? @submission.collect_files : @exercise.files).select(&:visible).sort_by(&:filepath)
|
|
@paths = collect_paths(@files)
|
|
end
|
|
|
|
def set_available_tips
|
|
# Order of elements is important and will be kept
|
|
available_tips = ExerciseTip.where(exercise: @exercise).order(rank: :asc).includes(:tip)
|
|
|
|
# Transform result set in a hash and prepare (temporary) children array.
|
|
# The children array will contain the sorted list of nested tips,
|
|
# shown for learners in the output sidebar with cards.
|
|
# Hash - Key: exercise_tip.id, value: exercise_tip Object loaded from database
|
|
nested_tips = available_tips.each_with_object({}) do |exercise_tip, hash|
|
|
exercise_tip.children = []
|
|
hash[exercise_tip.id] = exercise_tip
|
|
end
|
|
|
|
available_tips.each do |tip|
|
|
# A tip without a parent cannot be a children
|
|
next if tip.parent_exercise_tip_id.blank?
|
|
|
|
# Link tips if they are related
|
|
nested_tips[tip.parent_exercise_tip_id].children << tip
|
|
end
|
|
|
|
# Return an array with top-level tips
|
|
@tips = nested_tips.values.select {|tip| tip.parent_exercise_tip_id.nil? }
|
|
end
|
|
|
|
private :set_available_tips
|
|
|
|
def working_times
|
|
working_time_accumulated = @exercise.accumulated_working_time_for_only(current_contributor)
|
|
working_time_75_percentile = @exercise.get_quantiles([0.75]).first
|
|
render(json: {working_time_75_percentile:,
|
|
working_time_accumulated:})
|
|
end
|
|
|
|
def intervention
|
|
intervention = Intervention.find_by(name: params[:intervention_type])
|
|
if intervention.nil?
|
|
render(json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"})
|
|
else
|
|
uei = UserExerciseIntervention.new(
|
|
user: current_contributor, exercise: @exercise, intervention:,
|
|
accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_contributor)
|
|
)
|
|
uei.save
|
|
render(json: {success: 'true'})
|
|
end
|
|
end
|
|
|
|
def edit; end
|
|
|
|
def create
|
|
@exercise = Exercise.new(exercise_params&.except(:tips))
|
|
authorize!
|
|
collect_set_and_unset_exercise_tags
|
|
tips_params = exercise_params&.dig(:tips)
|
|
return if performed?
|
|
|
|
create_and_respond(object: @exercise, params: exercise_params_with_tags) do
|
|
# We first need to create the exercise before handling tips
|
|
handle_exercise_tips tips_params
|
|
end
|
|
end
|
|
|
|
def not_authorized_for_exercise(_exception)
|
|
return render_not_authorized unless current_user
|
|
return render_not_authorized unless %w[implement working_times intervention reload].include?(action_name)
|
|
|
|
if current_user.admin? || current_user.teacher?
|
|
redirect_to(@exercise, alert: t('exercises.implement.unpublished')) if @exercise.unpublished?
|
|
redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists?
|
|
redirect_to(@exercise, alert: t('exercises.implement.no_execution_environment')) if @exercise.execution_environment.blank?
|
|
else
|
|
render_not_authorized
|
|
end
|
|
end
|
|
private :not_authorized_for_exercise
|
|
|
|
def set_execution_environments
|
|
@execution_environments = ExecutionEnvironment.order(:name)
|
|
end
|
|
|
|
private :set_execution_environments
|
|
|
|
def set_exercise_and_authorize
|
|
@exercise = Exercise.includes(:exercise_tips, files: [:file_type]).find(params[:id])
|
|
authorize!
|
|
end
|
|
|
|
private :set_exercise_and_authorize
|
|
|
|
def set_external_user_and_authorize
|
|
if params[:external_user_id]
|
|
@external_user = ExternalUser.find(params[:external_user_id])
|
|
authorize!
|
|
end
|
|
end
|
|
|
|
private :set_external_user_and_authorize
|
|
|
|
def set_file_types
|
|
@file_types = FileType.order(:name)
|
|
end
|
|
|
|
private :set_file_types
|
|
|
|
def collect_set_and_unset_exercise_tags
|
|
@tags = policy_scope(Tag)
|
|
checked_exercise_tags = @exercise.exercise_tags
|
|
checked_tags = checked_exercise_tags.to_set(&:tag)
|
|
unchecked_tags = Tag.all.to_set.subtract checked_tags
|
|
@exercise_tags = checked_exercise_tags + unchecked_tags.collect do |tag|
|
|
ExerciseTag.new(exercise: @exercise, tag:)
|
|
end
|
|
end
|
|
|
|
private :collect_set_and_unset_exercise_tags
|
|
|
|
def update
|
|
handle_exercise_tips exercise_params&.dig(:tips)
|
|
return if performed?
|
|
|
|
update_and_respond(object: @exercise, params: exercise_params_with_tags)
|
|
end
|
|
|
|
def reload
|
|
# Returns JSON with original file content
|
|
end
|
|
|
|
def statistics
|
|
# Show general statistic page for specific exercise
|
|
contributor_statistics = {'InternalUser' => {}, 'ExternalUser' => {}, 'ProgrammingGroup' => {}}
|
|
|
|
query = Submission.select('contributor_id, contributor_type, MAX(score) AS maximum_score, COUNT(id) AS runs')
|
|
.where(exercise_id: @exercise.id)
|
|
.group('contributor_id, contributor_type')
|
|
|
|
query = if policy(@exercise).detailed_statistics?
|
|
query
|
|
elsif !policy(@exercise).detailed_statistics? && current_user.study_groups.count.positive?
|
|
query.where(study_groups: current_user.study_groups.pluck(:id), cause: 'submit')
|
|
else
|
|
# e.g. internal user without any study groups, show no submissions
|
|
query.where('false')
|
|
end
|
|
|
|
query.each do |tuple|
|
|
contributor_statistics[tuple['contributor_type']][tuple['contributor_id'].to_i] = tuple
|
|
end
|
|
|
|
render locals: {
|
|
contributor_statistics:,
|
|
}
|
|
end
|
|
|
|
def external_user_statistics
|
|
# Render statistics page for one specific external user
|
|
|
|
if policy(@exercise).detailed_statistics?
|
|
submissions = Submission.where(contributor: @external_user, exercise: @exercise)
|
|
.in_study_group_of(current_user)
|
|
.order(:created_at)
|
|
@show_autosaves = params[:show_autosaves] == 'true' || submissions.none? {|s| s.cause != 'autosave' }
|
|
submissions = submissions.where.not(cause: 'autosave') unless @show_autosaves
|
|
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.positive?
|
|
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].sum) if index.positive?))
|
|
end
|
|
else
|
|
final_submissions = Submission.where(contributor: @external_user,
|
|
exercise_id: @exercise.id).in_study_group_of(current_user).final
|
|
submissions = []
|
|
%i[before_deadline within_grace_period after_late_deadline].each do |filter|
|
|
relevant_submission = final_submissions.send(filter).latest
|
|
submissions.push relevant_submission if relevant_submission.present?
|
|
end
|
|
@all_events = submissions
|
|
end
|
|
|
|
render 'exercises/external_users/statistics'
|
|
end
|
|
|
|
def destroy
|
|
destroy_and_respond(object: @exercise)
|
|
end
|
|
|
|
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)
|
|
|
|
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
|
|
end
|
|
end
|