diff --git a/app/assets/javascripts/editor/editor.js.erb b/app/assets/javascripts/editor/editor.js.erb index 50e14514..75c09bb4 100644 --- a/app/assets/javascripts/editor/editor.js.erb +++ b/app/assets/javascripts/editor/editor.js.erb @@ -167,6 +167,14 @@ configureEditors: function () { $('button i.fa-spin').hide(); }, + + resizeAceEditors: function (){ + $('.editor').each(function (index, element) { + this.resizeParentOfAceEditor(element); + }.bind(this)); + window.dispatchEvent(new Event('resize')); + }, + resizeParentOfAceEditor: function (element){ // calculate needed size: window height - position of top of button-bar - 60 for bar itself and margins var windowHeight = window.innerHeight - $('#editor-buttons').offset().top - 60; @@ -316,10 +324,14 @@ configureEditors: function () { var button = $('#requestComments'); button.prop('disabled', true); button.on('click', function () { + $('#rfc_intervention_text').hide() $('#comment-modal').modal('show'); }); $('#askForCommentsButton').on('click', this.requestComments.bind(this)); + $('#closeAskForCommentsButton').on('click', function(){ + $('#comment-modal').modal('hide'); + }); setTimeout(function () { button.prop('disabled', false); @@ -363,7 +375,7 @@ configureEditors: function () { panel.find('.row .col-sm-9').eq(0).find('.number').eq(1).text(result.count); panel.find('.row .col-sm-9').eq(1).find('.number').eq(0).text(parseFloat((result.score * result.weight).toFixed(2))); panel.find('.row .col-sm-9').eq(1).find('.number').eq(1).text(result.weight); - panel.find('.row .col-sm-9').eq(2).text(result.message); + panel.find('.row .col-sm-9').eq(2).html(result.message); if (result.error_messages) panel.find('.row .col-sm-9').eq(3).text(result.error_messages.join(', ')); panel.find('.row .col-sm-9').eq(4).find('a').attr('href', '#output-' + index); }, @@ -559,8 +571,104 @@ configureEditors: function () { $('#description-panel').toggleClass('description-panel'); $('#description-symbol').toggleClass('fa-chevron-down'); $('#description-symbol').toggleClass('fa-chevron-right'); + // resize ace editors + this.resizeAceEditors(); + }, + /** + * interventions + * */ + initializeInterventionTimer: function() { + + if ($('#editor').data('rfc-interventions') == true || $('#editor').data('break-interventions') == true) { // split in break or rfc intervention + window.onblur = function() { window.blurred = true; }; + window.onfocus = function() { window.blurred = false; }; + + var delta = 100; // time in ms to wait for window event before time gets stopped + var tid; + $.ajax({ + data: { + exercise_id: $('#editor').data('exercise-id'), + user_id: $('#editor').data('user-id') + }, + dataType: 'json', + method: 'GET', + // get working times for this exercise + url: $('#editor').data('working-times-url'), + success: function (data) { + var percentile75 = data['working_time_75_percentile']; + var accumulatedWorkTimeUser = data['working_time_accumulated']; + + var minTimeIntervention = 10 * 60 * 1000; + + if ((accumulatedWorkTimeUser - percentile75) > 0) { + // working time is already over 75 percentile + var timeUntilIntervention = minTimeIntervention; + } else { + // working time is less than 75 percentile + // ensure we give user at least minTimeIntervention before we bother the user + var timeUntilIntervention = Math.max(percentile75 - accumulatedWorkTimeUser, minTimeIntervention); + } + + tid = setInterval(function() { + if ( window.blurred ) { return; } + timeUntilIntervention -= delta; + if ( timeUntilIntervention <= 0 ) { + clearInterval(tid); + // timeUntilIntervention passed + if ($('#editor').data('break-interventions')) { + $('#break-intervention-modal').modal('show'); + $.ajax({ + data: { + intervention_type: 'BreakIntervention' + }, + dataType: 'json', + type: 'POST', + url: $('#editor').data('intervention-save-url') + }); + } else if ($('#editor').data('rfc-interventions')){ + var button = $('#requestComments'); + // only show intervention if user did not requested for a comment already + if (!button.prop('disabled')) { + $('#rfc_intervention_text').show(); + $('#comment-modal').modal('show'); + $.ajax({ + data: { + intervention_type: 'QuestionIntervention' + }, + dataType: 'json', + type: 'POST', + url: $('#editor').data('intervention-save-url') + }); + }; + } + } + }, delta); + } + }); + } + }, + + initializeSearchButton: function(){ + $('#btn-search-col').button().click(function(){ + var search = $('#search-input-text').val(); + var course_token = $('#editor').data('course_token') + var save_search_url = $('#editor').data('search-save-url') + window.open("https://open.hpi.de/courses/" + course_token + "/pinboard?query=" + search, '_blank'); + // save search + $.ajax({ + data: { + search_text: search + }, + dataType: 'json', + type: 'POST', + url: save_search_url}); + }) + + $('#sidebar-search-collapsed').on('click',this.handleSideBarToggle.bind(this)); + }, + initializeEverything: function() { this.initializeRegexes(); @@ -575,10 +683,14 @@ configureEditors: function () { this.initializeDescriptionToggle(); this.initializeSideBarTooltips(); this.initializeTooltips(); + this.initializeInterventionTimer(); + this.initializeSearchButton(); this.initPrompt(); this.renderScore(); this.showFirstFile(); $(window).on("beforeunload", this.unloadAutoSave.bind(this)); + // create autosave when the editor is opened the first time + this.autosave(); } }; \ No newline at end of file diff --git a/app/assets/javascripts/editor/execution.js.erb b/app/assets/javascripts/editor/execution.js.erb index 668ca81a..37c53cb0 100644 --- a/app/assets/javascripts/editor/execution.js.erb +++ b/app/assets/javascripts/editor/execution.js.erb @@ -6,6 +6,9 @@ CodeOceanEditorWebsocket = { sockURL.pathname = url; sockURL.protocol = '<%= DockerClient.config['ws_client_protocol'] %>'; + // strip anchor if it is in the url + sockURL.hash = '' + return sockURL.toString(); }, diff --git a/app/assets/javascripts/exercises.js.erb b/app/assets/javascripts/exercises.js.erb index 2a17b405..5d202d64 100644 --- a/app/assets/javascripts/exercises.js.erb +++ b/app/assets/javascripts/exercises.js.erb @@ -228,7 +228,8 @@ $(function() { } if ($.isController('exercises')) { - if ($('table').isPresent()) { + // ignore tags table since it is in the dom before other tables + if ($('table:not(#tags-table)').isPresent()) { enableBatchUpdate(); } else if ($('.edit_exercise, .new_exercise').isPresent()) { execution_environments = $('form').data('execution-environments'); diff --git a/app/assets/stylesheets/base.css.scss b/app/assets/stylesheets/base.css.scss index e6e0e92c..a0fbd88b 100644 --- a/app/assets/stylesheets/base.css.scss +++ b/app/assets/stylesheets/base.css.scss @@ -5,6 +5,7 @@ h1 { .lead { font-size: 16px; + color: rgba(70, 70, 70, 1); } i.fa { diff --git a/app/assets/stylesheets/request-for-comments.css.scss b/app/assets/stylesheets/request-for-comments.css.scss index 02dab676..a219d21c 100644 --- a/app/assets/stylesheets/request-for-comments.css.scss +++ b/app/assets/stylesheets/request-for-comments.css.scss @@ -1,4 +1,5 @@ #commentitor { margin-top: 2rem; height: 600px; + background-color:#f9f9f9 } \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 617bab02..dfc25ca9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base MEMBER_ACTIONS = [:destroy, :edit, :show, :update] after_action :verify_authorized, except: [:help, :welcome] - before_action :set_locale + before_action :set_locale, :allow_iframe_requests protect_from_forgery(with: :exception) rescue_from Pundit::NotAuthorizedError, with: :render_not_authorized @@ -29,4 +29,8 @@ class ApplicationController < ActionController::Base def welcome end + + def allow_iframe_requests + response.headers.delete('X-Frame-Options') + end end diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index fe7f454c..f6e4f7fb 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -49,7 +49,7 @@ class CommentsController < ApplicationController @comment = Comment.new(comment_params_without_request_id) if comment_params[:request_id] - UserMailer.got_new_comment(@comment, RequestForComment.find(comment_params[:request_id]), current_user) + UserMailer.got_new_comment(@comment, RequestForComment.find(comment_params[:request_id]), current_user).deliver_now end respond_to do |format| diff --git a/app/controllers/concerns/lti.rb b/app/controllers/concerns/lti.rb index 7c168ec6..7483327d 100644 --- a/app/controllers/concerns/lti.rb +++ b/app/controllers/concerns/lti.rb @@ -74,7 +74,12 @@ module Lti private :require_valid_consumer_key def require_valid_exercise_token - @exercise = Exercise.find_by(token: params[:custom_token]) + proxy_exercise = ProxyExercise.find_by(token: params[:custom_token]) + unless proxy_exercise.nil? + @exercise = proxy_exercise.get_matching_exercise(@current_user) + else + @exercise = Exercise.find_by(token: params[:custom_token]) + end refuse_lti_launch(message: t('sessions.oauth.invalid_exercise_token')) unless @exercise end private :require_valid_exercise_token @@ -129,19 +134,16 @@ module Lti private :set_current_user def store_lti_session_data(options = {}) - exercise = Exercise.where(token: options[:parameters][:custom_token]).first - exercise_id = exercise.id unless exercise.nil? - - current_user = ExternalUser.find_or_create_by(consumer_id: options[:consumer].id, external_id: options[:parameters][:user_id].to_s) lti_parameters = LtiParameter.find_or_create_by(consumers_id: options[:consumer].id, - external_users_id: current_user.id, - exercises_id: exercise_id) + external_users_id: @current_user.id, + exercises_id: @exercise.id) lti_parameters.lti_parameters = options[:parameters].slice(*SESSION_PARAMETERS).to_json lti_parameters.save! + @lti_parameters = lti_parameters session[:consumer_id] = options[:consumer].id - session[:external_user_id] = current_user.id + session[:external_user_id] = @current_user.id end private :store_lti_session_data diff --git a/app/controllers/concerns/submission_scoring.rb b/app/controllers/concerns/submission_scoring.rb index c8dfb0b3..16f1f061 100644 --- a/app/controllers/concerns/submission_scoring.rb +++ b/app/controllers/concerns/submission_scoring.rb @@ -25,7 +25,7 @@ module SubmissionScoring def feedback_message(file, score) set_locale - score == Assessor::MAXIMUM_SCORE ? I18n.t('exercises.implement.default_feedback') : file.feedback_message + score == Assessor::MAXIMUM_SCORE ? I18n.t('exercises.implement.default_feedback') : render_markdown(file.feedback_message) end def score_submission(submission) diff --git a/app/controllers/execution_environments_controller.rb b/app/controllers/execution_environments_controller.rb index 89470996..c220ae74 100644 --- a/app/controllers/execution_environments_controller.rb +++ b/app/controllers/execution_environments_controller.rb @@ -40,7 +40,7 @@ class ExecutionEnvironmentsController < ApplicationController FROM (SELECT user_id, exercise_id, - CASE WHEN working_time >= '0:30:00' THEN '0' ELSE working_time END AS working_time_new + CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new FROM (SELECT user_id, exercise_id, diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 79314208..337cb9d4 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -6,9 +6,10 @@ class ExercisesController < ApplicationController before_action :handle_file_uploads, only: [:create, :update] before_action :set_execution_environments, only: [:create, :edit, :new, :update] - before_action :set_exercise, only: MEMBER_ACTIONS + [:clone, :implement, :run, :statistics, :submit, :reload] + before_action :set_exercise, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload] before_action :set_external_user, only: [:statistics] before_action :set_file_types, only: [:create, :edit, :new, :update] + before_action :set_course_token, only: [:implement] skip_before_filter :verify_authenticity_token, only: [:import_proforma_xml] skip_after_action :verify_authorized, only: [:import_proforma_xml] @@ -19,6 +20,15 @@ class ExercisesController < ApplicationController end private :authorize! + def max_intervention_count + 3 + end + + + def java_course_token + "702cbd2a-c84c-4b37-923a-692d7d1532d0" + end + def batch_update @exercises = Exercise.all authorize! @@ -54,6 +64,20 @@ class ExercisesController < ApplicationController def create @exercise = Exercise.new(exercise_params) + 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 } + + for et in checked_exercise_tags + 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} + authorize! create_and_respond(object: @exercise) end @@ -63,6 +87,7 @@ class ExercisesController < ApplicationController end def edit + collect_set_and_unset_exercise_tags end def import_proforma_xml @@ -118,7 +143,8 @@ class ExercisesController < ApplicationController private :user_by_code_harbor_token def exercise_params - params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :allow_file_creation, :allow_auto_completion, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name) + params[:exercise][:expected_worktime_seconds] = params[:exercise][:expected_worktime_minutes].to_i * 60 + params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :allow_file_creation, :allow_auto_completion, :title, :expected_difficulty, :expected_worktime_seconds, files_attributes: file_attributes, :tag_ids => []).merge(user_id: current_user.id, user_type: current_user.class.name) end private :exercise_params @@ -139,6 +165,22 @@ class ExercisesController < ApplicationController def implement redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists? + user_solved_exercise = @exercise.has_user_solved(current_user) + user_got_enough_interventions = UserExerciseIntervention.where(user: current_user).where("created_at >= ?", Time.zone.now.beginning_of_day).count >= max_intervention_count + is_java_course = @course_token && @course_token.eql?(java_course_token) + + user_intervention_group = UserGroupSeparator.getInterventionGroup(current_user) + + case user_intervention_group + when :no_intervention + when :break_intervention + @show_break_interventions = (user_solved_exercise || !is_java_course || user_got_enough_interventions) ? "false" : "true" + when :rfc_intervention + @show_rfc_interventions = (user_solved_exercise || !is_java_course || user_got_enough_interventions) ? "false" : "true" + end + + @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) @@ -150,6 +192,59 @@ class ExercisesController < ApplicationController end end + def set_course_token + lti_parameters = LtiParameter.find_by(external_users_id: current_user.id, + exercises_id: @exercise.id) + if lti_parameters + 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 + java_course_token + end + else + "" + end + else + # no consumer, therefore implementation with internal user + @course_token = java_course_token + end + end + private :set_course_token + + def working_times + working_time_accumulated = @exercise.accumulated_working_time_for_only(current_user) + working_time_75_percentile = @exercise.get_quantiles([0.75]).first + render(json: {working_time_75_percentile: working_time_75_percentile, working_time_accumulated: working_time_accumulated}) + end + + def intervention + intervention = Intervention.find_by_name(params[:intervention_type]) + unless intervention.nil? + uei = UserExerciseIntervention.new( + 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 + + def search + search_text = params[:search_text] + 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: #{$!}"}) + end + end + def index @search = policy_scope(Exercise).search(params[:q]) @exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page]) @@ -174,6 +269,8 @@ class ExercisesController < ApplicationController def new @exercise = Exercise.new + collect_set_and_unset_exercise_tags + authorize! end @@ -201,6 +298,16 @@ class ExercisesController < ApplicationController end private :set_file_types + def collect_set_and_unset_exercise_tags + @search = policy_scope(Tag).search(params[:q]) + @tags = @search.result.order(:name) + checked_exercise_tags = @exercise.exercise_tags + checked_tags = checked_exercise_tags.collect{|e| e.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)} + end + private :collect_set_and_unset_exercise_tags + def show end @@ -252,7 +359,20 @@ class ExercisesController < ApplicationController private :transmit_lti_score def update - update_and_respond(object: @exercise, params: exercise_params) + 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 } + + for et in checked_exercise_tags + 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} + update_and_respond(object: @exercise, params: myparam) end def redirect_after_submit @@ -260,8 +380,12 @@ class ExercisesController < ApplicationController if @submission.normalized_score == 1.0 # if user is external and has an own rfc, redirect to it and message him to clean up and accept the answer. (we need to check that the user is external, # otherwise an internal user could be shown a false rfc here, since current_user.id is polymorphic, but only makes sense for external users when used with rfcs.) + # redirect 10 percent pseudorandomly to the feedback page if current_user.respond_to? :external_id - if rfc = RequestForComment.unsolved.where(exercise_id: @submission.exercise, user_id: current_user.id).first + if ((current_user.id + @submission.exercise.created_at.to_i) % 10 == 1) + redirect_to_user_feedback + return + elsif rfc = RequestForComment.unsolved.where(exercise_id: @submission.exercise, user_id: current_user.id).first # set a message that informs the user that his own RFC should be closed. flash[:notice] = I18n.t('exercises.submit.full_score_redirect_to_own_rfc') flash.keep(:notice) @@ -273,7 +397,7 @@ class ExercisesController < ApplicationController return # else: show open rfc for same exercise if available - elsif rfc = RequestForComment.unsolved.where(exercise_id: @submission.exercise).where.not(question: nil).order("RANDOM()").first + elsif rfc = RequestForComment.unsolved.where(exercise_id: @submission.exercise).where.not(question: nil).order("RANDOM()").find { | rfc_element |(rfc_element.comments_count < 5) } # set a message that informs the user that his score was perfect and help in RFC is greatly appreciated. flash[:notice] = I18n.t('exercises.submit.full_score_redirect_to_rfc') flash.keep(:notice) @@ -285,8 +409,25 @@ class ExercisesController < ApplicationController return end end + else + # redirect to feedback page if score is less than 100 percent + redirect_to_user_feedback + return end redirect_to_lti_return_path end + def redirect_to_user_feedback + url = if UserExerciseFeedback.find_by(exercise: @exercise, user: current_user) + edit_user_exercise_feedback_path(user_exercise_feedback: {exercise_id: @exercise.id}) + else + new_user_exercise_feedback_path(user_exercise_feedback: {exercise_id: @exercise.id}) + end + + respond_to do |format| + format.html { redirect_to(url) } + format.json { render(json: {redirect: url}) } + end + end + end diff --git a/app/controllers/external_users_controller.rb b/app/controllers/external_users_controller.rb index 5c6619cb..22d70988 100644 --- a/app/controllers/external_users_controller.rb +++ b/app/controllers/external_users_controller.rb @@ -27,7 +27,7 @@ class ExternalUsersController < ApplicationController score, id, CASE - WHEN working_time >= '0:30:00' THEN '0' + WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new FROM diff --git a/app/controllers/interventions_controller.rb b/app/controllers/interventions_controller.rb new file mode 100644 index 00000000..b4b5971e --- /dev/null +++ b/app/controllers/interventions_controller.rb @@ -0,0 +1,55 @@ +class InterventionsController < ApplicationController + include CommonBehavior + + before_action :set_intervention, only: MEMBER_ACTIONS + + def authorize! + authorize(@intervention || @interventions) + end + private :authorize! + + def create + #@intervention = Intervention.new(intervention_params) + #authorize! + #create_and_respond(object: @intervention) + end + + def destroy + destroy_and_respond(object: @intervention) + end + + def edit + end + + def intervention_params + params[:intervention].permit(:name) + end + private :intervention_params + + def index + @interventions = Intervention.all.paginate(page: params[:page]) + authorize! + end + + def new + #@intervention = Intervention.new + #authorize! + end + + def set_intervention + @intervention = Intervention.find(params[:id]) + authorize! + end + private :set_intervention + + def show + end + + def update + update_and_respond(object: @intervention, params: intervention_params) + end + + def to_s + name + end +end diff --git a/app/controllers/proxy_exercises_controller.rb b/app/controllers/proxy_exercises_controller.rb new file mode 100644 index 00000000..02fe0220 --- /dev/null +++ b/app/controllers/proxy_exercises_controller.rb @@ -0,0 +1,80 @@ +class ProxyExercisesController < ApplicationController + include CommonBehavior + + before_action :set_exercise, only: MEMBER_ACTIONS + [:clone, :reload] + + def authorize! + authorize(@proxy_exercise || @proxy_exercises) + end + private :authorize! + + def clone + proxy_exercise = @proxy_exercise.duplicate(token: nil, exercises: @proxy_exercise.exercises) + proxy_exercise.send(:generate_token) + if proxy_exercise.save + redirect_to(proxy_exercise, notice: t('shared.object_cloned', model: ProxyExercise.model_name.human)) + else + flash[:danger] = t('shared.message_failure') + redirect_to(@proxy_exercise) + end + end + + def create + myparams = proxy_exercise_params + myparams[:exercises] = Exercise.find(myparams[:exercise_ids].reject { |c| c.empty? }) + @proxy_exercise = ProxyExercise.new(myparams) + authorize! + + create_and_respond(object: @proxy_exercise) + end + + def destroy + destroy_and_respond(object: @proxy_exercise) + end + + def edit + @search = policy_scope(Exercise).search(params[:q]) + @exercises = @search.result.order(:title) + authorize! + end + + def proxy_exercise_params + params[:proxy_exercise].permit(:description, :title, :exercise_ids => []) + end + private :proxy_exercise_params + + def index + @search = policy_scope(ProxyExercise).search(params[:q]) + @proxy_exercises = @search.result.order(:title).paginate(page: params[:page]) + authorize! + end + + def new + @proxy_exercise = ProxyExercise.new + @search = policy_scope(Exercise).search(params[:q]) + @exercises = @search.result.order(:title) + authorize! + end + + def set_exercise + @proxy_exercise = ProxyExercise.find(params[:id]) + authorize! + end + private :set_exercise + + def show + @search = @proxy_exercise.exercises.search + @exercises = @proxy_exercise.exercises.search.result.order(:title) #@search.result.order(:title) + end + + #we might want to think about auth here + def reload + end + + def update + myparams = proxy_exercise_params + myparams[:exercises] = Exercise.find(myparams[:exercise_ids].reject { |c| c.blank? }) + update_and_respond(object: @proxy_exercise, params: myparams) + end + +end diff --git a/app/controllers/request_for_comments_controller.rb b/app/controllers/request_for_comments_controller.rb index f9e7137e..bcbb1f28 100644 --- a/app/controllers/request_for_comments_controller.rb +++ b/app/controllers/request_for_comments_controller.rb @@ -11,12 +11,14 @@ class RequestForCommentsController < ApplicationController # GET /request_for_comments # GET /request_for_comments.json def index - @request_for_comments = RequestForComment.last_per_user(2).order('created_at DESC').paginate(page: params[:page]) + @search = RequestForComment.last_per_user(2).search(params[:q]) + @request_for_comments = @search.result.order('created_at DESC').paginate(page: params[:page]) authorize! end def get_my_comment_requests - @request_for_comments = RequestForComment.where(user_id: current_user.id).order('created_at DESC').paginate(page: params[:page]) + @search = RequestForComment.where(user_id: current_user.id).order('created_at DESC').search(params[:q]) + @request_for_comments = @search.result.paginate(page: params[:page]) render 'index' end @@ -32,6 +34,10 @@ class RequestForCommentsController < ApplicationController end end + def submit + + end + # GET /request_for_comments/1 # GET /request_for_comments/1.json def show @@ -63,6 +69,20 @@ class RequestForCommentsController < ApplicationController authorize! end + def create_comment_exercise + old = UserExerciseFeedback.find_by(exercise_id: params[:exercise_id], user_id: current_user.id, user_type: current_user.class.name) + if old + old.delete + end + uef = UserExerciseFeedback.new(comment_params) + + if uef.save + render(json: {success: "true"}) + else + render(json: {success: "false"}) + end + end + # DELETE /request_for_comments/1 # DELETE /request_for_comments/1.json def destroy @@ -74,6 +94,10 @@ class RequestForCommentsController < ApplicationController authorize! end + def comment_params + params.permit(:exercise_id, :feedback_text).merge(user_id: current_user.id, user_type: current_user.class.name) + end + private # Use callbacks to share common setup or constraints between actions. def set_request_for_comment @@ -85,4 +109,5 @@ class RequestForCommentsController < ApplicationController # we are using the current_user.id here, since internal users are not able to create comments. The external_user.id is a primary key and does not require the consumer_id to be unique. params.require(:request_for_comment).permit(:exercise_id, :file_id, :question, :requested_at, :solved, :submission_id).merge(user_id: current_user.id, user_type: current_user.class.name) end + end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index e6bdac8c..8f698d1a 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController include Lti - [:require_oauth_parameters, :require_valid_consumer_key, :require_valid_oauth_signature, :require_unique_oauth_nonce, :require_valid_exercise_token].each do |method_name| + [:require_oauth_parameters, :require_valid_consumer_key, :require_valid_oauth_signature, :require_unique_oauth_nonce, :set_current_user, :require_valid_exercise_token].each do |method_name| before_action(method_name, only: :create_through_lti) end @@ -18,7 +18,6 @@ class SessionsController < ApplicationController end def create_through_lti - set_current_user store_lti_session_data(consumer: @consumer, parameters: params) store_nonce(params[:oauth_nonce]) redirect_to(implement_exercise_path(@exercise), diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index b8c6fd37..8d84bd7d 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -13,6 +13,10 @@ class SubmissionsController < ApplicationController before_action :set_mime_type, only: [:download_file, :render_file] skip_before_action :verify_authenticity_token, only: [:download_file, :render_file] + def max_message_buffer_size + 500 + end + def authorize! authorize(@submission || @submissions) end @@ -156,7 +160,7 @@ class SubmissionsController < ApplicationController tubesock.onmessage do |data| Rails.logger.info(Time.now.getutc.to_s + ": Client sending: " + data) # Check whether the client send a JSON command and kill container - # if the command is 'client_exit', send it to docker otherwise. + # if the command is 'client_kill', send it to docker otherwise. begin parsed = JSON.parse(data) if parsed['cmd'] == 'client_kill' @@ -183,21 +187,31 @@ class SubmissionsController < ApplicationController end def kill_socket(tubesock) + # save the output of this "run" as a "testrun" (scoring runs are saved in submission_scoring.rb) + save_run_output + # Hijacked connection needs to be notified correctly tubesock.send_data JSON.dump({'cmd' => 'exit'}) tubesock.close end def handle_message(message, tubesock, container) + @message_buffer ||= "" # Handle special commands first - if (/^exit/.match(message)) - kill_socket(tubesock) + if (/^#exit/.match(message)) + # Just call exit_container on the docker_client. + # Do not call kill_socket for the websocket to the client here. + # @docker_client.exit_container closes the socket to the container, + # kill_socket is called in the "on close handler" of the websocket to the container @docker_client.exit_container(container) + elsif /^#timeout/.match(message) + @message_buffer = 'timeout: ' + @message_buffer # add information that this run timed out to the buffer else # Filter out information about run_command, test_command, user or working directory run_command = @submission.execution_environment.run_command % command_substitutions(params[:filename]) test_command = @submission.execution_environment.test_command % command_substitutions(params[:filename]) if !(/root|workspace|#{run_command}|#{test_command}/.match(message)) + @message_buffer += message if @message_buffer.size <= max_message_buffer_size parse_message(message, 'stdout', tubesock) end end @@ -245,6 +259,13 @@ class SubmissionsController < ApplicationController end end + def save_run_output + if !@message_buffer.blank? + @message_buffer = @message_buffer[(0..max_message_buffer_size-1)] # trim the string to max_message_buffer_size chars + Testrun.create(file: @file, submission: @submission, output: @message_buffer) + end + end + def score hijack do |tubesock| Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive? diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 00000000..ff6925dd --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,55 @@ +class TagsController < ApplicationController + include CommonBehavior + + before_action :set_tag, only: MEMBER_ACTIONS + + def authorize! + authorize(@tag || @tags) + end + private :authorize! + + def create + @tag = Tag.new(tag_params) + authorize! + create_and_respond(object: @tag) + end + + def destroy + destroy_and_respond(object: @tag) + end + + def edit + end + + def tag_params + params[:tag].permit(:name) + end + private :tag_params + + def index + @tags = Tag.all.paginate(page: params[:page]) + authorize! + end + + def new + @tag = Tag.new + authorize! + end + + def set_tag + @tag = Tag.find(params[:id]) + authorize! + end + private :set_tag + + def show + end + + def update + update_and_respond(object: @tag, params: tag_params) + end + + def to_s + name + end +end diff --git a/app/controllers/user_exercise_feedbacks_controller.rb b/app/controllers/user_exercise_feedbacks_controller.rb new file mode 100644 index 00000000..0d1e1925 --- /dev/null +++ b/app/controllers/user_exercise_feedbacks_controller.rb @@ -0,0 +1,115 @@ +class UserExerciseFeedbacksController < ApplicationController + include CommonBehavior + + before_action :set_user_exercise_feedback, only: [:edit, :update] + + def comment_presets + [[0,t('user_exercise_feedback.difficulty_easy')], + [1,t('user_exercise_feedback.difficulty_some_what_easy')], + [2,t('user_exercise_feedback.difficulty_ok')], + [3,t('user_exercise_feedback.difficulty_some_what_difficult')], + [4,t('user_exercise_feedback.difficult_too_difficult')]] + end + + def time_presets + [[0,t('user_exercise_feedback.estimated_time_less_5')], + [1,t('user_exercise_feedback.estimated_time_5_to_10')], + [2,t('user_exercise_feedback.estimated_time_10_to_20')], + [3,t('user_exercise_feedback.estimated_time_20_to_30')], + [4,t('user_exercise_feedback.estimated_time_more_30')]] + end + + def authorize! + authorize(@uef) + end + private :authorize! + + def create + @exercise = Exercise.find(uef_params[:exercise_id]) + rfc = RequestForComment.unsolved.where(exercise_id: @exercise.id, user_id: current_user.id).first + submission = current_user.submissions.where(exercise_id: @exercise.id).order('created_at DESC').first rescue nil + + if @exercise + @uef = UserExerciseFeedback.new(uef_params) + if validate_inputs(uef_params) + authorize! + path = + if rfc && submission && submission.normalized_score == 1.0 + request_for_comment_path(rfc) + else + implement_exercise_path(@exercise) + end + create_and_respond(object: @uef, path: proc{path}) + else + flash[:danger] = t('shared.message_failure') + redirect_to(:back, id: uef_params[:exercise_id]) + end + end + + end + + def destroy + destroy_and_respond(object: @tag) + end + + def edit + @texts = comment_presets.to_a + @times = time_presets.to_a + authorize! + end + + def uef_params + params[:user_exercise_feedback].permit(:feedback_text, :difficulty, :exercise_id, :user_estimated_worktime).merge(user_id: current_user.id, user_type: current_user.class.name) + end + private :uef_params + + def new + @texts = comment_presets.to_a + @times = time_presets.to_a + @uef = UserExerciseFeedback.new + @exercise = Exercise.find(params[:user_exercise_feedback][:exercise_id]) + authorize! + end + + def update + submission = current_user.submissions.where(exercise_id: @exercise.id).order('created_at DESC').first rescue nil + rfc = RequestForComment.unsolved.where(exercise_id: @exercise.id, user_id: current_user.id).first + authorize! + if @exercise && validate_inputs(uef_params) + path = + if rfc && submission && submission.normalized_score == 1.0 + request_for_comment_path(rfc) + else + implement_exercise_path(@exercise) + end + update_and_respond(object: @uef, params: uef_params, path: path) + else + flash[:danger] = t('shared.message_failure') + redirect_to(:back, id: uef_params[:exercise_id]) + end + end + + def to_s + name + end + + def set_user_exercise_feedback + @exercise = Exercise.find(params[:user_exercise_feedback][:exercise_id]) + @uef = UserExerciseFeedback.find_by(exercise_id: params[:user_exercise_feedback][:exercise_id], user: current_user) + end + + def validate_inputs(uef_params) + begin + if uef_params[:difficulty].to_i < 0 || uef_params[:difficulty].to_i >= comment_presets.size + return false + elsif uef_params[:user_estimated_worktime].to_i < 0 || uef_params[:user_estimated_worktime].to_i >= time_presets.size + return false + else + return true + end + rescue + return false + end + end + +end \ No newline at end of file diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index e1773d48..5b1a04fe 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -18,6 +18,6 @@ class UserMailer < ActionMailer::Base @commenting_user_displayname = commenting_user.displayname @comment_text = comment.text @rfc_link = request_for_comment_url(request_for_comment) - mail(subject: t('mailers.user_mailer.got_new_comment.subject', commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email).deliver + mail(subject: t('mailers.user_mailer.got_new_comment.subject', commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email) end end diff --git a/app/models/concerns/user.rb b/app/models/concerns/user.rb index ee72a715..89855062 100644 --- a/app/models/concerns/user.rb +++ b/app/models/concerns/user.rb @@ -8,6 +8,11 @@ module User has_many :exercises, as: :user has_many :file_types, as: :user has_many :submissions, as: :user + has_many :user_proxy_exercise_exercises, as: :user + has_many :user_exercise_interventions, as: :user + has_many :interventions, through: :user_exercise_interventions + accepts_nested_attributes_for :user_proxy_exercise_exercises + scope :with_submissions, -> { where('id IN (SELECT user_id FROM submissions)') } end @@ -21,6 +26,6 @@ module User end def to_s - name + displayname end end diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 29f260c2..58d328ae 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -12,6 +12,15 @@ class Exercise < ActiveRecord::Base belongs_to :execution_environment has_many :submissions + has_and_belongs_to_many :proxy_exercises + has_many :user_proxy_exercise_exercises + has_and_belongs_to_many :exercise_collections + has_many :user_exercise_interventions + has_many :interventions, through: :user_exercise_interventions + has_many :exercise_tags + has_many :tags, through: :exercise_tags + accepts_nested_attributes_for :exercise_tags + 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 @@ -48,17 +57,21 @@ class Exercise < ActiveRecord::Base return 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) + end + def user_working_time_query """ SELECT user_id, sum(working_time_new) AS working_time FROM (SELECT user_id, - CASE WHEN working_time >= '0:30:00' THEN '0' ELSE working_time END AS working_time_new + CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new FROM (SELECT user_id, id, - (created_at - lag(created_at) over (PARTITION BY user_id + (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}) AS foo) AS bar @@ -66,6 +79,123 @@ class Exercise < ActiveRecord::Base """ end + def get_quantiles(quantiles) + quantiles_str = "[" + quantiles.join(",") + "]" + result = 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_type = 'ExternalUser' + 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), + -- 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), + -- 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 >= '0:05:00' THEN '0' + ELSE working_time + END AS working_time_new + FROM all_working_times_until_max ), result AS + ( + 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 ) + 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} + else + quantiles.map{|q| 0} + end + + end + def retrieve_working_time_statistics @working_time_statistics = {} self.class.connection.execute(user_working_time_query).each do |tuple| @@ -88,23 +218,64 @@ class Exercise < ActiveRecord::Base @working_time_statistics[user_id]["working_time"] end - def average_working_time_for_only(user_id) - self.class.connection.execute(""" - SELECT sum(working_time_new) AS working_time - FROM - (SELECT CASE WHEN working_time >= '0:30:00' THEN '0' ELSE working_time END AS working_time_new - FROM - (SELECT id, - (created_at - lag(created_at) over (PARTITION BY user_id - ORDER BY created_at)) AS working_time - FROM submissions - WHERE exercise_id=#{id} and user_id=#{user_id}) AS foo) AS bar - """).first["working_time"] + 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), + + -- 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), + + 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))), + + FILTERED_TIMES_UNTIL_MAX AS + ( + SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= '0:05:00' 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 end def duplicate(attributes = {}) exercise = dup exercise.attributes = attributes + exercise_tags.each { |et| exercise.exercise_tags << et.dup } files.each { |file| exercise.files << file.dup } exercise end @@ -162,8 +333,16 @@ class Exercise < ActiveRecord::Base end private :generate_token - def maximum_score - files.teacher_defined_tests.sum(:weight) + def maximum_score(user = nil) + if user + submissions.where(user: user).where("cause IN ('submit','assess')").where("score IS NOT NULL").order("score DESC").first.score || 0 rescue 0 + else + files.teacher_defined_tests.sum(:weight) + end + end + + def has_user_solved(user) + return maximum_score(user).to_i == maximum_score.to_i end def set_default_values diff --git a/app/models/exercise_collection.rb b/app/models/exercise_collection.rb new file mode 100644 index 00000000..2dca0e9d --- /dev/null +++ b/app/models/exercise_collection.rb @@ -0,0 +1,5 @@ +class ExerciseCollection < ActiveRecord::Base + + has_and_belongs_to_many :exercises + +end \ No newline at end of file diff --git a/app/models/exercise_tag.rb b/app/models/exercise_tag.rb new file mode 100644 index 00000000..4b8ab3e5 --- /dev/null +++ b/app/models/exercise_tag.rb @@ -0,0 +1,13 @@ +class ExerciseTag < ActiveRecord::Base + + belongs_to :tag + belongs_to :exercise + + before_save :destroy_if_empty_exercise_or_tag + + private + def destroy_if_empty_exercise_or_tag + destroy if exercise_id.blank? || tag_id.blank? + end + +end \ No newline at end of file diff --git a/app/models/external_user.rb b/app/models/external_user.rb index 538f4997..54d5d526 100644 --- a/app/models/external_user.rb +++ b/app/models/external_user.rb @@ -5,8 +5,8 @@ class ExternalUser < ActiveRecord::Base validates :external_id, presence: true def displayname - result = name - if(consumer.name == 'openHPI') + result = "User " + id.to_s + if(!consumer.nil? && consumer.name == 'openHPI') result = Rails.cache.fetch("#{cache_key}/displayname", expires_in: 12.hours) do Xikolo::UserClient.get(external_id.to_s)[:display_name] end diff --git a/app/models/intervention.rb b/app/models/intervention.rb new file mode 100644 index 00000000..a6693450 --- /dev/null +++ b/app/models/intervention.rb @@ -0,0 +1,16 @@ +class Intervention < ActiveRecord::Base + + has_many :user_exercise_interventions + has_many :users, through: :user_exercise_interventions, source_type: "ExternalUser" + + def to_s + name + end + + def self.createDefaultInterventions + %w(BreakIntervention QuestionIntervention).each do |name| + Intervention.find_or_create_by(name: name) + end + end + +end \ No newline at end of file diff --git a/app/models/proxy_exercise.rb b/app/models/proxy_exercise.rb new file mode 100644 index 00000000..30cd7813 --- /dev/null +++ b/app/models/proxy_exercise.rb @@ -0,0 +1,258 @@ +class ProxyExercise < ActiveRecord::Base + + after_initialize :generate_token + after_initialize :set_reason + + has_and_belongs_to_many :exercises + has_many :user_proxy_exercise_exercises + + def count_files + exercises.count + end + + def set_reason + @reason = {} + end + + def generate_token + self.token ||= SecureRandom.hex(4) + end + private :generate_token + + def duplicate(attributes = {}) + proxy_exercise = dup + proxy_exercise.attributes = attributes + proxy_exercise + end + + def to_s + title + end + + def get_matching_exercise(user) + assigned_user_proxy_exercise = user_proxy_exercise_exercises.where(user: user).first + recommended_exercise = + if (assigned_user_proxy_exercise) + Rails.logger.debug("retrieved assigned exercise for user #{user.id}: Exercise #{assigned_user_proxy_exercise.exercise}" ) + assigned_user_proxy_exercise.exercise + else + matching_exercise = + if (token.eql? "47f4c736") + Rails.logger.debug("Proxy exercise with token 47f4c736, split user in groups..") + group = UserGroupSeparator.getGroupWeek2Testing(user) + Rails.logger.debug("user assigned to group #{group}") + case group + when :group_a + exercises.where(id: 348).first + when :group_b + exercises.where(id: 349).first + when :group_c + exercises.where(id: 350).first + when :group_d + exercises.where(id: 351).first + end + else + Rails.logger.debug("find new matching exercise for user #{user.id}" ) + begin + find_matching_exercise(user) + rescue #fallback + Rails.logger.error("finding matching exercise failed. Fall back to random exercise! Error: #{$!}" ) + @reason[:reason] = "fallback because of error" + @reason[:error] = "#{$!}" + exercises.shuffle.first + end + end + user.user_proxy_exercise_exercises << UserProxyExerciseExercise.create(user: user, exercise: matching_exercise, proxy_exercise: self, reason: @reason.to_json) + matching_exercise + + + end + recommended_exercise + end + + def find_matching_exercise(user) + user_group = UserGroupSeparator.getProxyExerciseGroup(user) + case user_group + when :dummy_assigment + rec_ex = select_easiest_exercise(exercises) + @reason[:reason] = "dummy group" + Rails.logger.debug("assigned user to dummy group, and gave him exercise: #{rec_ex.title}") + rec_ex + when :random_assigment + @reason[:reason] = "random group" + ex = exercises.where("expected_difficulty > 1").shuffle.first + Rails.logger.debug("assigned user to random group, and gave him exercise: #{ex.title}") + ex + when :recommended_assignment + exercises_user_has_accessed = user.submissions.where("cause IN ('submit','assess')").map{|s| s.exercise}.uniq.compact + tags_user_has_seen = exercises_user_has_accessed.map{|ex| ex.tags}.uniq.flatten + Rails.logger.debug("exercises_user_has_accessed #{exercises_user_has_accessed.map{|e|e.id}.join(",")}") + + # find exercises + potential_recommended_exercises = [] + exercises.where("expected_difficulty > 1").each do |ex| + ## find exercises which have only tags the user has already seen + if (ex.tags - tags_user_has_seen).empty? + potential_recommended_exercises << ex + end + end + Rails.logger.debug("potential_recommended_exercises: #{potential_recommended_exercises.map{|e|e.id}}") + # if all exercises contain tags which the user has never seen, recommend easiest exercise + if potential_recommended_exercises.empty? + Rails.logger.debug("matched easiest exercise in pool") + @reason[:reason] = "easiest exercise in pool. empty potential exercises" + select_easiest_exercise(exercises) + else + recommended_exercise = select_best_matching_exercise(user, exercises_user_has_accessed, potential_recommended_exercises) + recommended_exercise + end + end + + end + private :find_matching_exercise + + def select_best_matching_exercise(user, exercises_user_has_accessed, potential_recommended_exercises) + topic_knowledge_user_and_max = get_user_knowledge_and_max_knowledge(user, exercises_user_has_accessed) + Rails.logger.debug("topic_knowledge_user_and_max: #{topic_knowledge_user_and_max}") + Rails.logger.debug("potential_recommended_exercises: #{potential_recommended_exercises.size}: #{potential_recommended_exercises.map{|p| p.id}}") + topic_knowledge_user = topic_knowledge_user_and_max[:user_topic_knowledge] + topic_knowledge_max = topic_knowledge_user_and_max[:max_topic_knowledge] + current_users_knowledge_lack = {} + topic_knowledge_max.keys.each do |tag| + current_users_knowledge_lack[tag] = topic_knowledge_user[tag] / topic_knowledge_max[tag] + end + + relative_knowledge_improvement = {} + potential_recommended_exercises.each do |potex| + tags = potex.tags + relative_knowledge_improvement[potex] = 0.0 + Rails.logger.debug("review potential exercise #{potex.id}") + tags.each do |tag| + tag_ratio = potex.exercise_tags.where(tag: tag).first.factor.to_f / potex.exercise_tags.inject(0){|sum, et| sum += et.factor }.to_f + max_topic_knowledge_ratio = potex.expected_difficulty * tag_ratio + old_relative_loss_tag = topic_knowledge_user[tag] / topic_knowledge_max[tag] + new_relative_loss_tag = topic_knowledge_user[tag] / (topic_knowledge_max[tag] + max_topic_knowledge_ratio) + Rails.logger.debug("tag #{tag} old_relative_loss_tag #{old_relative_loss_tag}, new_relative_loss_tag #{new_relative_loss_tag}, tag_ratio #{tag_ratio}") + relative_knowledge_improvement[potex] += old_relative_loss_tag - new_relative_loss_tag + end + end + highest_difficulty_user_has_accessed = exercises_user_has_accessed.map{|e| e.expected_difficulty}.sort.last || 0 + best_matching_exercise = find_best_exercise(relative_knowledge_improvement, highest_difficulty_user_has_accessed) + @reason[:reason] = "best matching exercise" + @reason[:highest_difficulty_user_has_accessed] = highest_difficulty_user_has_accessed + @reason[:current_users_knowledge_lack] = current_users_knowledge_lack + @reason[:relative_knowledge_improvement] = relative_knowledge_improvement + + Rails.logger.debug("current users knowledge loss: " + current_users_knowledge_lack.map{|k,v| "#{k} => #{v}"}.to_s) + Rails.logger.debug("relative improvements #{relative_knowledge_improvement.map{|k,v| k.id.to_s + ':' + v.to_s}}") + best_matching_exercise + end + private :select_best_matching_exercise + + def find_best_exercise(relative_knowledge_improvement, highest_difficulty_user_has_accessed) + Rails.logger.debug("select most appropiate exercise for user. his highest difficulty was #{highest_difficulty_user_has_accessed}") + sorted_exercises = relative_knowledge_improvement.sort_by{|k,v| v}.reverse + + sorted_exercises.each do |ex,diff| + Rails.logger.debug("review exercise #{ex.id} diff: #{ex.expected_difficulty}") + if (ex.expected_difficulty - highest_difficulty_user_has_accessed) <= 1 + Rails.logger.debug("matched exercise #{ex.id}") + return ex + else + Rails.logger.debug("exercise #{ex.id} is too difficult") + end + end + easiest_exercise = sorted_exercises.min_by{|k,v| v}.first + Rails.logger.debug("no match, select easiest exercise as fallback #{easiest_exercise.id}") + easiest_exercise + end + private :find_best_exercise + + # [score][quantile] + def scoring_matrix + [ + [0 ,0 ,0 ,0 ,0 ], + [0.2,0.2,0.2,0.2,0.1], + [0.5,0.5,0.4,0.4,0.3], + [0.6,0.6,0.5,0.5,0.4], + [1 ,1 ,0.9,0.8,0.7], + ] + end + + def scoring_matrix_quantiles + [0.2,0.4,0.6,0.8] + end + private :scoring_matrix_quantiles + + def score(user, ex) + max_score = ex.maximum_score.to_f + if max_score <= 0 + Rails.logger.debug("scoring user #{user.id} for exercise #{ex.id}: score: 0" ) + return 0.0 + end + points_ratio = ex.maximum_score(user) / max_score + if points_ratio == 0.0 + Rails.logger.debug("scoring user #{user.id} for exercise #{ex.id}: points_ratio=#{points_ratio} score: 0" ) + return 0.0 + end + points_ratio_index = ((scoring_matrix.size - 1) * points_ratio).to_i + working_time_user = ex.accumulated_working_time_for_only(user) + quantiles_working_time = ex.get_quantiles(scoring_matrix_quantiles) + quantile_index = quantiles_working_time.size + quantiles_working_time.each_with_index do |quantile_time, i| + if working_time_user <= quantile_time + quantile_index = i + break + end + end + Rails.logger.debug( + "scoring user #{user.id} exercise #{ex.id}: worktime #{working_time_user}, points: #{points_ratio}" \ + "(index #{points_ratio_index}) quantiles #{quantiles_working_time} placed into quantile index #{quantile_index} " \ + "score: #{scoring_matrix[points_ratio_index][quantile_index]}") + scoring_matrix[points_ratio_index][quantile_index] + end + private :score + + def get_user_knowledge_and_max_knowledge(user, exercises) + # initialize knowledge for each tag with 0 + all_used_tags_with_count = {} + exercises.each do |ex| + ex.tags.each do |t| + all_used_tags_with_count[t] ||= 0 + all_used_tags_with_count[t] += 1 + end + end + tags_counter = all_used_tags_with_count.keys.map{|tag| [tag,0]}.to_h + topic_knowledge_loss_user = all_used_tags_with_count.keys.map{|t| [t, 0]}.to_h + topic_knowledge_max = all_used_tags_with_count.keys.map{|t| [t, 0]}.to_h + exercises_sorted = exercises.sort_by { |ex| ex.time_maximum_score(user)} + exercises_sorted.each do |ex| + Rails.logger.debug("exercise: #{ex.id}: #{ex}") + user_score_factor = score(user, ex) + ex.tags.each do |t| + tags_counter[t] += 1 + tag_diminishing_return_factor = tag_diminishing_return_function(tags_counter[t], all_used_tags_with_count[t]) + tag_ratio = ex.exercise_tags.where(tag: t).first.factor.to_f / ex.exercise_tags.inject(0){|sum, et| sum += et.factor }.to_f + Rails.logger.debug("tag: #{t}, factor: #{ex.exercise_tags.where(tag: t).first.factor}, sumall: #{ex.exercise_tags.inject(0){|sum, et| sum += et.factor }}") + Rails.logger.debug("tag #{t}, count #{tags_counter[t]}, max: #{all_used_tags_with_count[t]}, factor: #{tag_diminishing_return_factor}") + Rails.logger.debug("tag_ratio #{tag_ratio}") + topic_knowledge_ratio = ex.expected_difficulty * tag_ratio + Rails.logger.debug("topic_knowledge_ratio #{topic_knowledge_ratio}") + topic_knowledge_loss_user[t] += (1 - user_score_factor) * topic_knowledge_ratio * tag_diminishing_return_factor + topic_knowledge_max[t] += topic_knowledge_ratio * tag_diminishing_return_factor + end + end + {user_topic_knowledge: topic_knowledge_loss_user, max_topic_knowledge: topic_knowledge_max} + end + private :get_user_knowledge_and_max_knowledge + + def tag_diminishing_return_function(count_tag, total_count_tag) + total_count_tag += 1 # bonus exercise comes on top + return 1/(1+(Math::E**(-3/(0.5*total_count_tag)*(count_tag-0.5*total_count_tag)))) + end + + def select_easiest_exercise(exercises) + exercises.order(:expected_difficulty).first + end + +end \ No newline at end of file diff --git a/app/models/search.rb b/app/models/search.rb new file mode 100644 index 00000000..f22dbc3e --- /dev/null +++ b/app/models/search.rb @@ -0,0 +1,4 @@ +class Search < ActiveRecord::Base + belongs_to :user, polymorphic: true + belongs_to :exercise +end \ No newline at end of file diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 00000000..002ec687 --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,22 @@ +class Tag < ActiveRecord::Base + + has_many :exercise_tags + has_many :exercises, through: :exercise_tags + + validates_uniqueness_of :name + + def destroy + if (can_be_destroyed?) + super + end + end + + def can_be_destroyed? + !exercises.any? + end + + def to_s + name + end + +end \ No newline at end of file diff --git a/app/models/user_exercise_feedback.rb b/app/models/user_exercise_feedback.rb new file mode 100644 index 00000000..78d84972 --- /dev/null +++ b/app/models/user_exercise_feedback.rb @@ -0,0 +1,11 @@ +class UserExerciseFeedback < ActiveRecord::Base + include Creation + + belongs_to :exercise + + validates :user_id, uniqueness: { scope: [:exercise_id, :user_type] } + + def to_s + "User Exercise Feedback" + end +end \ No newline at end of file diff --git a/app/models/user_exercise_intervention.rb b/app/models/user_exercise_intervention.rb new file mode 100644 index 00000000..60824c34 --- /dev/null +++ b/app/models/user_exercise_intervention.rb @@ -0,0 +1,11 @@ +class UserExerciseIntervention < ActiveRecord::Base + + belongs_to :user, polymorphic: true + belongs_to :intervention + belongs_to :exercise + + validates :user, presence: true + validates :exercise, presence: true + validates :intervention, presence: true + +end \ No newline at end of file diff --git a/app/models/user_proxy_exercise_exercise.rb b/app/models/user_proxy_exercise_exercise.rb new file mode 100644 index 00000000..e7defae6 --- /dev/null +++ b/app/models/user_proxy_exercise_exercise.rb @@ -0,0 +1,14 @@ +class UserProxyExerciseExercise < ActiveRecord::Base + + belongs_to :user, polymorphic: true + belongs_to :exercise + belongs_to :proxy_exercise + + validates :user_id, presence: true + validates :user_type, presence: true + validates :exercise_id, presence: true + validates :proxy_exercise_id, presence: true + + validates :user_id, uniqueness: { scope: [:proxy_exercise_id, :user_type] } + +end \ No newline at end of file diff --git a/app/policies/exercise_policy.rb b/app/policies/exercise_policy.rb index 55f7d16b..54d22b87 100644 --- a/app/policies/exercise_policy.rb +++ b/app/policies/exercise_policy.rb @@ -16,7 +16,7 @@ class ExercisePolicy < AdminOrAuthorPolicy define_method(action) { admin? || author?} end - [:implement?, :submit?, :reload?].each do |action| + [:implement?, :working_times?, :intervention?, :search?, :submit?, :reload?].each do |action| define_method(action) { everyone } end diff --git a/app/policies/intervention_policy.rb b/app/policies/intervention_policy.rb new file mode 100644 index 00000000..b3a25667 --- /dev/null +++ b/app/policies/intervention_policy.rb @@ -0,0 +1,34 @@ +class InterventionPolicy < AdminOrAuthorPolicy + def author? + @user == @record.author + end + private :author? + + def batch_update? + admin? + end + + def show? + @user.internal_user? + end + + [:clone?, :destroy?, :edit?, :update?].each do |action| + define_method(action) { admin? || author?} + end + + [:reload?].each do |action| + define_method(action) { everyone } + end + + class Scope < Scope + def resolve + if @user.admin? + @scope.all + elsif @user.internal_user? + @scope.where('user_id = ? OR public = TRUE', @user.id) + else + @scope.none + end + end + end +end diff --git a/app/policies/proxy_exercise_policy.rb b/app/policies/proxy_exercise_policy.rb new file mode 100644 index 00000000..28de525e --- /dev/null +++ b/app/policies/proxy_exercise_policy.rb @@ -0,0 +1,34 @@ +class ProxyExercisePolicy < AdminOrAuthorPolicy + def author? + @user == @record.author + end + private :author? + + def batch_update? + admin? + end + + def show? + @user.internal_user? + end + + [:clone?, :destroy?, :edit?, :update?].each do |action| + define_method(action) { admin? || author?} + end + + [:reload?].each do |action| + define_method(action) { everyone } + end + + class Scope < Scope + def resolve + if @user.admin? + @scope.all + elsif @user.internal_user? + @scope.where('user_id = ? OR public = TRUE', @user.id) + else + @scope.none + end + end + end +end diff --git a/app/policies/request_for_comment_policy.rb b/app/policies/request_for_comment_policy.rb index f592e3bd..0c5fcb4c 100644 --- a/app/policies/request_for_comment_policy.rb +++ b/app/policies/request_for_comment_policy.rb @@ -8,6 +8,10 @@ class RequestForCommentPolicy < ApplicationPolicy everyone end + def search? + everyone + end + def show? everyone end @@ -27,4 +31,8 @@ class RequestForCommentPolicy < ApplicationPolicy def index? everyone end + + def create_comment_exercise? + everyone + end end diff --git a/app/policies/search_policy.rb b/app/policies/search_policy.rb new file mode 100644 index 00000000..9da9a641 --- /dev/null +++ b/app/policies/search_policy.rb @@ -0,0 +1,34 @@ +class SearchPolicy < AdminOrAuthorPolicy + def author? + @user == @record.author + end + private :author? + + def batch_update? + admin? + end + + def show? + @user.internal_user? + end + + [:clone?, :destroy?, :edit?, :update?].each do |action| + define_method(action) { admin? || author?} + end + + [:reload?].each do |action| + define_method(action) { everyone } + end + + class Scope < Scope + def resolve + if @user.admin? + @scope.all + elsif @user.internal_user? + @scope.where('user_id = ? OR public = TRUE', @user.id) + else + @scope.none + end + end + end +end diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb new file mode 100644 index 00000000..8325b9fa --- /dev/null +++ b/app/policies/tag_policy.rb @@ -0,0 +1,34 @@ +class TagPolicy < AdminOrAuthorPolicy + def author? + @user == @record.author + end + private :author? + + def batch_update? + admin? + end + + def show? + @user.internal_user? + end + + [:clone?, :destroy?, :edit?, :update?].each do |action| + define_method(action) { admin? || author?} + end + + [:reload?].each do |action| + define_method(action) { everyone } + end + + class Scope < Scope + def resolve + if @user.admin? + @scope.all + elsif @user.internal_user? + @scope.where('user_id = ? OR public = TRUE', @user.id) + else + @scope.none + end + end + end +end diff --git a/app/policies/user_exercise_feedback_policy.rb b/app/policies/user_exercise_feedback_policy.rb new file mode 100644 index 00000000..20a89a6e --- /dev/null +++ b/app/policies/user_exercise_feedback_policy.rb @@ -0,0 +1,19 @@ +class UserExerciseFeedbackPolicy < ApplicationPolicy + def author? + @user == @record.author + end + private :author? + + def create? + everyone + end + + def new? + everyone + end + + [:show? ,:destroy?, :edit?, :update?].each do |action| + define_method(action) { admin? || author?} + end + +end diff --git a/app/views/application/_flash.html.slim b/app/views/application/_flash.html.slim index 0f854179..a391ee23 100644 --- a/app/views/application/_flash.html.slim +++ b/app/views/application/_flash.html.slim @@ -1,3 +1,3 @@ -#flash data-message-failure=t('shared.message_failure') +#flash.fixed_error_messages.clickthrough data-message-failure=t('shared.message_failure') - %w[alert danger info notice success warning].each do |severity| p.alert.flash class="alert-#{{'alert' => 'warning', 'notice' => 'success'}.fetch(severity, severity)}" id="flash-#{severity}" = flash[severity] diff --git a/app/views/exercises/_comment_dialogcontent.html.slim b/app/views/exercises/_comment_dialogcontent.html.slim index 0d89bea3..4c78035a 100644 --- a/app/views/exercises/_comment_dialogcontent.html.slim +++ b/app/views/exercises/_comment_dialogcontent.html.slim @@ -5,5 +5,5 @@ textarea.form-control(style='resize:none;') h5 =t('exercises.implement.comment.others') pre#otherCommentsTextfield p = '' -button#addCommentButton.btn.btn-block.btn-primary(type='button') =t('exercises.implement.comment.addComment') +button#addCommentButton.btn.btn-block.btn-primary(type='button') =t('exercises.implement.comment.addCommentButton') button#removeAllButton.btn.btn-block.btn-warning(type='button') =t('exercises.implement.comment.removeAllOnLine') \ No newline at end of file diff --git a/app/views/exercises/_editor.html.slim b/app/views/exercises/_editor.html.slim index 56986673..4291028f 100644 --- a/app/views/exercises/_editor.html.slim +++ b/app/views/exercises/_editor.html.slim @@ -1,7 +1,9 @@ - external_user_external_id = @current_user.respond_to?(:external_id) ? @current_user.external_id : '' #'tests' #(@current_user.uuid.present? ? @current_user.uuid : '') - external_user_id = @current_user.respond_to?(:external_id) ? @current_user.id : '' #'tests' #(@current_user.uuid.present? ? @current_user.uuid : '') - consumer_id = @current_user.respond_to?(:external_id) ? @current_user.consumer_id : '' #'tests' #(@current_user.uuid.present? ? @current_user.uuid : '') -#editor.row data-exercise-id=exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-errors-url=execution_environment_errors_path(exercise.execution_environment) data-submissions-url=submissions_path data-user-id=@current_user.id data-user-external-id=external_user_external_id +- show_break_interventions = @show_break_interventions || "false" +- show_rfc_interventions = @show_rfc_interventions || "false" +#editor.row data-exercise-id=exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-errors-url=execution_environment_errors_path(exercise.execution_environment) data-submissions-url=submissions_path data-user-id=@current_user.id data-user-external-id=external_user_external_id data-working-times-url=working_times_exercise_path data-intervention-save-url=intervention_exercise_path data-rfc-interventions=show_rfc_interventions data-break-interventions=show_break_interventions data-course_token=@course_token data-search-save-url=search_exercise_path div id="sidebar" class=(@exercise.hide_file_tree ? 'sidebar-col-collapsed' : 'sidebar-col') = render('editor_file_tree', exercise: @exercise, files: @files) div id='output_sidebar' class='output-col-collapsed' = render('exercises/editor_output', external_user_id: external_user_id, consumer_id: consumer_id ) div id='frames' class='editor-col' @@ -22,3 +24,4 @@ = render('shared/modal', id: 'comment-modal', title: t('exercises.implement.comment.request'), template: 'exercises/_request_comment_dialogcontent') += render('shared/modal', id: 'break-intervention-modal', title: t('exercises.implement.break_intervention.title'), template: 'interventions/_break_intervention_modal') \ No newline at end of file diff --git a/app/views/exercises/_editor_file_tree.html.slim b/app/views/exercises/_editor_file_tree.html.slim index 16cc705b..3f8334dc 100644 --- a/app/views/exercises/_editor_file_tree.html.slim +++ b/app/views/exercises/_editor_file_tree.html.slim @@ -6,6 +6,8 @@ div id='sidebar-collapsed' class=(@exercise.hide_file_tree ? '' : 'hidden') = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', data: {:'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-download', id: 'download-collapsed', label:'', title: t('exercises.editor.download')) = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', data: {:'data-message-confirm' => t('exercises.editor.confirm_start_over'), :'data-url' => reload_exercise_path(@exercise), :'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-history', id: 'start-over-collapsed', label:'', title: t('exercises.editor.start_over')) + - if !@course_token.blank? + = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', data: {:'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-search', id: 'sidebar-search-collapsed', label: '', title: t('search.search_in_forum')) div id='sidebar-uncollapsed' class=(@exercise.hide_file_tree ? 'hidden' : '') = render('editor_button', classes: 'btn-block btn-primary btn-sm', icon: 'fa fa-minus-square', id: 'sidebar-collapse', label: t('exercises.editor.collapse_action_sidebar')) @@ -24,5 +26,13 @@ div id='sidebar-uncollapsed' class=(@exercise.hide_file_tree ? 'hidden' : '') = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', icon: 'fa fa-download', id: 'download', label: t('exercises.editor.download')) = render('editor_button', classes: 'btn-block btn-primary btn-sm', data: {:'data-message-confirm' => t('exercises.editor.confirm_start_over'), :'data-url' => reload_exercise_path(@exercise)}, icon: 'fa fa-history', id: 'start-over', label: t('exercises.editor.start_over')) + - if !@course_token.blank? + .input-group.enforce-top-margin + .enforce-right-margin + = text_field_tag 'search-input-text', nil, placeholder: t('search.search_in_forum'), class: 'form-control' + .input-group-btn + = button_tag(class: 'btn btn-primary', id: 'btn-search-col') do + i.fa.fa-search + - if @exercise.allow_file_creation? = render('shared/modal', id: 'modal-file', template: 'code_ocean/files/_form', title: t('exercises.editor.create_file')) \ No newline at end of file diff --git a/app/views/exercises/_form.html.slim b/app/views/exercises/_form.html.slim index c4159254..5ab8502b 100644 --- a/app/views/exercises/_form.html.slim +++ b/app/views/exercises/_form.html.slim @@ -32,6 +32,25 @@ label = f.check_box(:allow_auto_completion) = t('activerecord.attributes.exercise.allow_auto_completion') + .form-group + = f.label(t('activerecord.attributes.exercise.difficulty')) + = f.number_field :expected_difficulty, in: 1..10, step: 1 + .form-group + = f.label(t('activerecord.attributes.exercise.worktime')) + = f.number_field "expected_worktime_minutes", value: @exercise.expected_worktime_seconds / 60, in: 1..1000, step: 1 + h2 Tags + .table-responsive + table.table#tags-table + thead + tr + th = t('activerecord.attributes.exercise.selection') + th = sort_link(@search, :title, t('activerecord.attributes.tag.name')) + th = t('activerecord.attributes.tag.difficulty') + = collection_check_boxes :exercise, :tag_ids, @exercise_tags, :tag_id, :id do |b| + tr + td = b.check_box + td = b.object.tag.name + td = number_field "tag_factors[#{b.object.tag.id}]", :factor, :value => b.object.factor, in: 1..10, step: 1 h2 = t('activerecord.attributes.exercise.files') ul#files.list-unstyled.panel-group = f.fields_for :files do |files_form| diff --git a/app/views/exercises/_request_comment_dialogcontent.html.slim b/app/views/exercises/_request_comment_dialogcontent.html.slim index 677ccb12..8fb71781 100644 --- a/app/views/exercises/_request_comment_dialogcontent.html.slim +++ b/app/views/exercises/_request_comment_dialogcontent.html.slim @@ -1,6 +1,11 @@ +h5#rfc_intervention_text style='display: none;' = t('exercises.implement.rfc_intervention.text') h5 = t('exercises.implement.comment.question') + + textarea.form-control#question(style='resize:none;') p = '' / data-cause='requestComments' is not used here right now, we pass the button #requestComments (not askForCommentsButton) as initiator of the action. -/ But if we use this button, it will work since the correct cause is supplied -button#askForCommentsButton.btn.btn-block.btn-primary(type='button' data-cause='requestComments' data-message-success=t('exercises.editor.request_for_comments_sent')) =t('exercises.implement.comment.request') \ No newline at end of file +/ But if we use this button, it will work since the correct cause is supplied +div + button#askForCommentsButton.btn.btn-block.btn-primary(type='button' data-cause='requestComments' data-message-success=t('exercises.editor.request_for_comments_sent')) =t('exercises.implement.comment.request') + button#closeAskForCommentsButton.btn.btn-block.btn-warning(type='button') =t('activerecord.attributes.request_for_comments.close') diff --git a/app/views/exercises/external_users/statistics.html.slim b/app/views/exercises/external_users/statistics.html.slim index 3c755be6..313a19b0 100644 --- a/app/views/exercises/external_users/statistics.html.slim +++ b/app/views/exercises/external_users/statistics.html.slim @@ -1,6 +1,8 @@ h1 = "#{@exercise} (external user #{@external_user})" - submissions = Submission.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id).order("created_at") - current_submission = submissions.first +- submissions_and_interventions = (submissions + UserExerciseIntervention.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id)).sort_by { |a| a.created_at } + - if current_submission - initial_files = current_submission.files.to_a @@ -41,20 +43,26 @@ h1 = "#{@exercise} (external user #{@external_user})" - ['.time', '.cause', '.score', '.tests', '.time_difference'].each do |title| th.header = t(title) tbody - - deltas = submissions.map.with_index {|item, index| delta = item.created_at - submissions[index - 1].created_at if index > 0; if delta == nil or delta > 30*60 then 0 else delta end} - - submissions.each_with_index do |submission, index| - tr data-id=submission.id - td.clickable = submission.created_at.strftime("%F %T") - td = submission.cause - td = submission.score - td - -submission.testruns.each do |run| - - if run.passed - .unit-test-result.positive-result title=run.output - - else - .unit-test-result.negative-result title=run.output - td = Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0 - -working_times_until.push((Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0)) + - deltas = submissions.map.with_index {|item, index| delta = item.created_at - submissions[index - 1].created_at if index > 0; if delta == nil or delta > 10*60 then 0 else delta end} + - submissions_and_interventions.each_with_index do |submission_or_intervention, index| + tr data-id=submission_or_intervention.id + td.clickable = submission_or_intervention.created_at.strftime("%F %T") + - if submission_or_intervention.is_a?(Submission) + td = submission_or_intervention.cause + td = submission_or_intervention.score + td + -submission_or_intervention.testruns.each do |run| + - if run.passed + .unit-test-result.positive-result title=run.output + - else + .unit-test-result.negative-result title=run.output + td = Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0 + -working_times_until.push((Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0)) + - elsif submission_or_intervention.is_a? UserExerciseIntervention + td = submission_or_intervention.intervention.name + td = + td = + td = p = t('.addendum') .hidden#wtimes data-working_times=ActiveSupport::JSON.encode(working_times_until); div#progress_chart.col-lg-12 diff --git a/app/views/exercises/implement.html.slim b/app/views/exercises/implement.html.slim index 87ff4e1f..300775a6 100644 --- a/app/views/exercises/implement.html.slim +++ b/app/views/exercises/implement.html.slim @@ -22,3 +22,4 @@ #questions-column #questions-holder data-url="#{qa_url}/qa/index/#{@exercise.id}/#{@user_id}" = qa_js_tag + diff --git a/app/views/exercises/index.html.slim b/app/views/exercises/index.html.slim index 9ad5cc25..bd8fe880 100644 --- a/app/views/exercises/index.html.slim +++ b/app/views/exercises/index.html.slim @@ -16,6 +16,9 @@ h1 = Exercise.model_name.human(count: 2) th = sort_link(@search, :execution_environment_id, t('activerecord.attributes.exercise.execution_environment')) th = t('.test_files') th = t('activerecord.attributes.exercise.maximum_score') + th = t('activerecord.attributes.exercise.tags') + th = t('activerecord.attributes.exercise.difficulty') + th = t('activerecord.attributes.exercise.worktime') th = t('activerecord.attributes.exercise.public') - if policy(Exercise).batch_update? @@ -29,6 +32,9 @@ h1 = Exercise.model_name.human(count: 2) td = link_to_if(exercise.execution_environment && policy(exercise.execution_environment).show?, exercise.execution_environment, exercise.execution_environment) td = exercise.files.teacher_defined_tests.count td = exercise.maximum_score + td = exercise.exercise_tags.count + td = exercise.expected_difficulty + td = (exercise.expected_worktime_seconds / 60).ceil td.public data-value=exercise.public? = symbol_for(exercise.public?) td = link_to(t('shared.edit'), edit_exercise_path(exercise)) if policy(exercise).edit? td = link_to(t('.implement'), implement_exercise_path(exercise)) if policy(exercise).implement? diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim index 902f8135..1efbd612 100644 --- a/app/views/exercises/show.html.slim +++ b/app/views/exercises/show.html.slim @@ -19,6 +19,9 @@ h1 = row(label: 'exercise.allow_auto_completion', value: @exercise.allow_auto_completion?) = row(label: 'exercise.embedding_parameters') do = content_tag(:input, nil, class: 'form-control', readonly: true, value: embedding_parameters(@exercise)) += row(label: 'exercise.difficulty', value: @exercise.expected_difficulty) += row(label: 'exercise.worktime', value: "#{@exercise.expected_worktime_seconds/60} min") += row(label: 'exercise.tags', value: @exercise.exercise_tags.map{|et| "#{et.tag.name} (#{et.factor})"}.sort.join(", ")) h2 = t('activerecord.attributes.exercise.files') diff --git a/app/views/interventions/_break_intervention_modal.html.slim b/app/views/interventions/_break_intervention_modal.html.slim new file mode 100644 index 00000000..12f0e314 --- /dev/null +++ b/app/views/interventions/_break_intervention_modal.html.slim @@ -0,0 +1 @@ +h5 = t('exercises.implement.break_intervention.text') diff --git a/app/views/interventions/_form.html.slim b/app/views/interventions/_form.html.slim new file mode 100644 index 00000000..6ffe7397 --- /dev/null +++ b/app/views/interventions/_form.html.slim @@ -0,0 +1,6 @@ += form_for(@intervention) do |f| + = render('shared/form_errors', object: @intervention) + .form-group + = f.label(:name) + = f.text_field(:name, class: 'form-control', required: true) + .actions = render('shared/submit_button', f: f, object: @intervention) diff --git a/app/views/interventions/index.html.slim b/app/views/interventions/index.html.slim new file mode 100644 index 00000000..fc7afe05 --- /dev/null +++ b/app/views/interventions/index.html.slim @@ -0,0 +1,14 @@ +h1 = Intervention.model_name.human(count: 2) + +.table-responsive + table.table + thead + tr + th = t('activerecord.attributes.intervention.name') + tbody + - @interventions.each do |intervention| + tr + td = intervention.name + td = link_to(t('shared.show'), intervention) + += render('shared/pagination', collection: @interventions) diff --git a/app/views/interventions/show.html.slim b/app/views/interventions/show.html.slim new file mode 100644 index 00000000..f9202240 --- /dev/null +++ b/app/views/interventions/show.html.slim @@ -0,0 +1,4 @@ +h1 + = @intervention.name + += row(label: 'intervention.name', value: @intervention.name) diff --git a/app/views/proxy_exercises/_form.html.slim b/app/views/proxy_exercises/_form.html.slim new file mode 100644 index 00000000..bd57bf06 --- /dev/null +++ b/app/views/proxy_exercises/_form.html.slim @@ -0,0 +1,24 @@ += form_for(@proxy_exercise, multipart: true) do |f| + = render('shared/form_errors', object: @proxy_exercise) + .form-group + = f.label(:title) + = f.text_field(:title, class: 'form-control', required: true) + .form-group + = f.label(:description) + = f.pagedown_editor :description + + h3 Exercises + .table-responsive + table.table + thead + tr + th = t('activerecord.attributes.exercise.selection') + th = sort_link(@search, :title, t('activerecord.attributes.submission.exercise')) + th = sort_link(@search, :created_at, t('shared.created_at')) + = collection_check_boxes :proxy_exercise, :exercise_ids, @exercises, :id, :title do |b| + tr + td = b.check_box + td = link_to(b.object, b.object) + td = l(b.object.created_at, format: :short) + + .actions = render('shared/submit_button', f: f, object: @proxy_exercise) \ No newline at end of file diff --git a/app/views/proxy_exercises/edit.html.slim b/app/views/proxy_exercises/edit.html.slim new file mode 100644 index 00000000..8aa200c9 --- /dev/null +++ b/app/views/proxy_exercises/edit.html.slim @@ -0,0 +1,3 @@ +h1 = t('activerecord.models.proxy_exercise.one', model: ProxyExercise.model_name.human)+ ": " + @proxy_exercise.title + += render('form') diff --git a/app/views/proxy_exercises/index.html.slim b/app/views/proxy_exercises/index.html.slim new file mode 100644 index 00000000..2a8067c1 --- /dev/null +++ b/app/views/proxy_exercises/index.html.slim @@ -0,0 +1,35 @@ +h1 = ProxyExercise.model_name.human(count: 2) + += render(layout: 'shared/form_filters') do |f| + .form-group + = f.label(:title_cont, t('activerecord.attributes.proxy_exercise.title'), class: 'sr-only') + = f.search_field(:title_cont, class: 'form-control', placeholder: t('activerecord.attributes.proxy_exercise.title')) + +.table-responsive + table.table + thead + tr + th = sort_link(@search, :title, t('activerecord.attributes.proxy_exercise.title')) + th = "Token" + th = t('activerecord.attributes.proxy_exercise.files_count') + th colspan=6 = t('shared.actions') + tbody + - @proxy_exercises.each do |proxy_exercise| + tr data-id=proxy_exercise.id + td = link_to(proxy_exercise.title,proxy_exercise) + td = proxy_exercise.token + td = proxy_exercise.count_files + td = link_to(t('shared.edit'), edit_proxy_exercise_path(proxy_exercise)) if policy(proxy_exercise).edit? + + td + .btn-group + button.btn.btn-primary-outline.btn-xs.dropdown-toggle data-toggle="dropdown" type="button" = t('shared.actions_button') + span.caret + span.sr-only Toggle Dropdown + ul.dropdown-menu.pull-right role="menu" + li = link_to(t('shared.show'), proxy_exercise) if policy(proxy_exercise).show? + li = link_to(t('shared.destroy'), proxy_exercise, data: {confirm: t('shared.confirm_destroy')}, method: :delete) if policy(proxy_exercise).destroy? + li = link_to(t('.clone'), clone_proxy_exercise_path(proxy_exercise), data: {confirm: t('shared.confirm_destroy')}, method: :post) if policy(proxy_exercise).clone? + += render('shared/pagination', collection: @proxy_exercises) +p = render('shared/new_button', model: ProxyExercise) diff --git a/app/views/proxy_exercises/new.html.slim b/app/views/proxy_exercises/new.html.slim new file mode 100644 index 00000000..ae59a292 --- /dev/null +++ b/app/views/proxy_exercises/new.html.slim @@ -0,0 +1,3 @@ +h1 = t('shared.new_model', model: ProxyExercise.model_name.human) + += render('form') diff --git a/app/views/proxy_exercises/reload.json.jbuilder b/app/views/proxy_exercises/reload.json.jbuilder new file mode 100644 index 00000000..8e5d4e3c --- /dev/null +++ b/app/views/proxy_exercises/reload.json.jbuilder @@ -0,0 +1,3 @@ +json.set! :files do + json.array! @exercise.files.visible, :content, :id +end diff --git a/app/views/proxy_exercises/show.html.slim b/app/views/proxy_exercises/show.html.slim new file mode 100644 index 00000000..c1888d79 --- /dev/null +++ b/app/views/proxy_exercises/show.html.slim @@ -0,0 +1,23 @@ +- content_for :head do + = javascript_include_tag('http://cdnjs.cloudflare.com/ajax/libs/highlight.js/8.4/highlight.min.js') + = stylesheet_link_tag('http://cdnjs.cloudflare.com/ajax/libs/highlight.js/8.4/styles/default.min.css') + +h1 + = @proxy_exercise.title + - if policy(@proxy_exercise).edit? + = render('shared/edit_button', object: @proxy_exercise) + += row(label: 'exercise.title', value: @proxy_exercise.title) += row(label: 'proxy_exercise.files_count', value: @exercises.count) += row(label: 'exercise.description', value: @proxy_exercise.description) +h3 Exercises +.table-responsive + table.table + thead + tr + th = sort_link(@search, :title, t('activerecord.attributes.submission.exercise')) + th = sort_link(@search, :created_at, t('shared.created_at')) + - @proxy_exercise.exercises.each do |exercise| + tr + td = link_to(exercise.title, exercise) + td = l(exercise.created_at, format: :short) diff --git a/app/views/request_for_comments/index.html.slim b/app/views/request_for_comments/index.html.slim index b7ada0a2..db16b562 100644 --- a/app/views/request_for_comments/index.html.slim +++ b/app/views/request_for_comments/index.html.slim @@ -1,12 +1,20 @@ h1 = RequestForComment.model_name.human(count: 2) += render(layout: 'shared/form_filters') do |f| + .form-group + = f.label(:exercise_title_cont, t('activerecord.attributes.request_for_comments.exercise'), class: 'sr-only') + = f.search_field(:exercise_title_cont, class: 'form-control', placeholder: t('activerecord.attributes.request_for_comments.exercise')) + .form-group + = f.label(:title_cont, t('request_for_comments.solved'), class: 'sr-only') + = f.select(:solved_not_eq, [[t('request_for_comments.show_all'), 2], [t('request_for_comments.show_unsolved'), 1], [t('request_for_comments.show_solved'), 0]]) + .table-responsive table.table.sortable thead tr th i class="fa fa-lightbulb-o" aria-hidden="true" title = t('request_for_comments.solved') align="right" - th = t('activerecord.attributes.request_for_comments.exercise') + th = sort_link(@search, :title, t('activerecord.attributes.request_for_comments.exercise')) th = t('activerecord.attributes.request_for_comments.question') th i class="fa fa-comment" aria-hidden="true" title = t('request_for_comments.comments') align="center" diff --git a/app/views/request_for_comments/show.html.erb b/app/views/request_for_comments/show.html.erb index d0fe31af..0f68f2b0 100644 --- a/app/views/request_for_comments/show.html.erb +++ b/app/views/request_for_comments/show.html.erb @@ -1,5 +1,5 @@
<%
user = @request_for_comment.user
@@ -20,14 +20,17 @@
<%= t('activerecord.attributes.request_for_comments.question')%>: <%= t('request_for_comments.no_question') %>
<% end %>
+
<% if (policy(@request_for_comment).mark_as_solved? and not @request_for_comment.solved?) %>
-
+
<% elsif (@request_for_comment.solved?) %>
-
+
<% else %>
<% end %>
+
+
<% if @current_user.admin? && user.is_a?(ExternalUser) %>
@@ -41,14 +44,18 @@
<% end %>
+