Files
codeocean/app/controllers/exercises_controller.rb
Sebastian Serth e5678483cc Prevent duplicated 'pp_work_alone' events.
* Ensure only one `pp_work_alone` event is stored.
* Disable Turbolinks for Programming Groups Work Alone, so that the implement page is requested normally. Otherwise, Turbolinks would load the page first, just to notice that it needs to reload the page afterwards to include Highlight.js for the tips.
2023-09-23 20:45:49 +02:00

613 lines
22 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 search run statistics submit reload feedback
requests_for_comments 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)
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,
: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)
@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? && PairProgramming23Study.participate?(current_user, @exercise) && 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 submit
@submission = Submission.create(submission_params)
@submission.calculate_score(current_user)
if @submission.users.map {|user| lti_outcome_service?(@submission.exercise, user, @submission.study_group_id) }.any?
transmit_lti_score
else
redirect_after_submit
end
rescue Runner::Error => e
Rails.logger.debug { "Runner error while submitting submission #{@submission.id}: #{e.message}" }
respond_to do |format|
format.html { redirect_to(implement_exercise_path(@submission.exercise)) }
format.json { render(json: {danger: I18n.t('exercises.editor.depleted'), status: :container_depleted}) }
end
end
def transmit_lti_score
responses = send_scores(@submission)
messages = {}
failed_users = []
responses.each do |response|
if Lti::ERROR_STATUS.include? response[:status]
failed_users << response[:user]
elsif response[:score_sent] != @submission.normalized_score # the score was sent successfully, but received too late
messages[:warning] = I18n.t('exercises.submit.too_late')
end
end
if failed_users.size == responses.size # all submissions failed
messages[:danger] = I18n.t('exercises.submit.failure')
elsif failed_users.size.positive? # at least one submission failed
messages[:warning] = [[messages[:warning]], I18n.t('exercises.submit.warning_not_for_all_users_submitted', user: failed_users.join(', '))].join('<br><br>')
messages[:warning] = "#{messages[:warning]}\n\n#{I18n.t('exercises.submit.warning_not_for_all_users_submitted', user: failed_users.join(', '))}".strip
else
messages.each do |type, message_text|
flash.now[type] = message_text
flash.keep(type)
end
return redirect_after_submit
end
respond_to do |format|
format.html { redirect_to(implement_exercise_path(@submission.exercise), **messages) }
format.json { render(json: messages) } # We must not change the HTTP status code here, since otherwise the custom message is not displayed.
end
end
private :transmit_lti_score
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