Files
codeocean/app/controllers/exercises_controller.rb
kiragrammel 175c8933f3 Automatically submit LTI grade on each score run
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.
2023-11-23 14:42:10 +01:00

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