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 @@
-

<%= link_to(@request_for_comment.exercise.title, [:implement, @request_for_comment.exercise]) %>

+

<%= link_to(@request_for_comment.exercise.title, [:implement, @request_for_comment.exercise]) %>

<% 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 %> +

+ <%= t('request_for_comments.howto_title') %>
<%= render_markdown(t('request_for_comments.howto')) %> +
- +
<% submission.files.each do |file| %> - <%= (file.path or "") + "/" + file.name + file.file_type.file_extension %> + <%= (file.path or "") + "/" + file.name + file.file_type.file_extension %>
+    <%= t('request_for_comments.click_here') %>
<%= file.content %>
<% end %> @@ -58,6 +65,8 @@ also, all settings from the rails model needed for the editor configuration in t diff --git a/app/views/searches/destroy.html.erb b/app/views/searches/destroy.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/shared/_file.html.slim b/app/views/shared/_file.html.slim index 1e50388c..bac52d1a 100644 --- a/app/views/shared/_file.html.slim +++ b/app/views/shared/_file.html.slim @@ -5,6 +5,6 @@ = row(label: 'file.hidden', value: file.hidden) = row(label: 'file.read_only', value: file.read_only) - if file.teacher_defined_test? - = row(label: 'file.feedback_message', value: file.feedback_message) + = row(label: 'file.feedback_message', value: render_markdown(file.feedback_message)) = row(label: 'file.weight', value: file.weight) = row(label: 'file.content', value: file.native_file? ? link_to(file.native_file.file.filename, file.native_file.url) : code_tag(file.content)) diff --git a/app/views/tags/_form.html.slim b/app/views/tags/_form.html.slim new file mode 100644 index 00000000..4f02a28f --- /dev/null +++ b/app/views/tags/_form.html.slim @@ -0,0 +1,6 @@ += form_for(@tag) do |f| + = render('shared/form_errors', object: @tag) + .form-group + = f.label(:name) + = f.text_field(:name, class: 'form-control', required: true) + .actions = render('shared/submit_button', f: f, object: @tag) diff --git a/app/views/tags/edit.html.slim b/app/views/tags/edit.html.slim new file mode 100644 index 00000000..23c76720 --- /dev/null +++ b/app/views/tags/edit.html.slim @@ -0,0 +1,3 @@ +h1 = @tag.name + += render('form') diff --git a/app/views/tags/index.html.slim b/app/views/tags/index.html.slim new file mode 100644 index 00000000..2d4916af --- /dev/null +++ b/app/views/tags/index.html.slim @@ -0,0 +1,19 @@ +h1 = Tag.model_name.human(count: 2) + +.table-responsive + table.table + thead + tr + th = t('activerecord.attributes.hint.name') + /th = t('activerecord.attributes.hint.locale') + /th colspan=3 = t('shared.actions') + tbody + - @tags.each do |tag| + tr + td = tag.name + td = link_to(t('shared.show'), tag) + td = link_to(t('shared.edit'), edit_tag_path(tag)) + td = link_to(t('shared.destroy'), tag, data: {confirm: t('shared.confirm_destroy')}, method: :delete) if tag.can_be_destroyed? + += render('shared/pagination', collection: @tags) +p = render('shared/new_button', model: Tag, path: new_tag_path) diff --git a/app/views/tags/new.html.slim b/app/views/tags/new.html.slim new file mode 100644 index 00000000..e5dbc4ee --- /dev/null +++ b/app/views/tags/new.html.slim @@ -0,0 +1,3 @@ +h1 = t('shared.new_model', model: Tag.model_name.human) + += render('form') diff --git a/app/views/tags/show.html.slim b/app/views/tags/show.html.slim new file mode 100644 index 00000000..81eda745 --- /dev/null +++ b/app/views/tags/show.html.slim @@ -0,0 +1,6 @@ +h1 + = @tag.name + = render('shared/edit_button', object: @tag) + += row(label: 'tag.name', value: @tag.name) += row(label: 'tag.usage', value: @tag.exercises.count) diff --git a/app/views/user_exercise_feedbacks/_form.html.slim b/app/views/user_exercise_feedbacks/_form.html.slim new file mode 100644 index 00000000..46eaf81e --- /dev/null +++ b/app/views/user_exercise_feedbacks/_form.html.slim @@ -0,0 +1,23 @@ += form_for(@uef) do |f| + div + span.badge.pull-right.score + + h1 id="exercise-headline" + = t('activerecord.models.user_exercise_feedback.one') + " " + = link_to(@exercise.title, [:implement, @exercise]) + = render('shared/form_errors', object: @uef) + h4 + == t('user_exercise_feedback.description') + #description-panel.lead.description-panel + u = t('activerecord.attributes.exercise.description') + = render_markdown(@exercise.description) + .form-group + = f.text_area(:feedback_text, class: 'form-control', required: true, :rows => "10") + h4 = t('user_exercise_feedback.difficulty') + = f.collection_radio_buttons :difficulty, @texts, :first, :last, html_options={class: "radio-inline"} do |b| + = b.label(:class => 'radio') { b.radio_button + b.text } + h4 = t('user_exercise_feedback.working_time') + = f.collection_radio_buttons :user_estimated_worktime, @times, :first, :last, html_options={class: "radio-inline"} do |b| + = b.label(:class => 'radio') { b.radio_button + b.text } + = f.hidden_field(:exercise_id, :value => @exercise.id) + .actions = render('shared/submit_button', f: f, object: @uef) diff --git a/app/views/user_exercise_feedbacks/edit.html.slim b/app/views/user_exercise_feedbacks/edit.html.slim new file mode 100644 index 00000000..7e5cfff1 --- /dev/null +++ b/app/views/user_exercise_feedbacks/edit.html.slim @@ -0,0 +1 @@ += render('form') diff --git a/app/views/user_exercise_feedbacks/new.html.slim b/app/views/user_exercise_feedbacks/new.html.slim new file mode 100644 index 00000000..7e5cfff1 --- /dev/null +++ b/app/views/user_exercise_feedbacks/new.html.slim @@ -0,0 +1 @@ += render('form') diff --git a/app/views/user_mailer/got_new_comment.slim b/app/views/user_mailer/got_new_comment.slim index 656e245d..1f06ae49 100644 --- a/app/views/user_mailer/got_new_comment.slim +++ b/app/views/user_mailer/got_new_comment.slim @@ -1 +1 @@ -== t('mailers.user_mailer.got_new_comment.body', receiver_displayname: @receiver_displayname, link: link_to(@rfc_link, @rfc_link), commenting_user_displayname: @commenting_user_displayname, comment_text: @comment_text) +== t('mailers.user_mailer.got_new_comment.body', receiver_displayname: @receiver_displayname, link_to_comment: link_to(@rfc_link, @rfc_link), commenting_user_displayname: @commenting_user_displayname, comment_text: @comment_text, link_my_comments: link_to(t('request_for_comments.index.get_my_comment_requests'), my_request_for_comments_url), link_all_comments: link_to(t('request_for_comments.index.all'), request_for_comments_url) ) diff --git a/config/application.rb b/config/application.rb index a9e3518b..97118237 100644 --- a/config/application.rb +++ b/config/application.rb @@ -28,7 +28,7 @@ module CodeOcean config.eager_load_paths << Rails.root.join('lib') config.assets.precompile += %w( markdown-buttons.png ) - config.active_record.schema_format = :sql + #config.active_record.schema_format = :sql case (RUBY_ENGINE) when 'ruby' diff --git a/config/deploy.rb b/config/deploy.rb index 173f2b56..f4b10182 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -4,7 +4,7 @@ set :default_env, 'PATH' => '/usr/java/jdk1.8.0_40/bin:$PATH' set :deploy_to, '/var/www/app' set :keep_releases, 3 set :linked_dirs, %w(log public/uploads tmp/cache tmp/files tmp/pids tmp/sockets) -set :linked_files, %w(config/action_mailer.yml config/code_ocean.yml config/database.yml config/newrelic.yml config/secrets.yml config/sendmail.yml config/smtp.yml) +set :linked_files, %w(config/action_mailer.yml config/docker.yml.erb config/code_ocean.yml config/database.yml config/newrelic.yml config/secrets.yml config/sendmail.yml config/smtp.yml) set :log_level, :info set :puma_threads, [0, 16] set :repo_url, 'git@github.com:openHPI/codeocean.git' diff --git a/config/docker.yml.erb b/config/docker.yml.erb index 8fac75d0..c1871de7 100644 --- a/config/docker.yml.erb +++ b/config/docker.yml.erb @@ -32,7 +32,7 @@ production: timeout: 60 workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %> ws_host: ws://localhost:4243 #url to connect rails server to docker host - ws_client_protocol: wss:// #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production) + ws_client_protocol: 'wss:' #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production) staging: <<: *default @@ -46,7 +46,7 @@ staging: timeout: 60 workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %> ws_host: ws://localhost:4243 #url to connect rails server to docker host - ws_client_protocol: wss:// #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production) + ws_client_protocol: 'wss:' #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production) test: <<: *default diff --git a/config/locales/de.yml b/config/locales/de.yml index de6fe41a..a5bcf381 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -27,6 +27,7 @@ de: exercise: description: Beschreibung embedding_parameters: Parameter für LTI-Einbettung + tags: Tags execution_environment: Ausführungsumgebung execution_environment_id: Ausführungsumgebung files: Dateien @@ -34,10 +35,16 @@ de: instructions: Anweisungen maximum_score: Erreichbare Punktzahl public: Öffentlich + selection: Ausgewählt title: Titel user: Autor allow_auto_completion: "Autovervollständigung aktivieren" allow_file_creation: "Dateierstellung erlauben" + difficulty: Schwierigkeitsgrad + worktime: "vermutete Arbeitszeit in Minuten" + proxy_exercise: + title: Title + files_count: Anzahl der Aufgaben external_user: consumer: Konsument email: E-Mail @@ -68,6 +75,8 @@ de: message: Nachricht name: Name regular_expression: Regulärer Ausdruck + intervention: + name: Name internal_user: activated: Aktiviert consumer: Konsument @@ -84,6 +93,7 @@ de: username: Benutzername requested_at: Angefragezeitpunkt question: "Frage" + close: "Fenster schließen" submission: cause: Anlass code: Code @@ -91,6 +101,10 @@ de: files: Dateien score: Punktzahl user: Autor + tag: + name: Name + usage: Verwendet + difficulty: Anteil an der Aufgabe file_template: name: "Name" file_type: "Dateityp" @@ -111,6 +125,9 @@ de: exercise: one: Aufgabe other: Aufgaben + proxy_exercise: + one: Proxy Aufgabe + other: Proxy Aufgaben external_user: one: Externer Nutzer other: Externe Nutzer @@ -129,9 +146,15 @@ de: internal_user: one: Interner Nutzer other: Interne Nutzer + request_for_comment: + one: Kommentaranfrage + other: Kommentaranfragen submission: one: Abgabe other: Abgaben + user_exercise_feedback: + one: Feedback + other: Feedback errors: messages: together: 'muss zusammen mit %{attribute} definiert werden' @@ -254,12 +277,19 @@ de: line: Zeile dialogtitle: Kommentar hinzufügen others: Andere Kommentare auf dieser Zeile + addCommentExercise: Aufgabe kommentieren addyours: Fügen Sie Ihren Kommentar hinzu - addComment: Kommentieren + addComment: Hier haben Sie die Möglichkeit Ihr Feedback zu dieser Aufgabe zu geben. Dies bezieht sich ausdrücklich NICHT auf die hier sichtbare Lösung des Teilnehmers, sondern nur auf die Aufgabe. Fanden Sie die Aufgabe zu leicht oder schwer? War die Beschreibung der Aufgabe verständlich und vollständig? + addCommentButton: Kommentar abschicken removeAllOnLine: Meine Kommentare auf dieser Zeile löschen listing: Die neuesten Kommentaranfragen request: "Kommentaranfrage stellen" - question: "Bitte beschreiben Sie kurz ihre Problem oder nennen Sie den Programmteil, zu dem sie Feedback wünschen." + question: "Bitte beschreiben Sie kurz ihre Probleme oder nennen Sie den Programmteil, zu dem Sie Feedback wünschen." + rfc_intervention: + text: "Es scheint so als würden Sie Probleme mit der Aufgabe haben. Wenn Sie möchten, können wir Ihnen helfen!" + break_intervention: + title: "Pause" + text: "Uns ist aufgefallen, dass du schon lange an dieser Aufgabe arbeitest. Möchtest du vielleicht später weiter machen um erstmal auf neue Gedanken zu kommen?" index: clone: Duplizieren implement: Implementieren @@ -290,6 +320,9 @@ de: tests: Unit Tests time_difference: 'Arbeitszeit bis hier*' addendum: '* Differenzen von mehr als 30 Minuten werden ignoriert.' + proxy_exercises: + index: + clone: Duplizieren external_users: statistics: title: Statistiken für Externe Benutzer @@ -327,6 +360,8 @@ de: success: Sie haben Ihr Passwort erfolgreich geändert. show: link: Profil + search: + search_in_forum: "Probleme? Suche hier im Forum" locales: de: Deutsch en: Englisch @@ -343,7 +378,15 @@ de: Hallo %{receiver_displayname},

es gibt einen neuen Kommentar von %{commenting_user_displayname} zu Ihrer Kommentaranfrage auf CodeOcean.
- Sie finden ihn hier: %{link}
+
+ %{commenting_user_displayname} schreibt: %{comment_text}
+
+ Sie finden ihre Kommentaranfrage hier: %{link_to_comment}
+
+ Falls Sie beim Klick auf diesen Link eine Fehlermeldung erhalten, dass Sie nicht berechtigt wären diese Aktion auszuführen, öffnen Sie bitte eine beliebige Programmieraufgabe aus einem Kurs heraus und klicken den Link danach noch einmal.
+
+ Eine Übersicht Ihrer Kommentaranfragen gibt es hier: %{link_my_comments}
+ Alle Kommentaranfragen aller Benutzer finden Sie hier: %{link_all_comments}

Diese Mail wurde automatisch von CodeOcean verschickt.

@@ -352,7 +395,15 @@ de: Dear %{receiver_displayname},

you received a new comment from %{commenting_user_displayname} to your request for comments on CodeOcean.
- You can find it here: %{link}
+
+ %{commenting_user_displayname} wrote: %{comment_text}
+
+ You can find your request for comments here: %{link_to_comment}
+
+ If you receive an error that you are not authorized to perform this action when clicking the link, please log-in through any course exercise beforehand and click the link again.
+
+ An overview of all your comments can be accessed here: %{link_my_comments}
+ All comments of all participants are available here: %{link_all_comments}

This mail was automatically sent by CodeOcean.
subject: Sie haben einen neuen Kommentar von %{commenting_user_displayname} auf CodeOcean erhalten. @@ -360,13 +411,23 @@ de: body: 'Bitte besuchen Sie %{link}, sofern Sie Ihr Passwort zurücksetzen wollen.' subject: Anweisungen zum Zurücksetzen Ihres Passworts request_for_comments: + click_here: Zum Kommentieren auf die Seitenleiste klicken! comments: Kommentare + howto: | + Um Kommentare zu einer Programmzeile hinzuzufügen, kann einfach auf die jeweilige Zeilennummer auf der linken Seite geklickt werden.
+ Es öffnet sich ein Textfeld, in dem der Kommentar eingetragen werden kann.
+ Mit "Kommentieren" wird der Kommentar dann gesichert und taucht als Sprechblase neben der Zeile auf. + howto_title: 'Anleitung:' index: get_my_comment_requests: Meine Kommentaranfragen all: "Alle Kommentaranfragen" no_question: "Der Autor hat keine Frage zu dieser Anfrage gestellt." mark_as_solved: "Diese Frage als beantwortet markieren" + show_all: "Alle Anfragen anzeigen" + show_solved: "Nur gelöste Anfragen anzeigen" + show_unsolved: "Nur ungelöste Anfragen anzeigen" solved: "Diese Frage wurde erfolgreich beantwortet" + comment_exercise: "Ich möchte die Aufgabenstellung kommentieren" sessions: create: failure: Fehlerhafte E-Mail oder Passwort. @@ -467,3 +528,18 @@ de: previous_label: '← Vorherige Seite' file_template: no_template_label: "Leere Datei" + user_exercise_feedback: + difficulty_easy: "Die Aufgabe war zu einfach" + difficulty_some_what_easy: "Die Aufgabe war etwas zu einfach" + difficulty_ok: "Die Aufgabe war gut zu lösen" + difficulty_some_what_difficult: "Die Aufgabe war etwas zu schwer" + difficult_too_difficult: "Die Aufgabe war zu schwer" + difficulty: "Schwierigkeit der Aufgabe:" + description: "Ihre Punkte wurden übertragen. Wir würden uns freuen, wenn Sie uns hier Feedback zur Aufgabe geben.
Wenn sie das nicht möchten, können Sie das Fenster auch einfach schließen.

Bitte beschreiben Sie, was Ihnen an der Aufgabe gefallen hat und was nicht. Gab es Schwierigkeiten bei der Aufgabe? War die Aufgabe zu leicht oder zu schwer?
Wir freuen uns über jedes Feedback." + estimated_time_less_5: "weniger als 5 Minuten" + estimated_time_5_to_10: "zwischen 5 und 10 Minuten" + estimated_time_10_to_20: "zwischen 10 und 20 Minuten" + estimated_time_20_to_30: "zwischen 20 und 30 Minuten" + estimated_time_more_30: "mehr als 30 Minuten" + working_time: "Geschätze Bearbeitungszeit für diese Aufgabe:" + diff --git a/config/locales/en.yml b/config/locales/en.yml index 304d7d0f..e5077e4e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -48,6 +48,7 @@ en: exercise: description: Description embedding_parameters: LTI Embedding Parameters + tags: Tags execution_environment: Execution Environment execution_environment_id: Execution Environment files: Files @@ -55,10 +56,16 @@ en: instructions: Instructions maximum_score: Maximum Score public: Public + selection: Selected title: Title user: Author allow_auto_completion: "Allow auto completion" allow_file_creation: "Allow file creation" + difficulty: Difficulty + worktime: "Expected worktime in minutes" + proxy_exercise: + title: Title + files_count: Exercises Count external_user: consumer: Consumer email: Email @@ -89,6 +96,8 @@ en: message: Message name: Name regular_expression: Regular Expression + intervention: + name: Name internal_user: activated: Activated consumer: Consumer @@ -105,6 +114,7 @@ en: username: Username requested_at: Request Date question: "Question" + close: Close window submission: cause: Cause code: Code @@ -112,6 +122,10 @@ en: files: Files score: Score user: Author + tag: + name: Name + usage: Used + difficulty: Share on the Exercise file_template: name: "Name" file_type: "File Type" @@ -132,6 +146,9 @@ en: exercise: one: Exercise other: Exercises + proxy_exercise: + one: Proxy Exercise + other: Proxy Exercises external_user: one: External User other: External Users @@ -150,9 +167,15 @@ en: internal_user: one: Internal User other: Internal Users + request_for_comment: + one: Request for Comments + other: Requests for Comments submission: one: Submission other: Submissions + user_exercise_feedback: + one: Feedback + other: Feedback errors: messages: together: 'has to be set along with %{attribute}' @@ -276,11 +299,18 @@ en: dialogtitle: Comment on this line others: Other comments on this line addyours: Add your comment - addComment: Comment this + addCommentExercise: Comment this exercise + addComment: You can give feedback to this exercise. Keep in mind that this should refer to the exercise and not to the solution of the participant. Did you find this exercise particulary easy or difficult? Was the description sufficient? + addCommentButton: Comment this removeAllOnLine: Remove my comments on this line listing: Listing the newest comment requests request: "Request Comments" question: "Please shortly describe your problem or the program part you would like to get feedback for." + rfc_intervention: + text: "It looks like you may struggle with this exercise. If you like we can help you out!" + break_intervention: + title: "Break" + text: "We recognized that you are already working quite a while on this exercise. We would like to encourage you to take a break and come back later." index: clone: Duplicate implement: Implement @@ -311,6 +341,9 @@ en: tests: Unit Test Results time_difference: 'Working Time until here*' addendum: '* Deltas longer than 30 minutes are ignored.' + proxy_exercises: + index: + clone: Duplicate external_users: statistics: title: External User Statistics @@ -348,6 +381,8 @@ en: success: You successfully changed your password. show: link: Profile + search: + search_in_forum: "Problems? Search here in forum" locales: de: German en: English @@ -364,7 +399,15 @@ en: Hallo %{receiver_displayname},

es gibt einen neuen Kommentar von %{commenting_user_displayname} zu Ihrer Kommentaranfrage auf CodeOcean.
- Sie finden ihn hier: %{link}
+
+ %{commenting_user_displayname} schreibt: %{comment_text}
+
+ Sie finden ihre Kommentaranfrage hier: %{link_to_comment}
+
+ Falls Sie beim Klick auf diesen Link eine Fehlermeldung erhalten, dass Sie nicht berechtigt wären diese Aktion auszuführen, öffnen Sie bitte eine beliebige Programmieraufgabe aus einem Kurs heraus und klicken den Link danach noch einmal.
+
+ Eine Übersicht Ihrer Kommentaranfragen gibt es hier: %{link_my_comments}
+ Alle Kommentaranfragen aller Benutzer finden Sie hier: %{link_all_comments}

Diese Mail wurde automatisch von CodeOcean verschickt.

@@ -373,7 +416,15 @@ en: Dear %{receiver_displayname},

you received a new comment from %{commenting_user_displayname} to your request for comments on CodeOcean.
- You can find it here: %{link}
+
+ %{commenting_user_displayname} wrote: %{comment_text}
+
+ You can find your request for comments here: %{link_to_comment}
+
+ If you receive an error that you are not authorized to perform this action when clicking the link, please log-in through any course exercise beforehand and click the link again.
+
+ An overview of all your comments can be accessed here: %{link_my_comments}
+ All comments of all participants are available here: %{link_all_comments}

This mail was automatically sent by CodeOcean.
subject: 'You received a new comment on CodeOcean from %{commenting_user_displayname}.' @@ -381,13 +432,23 @@ en: body: 'Please visit %{link} if you want to reset your password.' subject: Password reset instructions request_for_comments: + click_here: Click on this sidebar to comment! comments: Comments + howto: | + To leave comments to a specific code line, click on the respective line number.
+ Enter your comment in the popup and save it by clicking "Comment this".
+ Your comment will show up next to the line number as a speech bubble symbol. + howto_title: 'How to comment:' index: all: All Requests for Comments get_my_comment_requests: My Requests for Comments no_question: "The author did not enter a question for this request." mark_as_solved: "Mark this question as answered" + show_all: "All requests" + show_solved: "Solved requests" + show_unsolved: "Unvsolved requests" solved: "This question has been answered" + comment_exercise: "I would like to give feedback for this exercise" sessions: create: failure: Invalid email or password. @@ -488,4 +549,17 @@ en: previous_label: '← Previous Page' file_template: no_template_label: "Empty File" - + user_exercise_feedback: + difficulty_easy: "the exercise was too easy" + difficulty_some_what_easy: "the exercise was somewhat easy" + difficulty_ok: "the difficulty of the exercise was just right" + difficulty_some_what_difficult: "the exercise was somewhat difficult" + difficult_too_difficult: "the exercise was too difficult" + difficulty: "Difficulty of the exercise:" + description: "Your points have been submitted. We kindly ask you for feedback for this exercise.
If you do not want to give feedback you can simply close this window.

Please describe what you liked on this exercise and what you did not. Was the exercise easy to understand or did you have problems understanding? How was the difficulty of the exercise to you?
We are happy about any feedback." + estimated_time_less_5: "less than 5 minutes" + estimated_time_5_to_10: "between 5 and 10 minutes" + estimated_time_10_to_20: "between 10 and 20 minutes" + estimated_time_20_to_30: "between 20 and 30 minutes" + estimated_time_more_30: "more than 30 minutes" + working_time: "Estimated time working on this exercise:" diff --git a/config/routes.rb b/config/routes.rb index 6abbe83f..fc0f9406 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,7 @@ Rails.application.routes.draw do resources :request_for_comments do member do get :mark_as_solved + post :create_comment_exercise end end resources :comments, except: [:destroy] do @@ -60,12 +61,46 @@ Rails.application.routes.draw do member do post :clone get :implement + get :working_times + post :intervention + post :search get :statistics get :reload post :submit end end + resources :proxy_exercises do + member do + post :clone + get :reload + post :submit + end + end + + resources :tags do + member do + post :clone + get :reload + post :submit + end + end + + resources :user_exercise_feedbacks do + member do + get :reload + post :submit + end + end + + resources :interventions do + member do + post :clone + get :reload + post :submit + end + end + resources :external_users, only: [:index, :show], concerns: :statistics do resources :exercises, concerns: :statistics end diff --git a/db/migrate/20170205163247_create_exercise_collections.rb b/db/migrate/20170205163247_create_exercise_collections.rb new file mode 100644 index 00000000..ef27f756 --- /dev/null +++ b/db/migrate/20170205163247_create_exercise_collections.rb @@ -0,0 +1,14 @@ +class CreateExerciseCollections < ActiveRecord::Migration + def change + create_table :exercise_collections do |t| + t.string :name + t.timestamps + end + + create_table :exercise_collections_exercises, id: false do |t| + t.belongs_to :exercise_collection, index: true + t.belongs_to :exercise, index: true + end + + end +end diff --git a/db/migrate/20170205165450_create_proxy_exercises.rb b/db/migrate/20170205165450_create_proxy_exercises.rb new file mode 100644 index 00000000..fb2704ce --- /dev/null +++ b/db/migrate/20170205165450_create_proxy_exercises.rb @@ -0,0 +1,23 @@ +class CreateProxyExercises < ActiveRecord::Migration + def change + create_table :proxy_exercises do |t| + t.string :title + t.string :description + t.string :token + t.timestamps + end + + create_table :exercises_proxy_exercises, id: false do |t| + t.belongs_to :proxy_exercise, index: true + t.belongs_to :exercise, index: true + t.timestamps + end + + create_table :user_proxy_exercise_exercises do |t| + t.belongs_to :user, polymorphic: true, index: true + t.belongs_to :proxy_exercise, index: true + t.belongs_to :exercise, index: true + t.timestamps + end + end +end diff --git a/db/migrate/20170205210357_create_interventions.rb b/db/migrate/20170205210357_create_interventions.rb new file mode 100644 index 00000000..07223e1c --- /dev/null +++ b/db/migrate/20170205210357_create_interventions.rb @@ -0,0 +1,23 @@ +class CreateInterventions < ActiveRecord::Migration + def change + create_table :user_exercise_interventions do |t| + t.belongs_to :user, polymorphic: true + t.belongs_to :exercise + t.belongs_to :intervention + t.integer :accumulated_worktime_s + t.text :reason + t.timestamps + end + + create_table :interventions do |t| + t.string :name + t.text :markup + t.timestamps + end + + Intervention.createDefaultInterventions + + end + + +end diff --git a/db/migrate/20170206141210_add_tags.rb b/db/migrate/20170206141210_add_tags.rb new file mode 100644 index 00000000..8c0c129a --- /dev/null +++ b/db/migrate/20170206141210_add_tags.rb @@ -0,0 +1,19 @@ +class AddTags < ActiveRecord::Migration + + def change + add_column :exercises, :expected_worktime_seconds, :integer, default: 60 + add_column :exercises, :expected_difficulty, :integer, default: 1 + + create_table :tags do |t| + t.string :name, null: false + t.timestamps + end + + create_table :exercise_tags do |t| + t.belongs_to :exercise + t.belongs_to :tag + t.integer :factor, default: 1 + end + end + +end diff --git a/db/migrate/20170206152503_add_user_feedback.rb b/db/migrate/20170206152503_add_user_feedback.rb new file mode 100644 index 00000000..f62ccd9d --- /dev/null +++ b/db/migrate/20170206152503_add_user_feedback.rb @@ -0,0 +1,11 @@ +class AddUserFeedback < ActiveRecord::Migration + def change + create_table :user_exercise_feedbacks do |t| + t.belongs_to :exercise, null: false + t.belongs_to :user, polymorphic: true, null: false + t.integer :difficulty + t.integer :working_time_seconds + t.string :feedback_text + end + end +end diff --git a/db/migrate/20170228165741_add_search.rb b/db/migrate/20170228165741_add_search.rb new file mode 100644 index 00000000..a36d94ff --- /dev/null +++ b/db/migrate/20170228165741_add_search.rb @@ -0,0 +1,10 @@ +class AddSearch < ActiveRecord::Migration + def change + create_table :searches do |t| + t.belongs_to :exercise, null: false + t.belongs_to :user, polymorphic: true, null: false + t.string :search + t.timestamps + end + end +end diff --git a/db/migrate/20170321150454_add_reason_to_user_proxy_exercise_exercise.rb b/db/migrate/20170321150454_add_reason_to_user_proxy_exercise_exercise.rb new file mode 100644 index 00000000..93ab0cf0 --- /dev/null +++ b/db/migrate/20170321150454_add_reason_to_user_proxy_exercise_exercise.rb @@ -0,0 +1,7 @@ +class AddReasonToUserProxyExerciseExercise < ActiveRecord::Migration + def change + change_table :user_proxy_exercise_exercises do |t| + t.string :reason + end + end +end diff --git a/db/migrate/20170323130756_add_index_to_submissions.rb b/db/migrate/20170323130756_add_index_to_submissions.rb new file mode 100644 index 00000000..185500d3 --- /dev/null +++ b/db/migrate/20170323130756_add_index_to_submissions.rb @@ -0,0 +1,6 @@ +class AddIndexToSubmissions < ActiveRecord::Migration + def change + add_index :submissions, :exercise_id + add_index :submissions, :user_id + end +end diff --git a/db/migrate/20170403162848_set_default_for_request_for_comment_solved.rb b/db/migrate/20170403162848_set_default_for_request_for_comment_solved.rb new file mode 100644 index 00000000..6e01dcd4 --- /dev/null +++ b/db/migrate/20170403162848_set_default_for_request_for_comment_solved.rb @@ -0,0 +1,6 @@ +class SetDefaultForRequestForCommentSolved < ActiveRecord::Migration + def change + change_column_default :request_for_comments, :solved, false + RequestForComment.where(solved: nil).update_all(solved: false) + end +end \ No newline at end of file diff --git a/db/migrate/20170411090543_improve_user_feedback.rb b/db/migrate/20170411090543_improve_user_feedback.rb new file mode 100644 index 00000000..bfbd8a02 --- /dev/null +++ b/db/migrate/20170411090543_improve_user_feedback.rb @@ -0,0 +1,5 @@ +class ImproveUserFeedback < ActiveRecord::Migration + def change + add_column :user_exercise_feedbacks, :user_estimated_worktime, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index e53b502f..f0ecde99 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161214144837) do +ActiveRecord::Schema.define(version: 20170403162848) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -76,22 +76,54 @@ ActiveRecord::Schema.define(version: 20161214144837) do t.boolean "network_enabled" end + create_table "exercise_collections", force: :cascade do |t| + t.string "name" + t.datetime "created_at" + t.datetime "updated_at" + end + + create_table "exercise_collections_exercises", id: false, force: :cascade do |t| + t.integer "exercise_collection_id" + t.integer "exercise_id" + end + + add_index "exercise_collections_exercises", ["exercise_collection_id"], name: "index_exercise_collections_exercises_on_exercise_collection_id", using: :btree + add_index "exercise_collections_exercises", ["exercise_id"], name: "index_exercise_collections_exercises_on_exercise_id", using: :btree + + create_table "exercise_tags", force: :cascade do |t| + t.integer "exercise_id" + t.integer "tag_id" + t.integer "factor", default: 0 + end + create_table "exercises", force: :cascade do |t| t.text "description" t.integer "execution_environment_id" - t.string "title", limit: 255 + t.string "title", limit: 255 t.datetime "created_at" t.datetime "updated_at" t.integer "user_id" t.text "instructions" t.boolean "public" - t.string "user_type", limit: 255 - t.string "token", limit: 255 + t.string "user_type", limit: 255 + t.string "token", limit: 255 t.boolean "hide_file_tree" t.boolean "allow_file_creation" - t.boolean "allow_auto_completion", default: false + t.boolean "allow_auto_completion", default: false + t.integer "expected_worktime_seconds", default: 0 + t.integer "expected_difficulty", default: 1 end + create_table "exercises_proxy_exercises", id: false, force: :cascade do |t| + t.integer "proxy_exercise_id" + t.integer "exercise_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "exercises_proxy_exercises", ["exercise_id"], name: "index_exercises_proxy_exercises_on_exercise_id", using: :btree + add_index "exercises_proxy_exercises", ["proxy_exercise_id"], name: "index_exercises_proxy_exercises_on_proxy_exercise_id", using: :btree + create_table "external_users", force: :cascade do |t| t.integer "consumer_id" t.string "email", limit: 255 @@ -182,11 +214,26 @@ ActiveRecord::Schema.define(version: 20161214144837) do add_index "internal_users", ["remember_me_token"], name: "index_internal_users_on_remember_me_token", using: :btree add_index "internal_users", ["reset_password_token"], name: "index_internal_users_on_reset_password_token", using: :btree + create_table "interventions", force: :cascade do |t| + t.string "name" + t.text "markup" + t.datetime "created_at" + t.datetime "updated_at" + end + create_table "lti_parameters", force: :cascade do |t| - t.string "external_user_id" + t.integer "external_users_id" t.integer "consumers_id" t.integer "exercises_id" - t.jsonb "lti_parameters", default: {}, null: false + t.jsonb "lti_parameters", default: {}, null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + create_table "proxy_exercises", force: :cascade do |t| + t.string "title" + t.string "description" + t.string "token" t.datetime "created_at" t.datetime "updated_at" end @@ -200,17 +247,26 @@ ActiveRecord::Schema.define(version: 20161214144837) do end create_table "request_for_comments", force: :cascade do |t| - t.integer "user_id", null: false - t.integer "exercise_id", null: false - t.integer "file_id", null: false + t.integer "user_id", null: false + t.integer "exercise_id", null: false + t.integer "file_id", null: false t.datetime "created_at" t.datetime "updated_at" t.string "user_type", limit: 255 t.text "question" - t.boolean "solved" + t.boolean "solved", default: false t.integer "submission_id" end + create_table "searches", force: :cascade do |t| + t.integer "exercise_id", null: false + t.integer "user_id", null: false + t.string "user_type", null: false + t.string "search" + t.datetime "created_at" + t.datetime "updated_at" + end + create_table "submissions", force: :cascade do |t| t.integer "exercise_id" t.float "score" @@ -221,6 +277,15 @@ ActiveRecord::Schema.define(version: 20161214144837) do t.string "user_type", limit: 255 end + add_index "submissions", ["exercise_id"], name: "index_submissions_on_exercise_id", using: :btree + add_index "submissions", ["user_id"], name: "index_submissions_on_user_id", using: :btree + + create_table "tags", force: :cascade do |t| + t.string "name", null: false + t.datetime "created_at" + t.datetime "updated_at" + end + create_table "testruns", force: :cascade do |t| t.boolean "passed" t.text "output" @@ -230,4 +295,36 @@ ActiveRecord::Schema.define(version: 20161214144837) do t.datetime "updated_at" end + create_table "user_exercise_feedbacks", force: :cascade do |t| + t.integer "exercise_id", null: false + t.integer "user_id", null: false + t.string "user_type", null: false + t.integer "difficulty" + t.integer "working_time_seconds" + t.string "feedback_text" + end + + create_table "user_exercise_interventions", force: :cascade do |t| + t.integer "user_id" + t.string "user_type" + t.integer "exercise_id" + t.integer "intervention_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + create_table "user_proxy_exercise_exercises", force: :cascade do |t| + t.integer "user_id" + t.string "user_type" + t.integer "proxy_exercise_id" + t.integer "exercise_id" + t.datetime "created_at" + t.datetime "updated_at" + t.string "reason" + end + + add_index "user_proxy_exercise_exercises", ["exercise_id"], name: "index_user_proxy_exercise_exercises_on_exercise_id", using: :btree + add_index "user_proxy_exercise_exercises", ["proxy_exercise_id"], name: "index_user_proxy_exercise_exercises_on_proxy_exercise_id", using: :btree + add_index "user_proxy_exercise_exercises", ["user_type", "user_id"], name: "index_user_proxy_exercise_exercises_on_user_type_and_user_id", using: :btree + end diff --git a/deleteme.txt b/deleteme.txt new file mode 100644 index 00000000..e69de29b diff --git a/lib/assets/stylesheets/flash.css.scss b/lib/assets/stylesheets/flash.css.scss index 1c805ebf..661526e8 100644 --- a/lib/assets/stylesheets/flash.css.scss +++ b/lib/assets/stylesheets/flash.css.scss @@ -1,3 +1,26 @@ .flash { display: none; } + +.fixed_error_messages { + position: fixed; + z-index: 1000; + top: 20px; + left: 0; + width: 100%; + padding-left: 10%; + padding-right: 10%; + padding-top: 0; +} + +.clickthrough { + pointer-events: none; + + /* fixes for IE */ + /* + background:white; + opacity:0; + filter:Alpha(opacity=0); + */ +} + diff --git a/lib/docker_client.rb b/lib/docker_client.rb index 28e2a3fb..76451096 100644 --- a/lib/docker_client.rb +++ b/lib/docker_client.rb @@ -255,6 +255,12 @@ class DockerClient if(@tubesock) @tubesock.send_data JSON.dump({'cmd' => 'timeout'}) end + if(@socket) + @socket.send('#timeout') + #sleep one more second to ensure that the message reaches the submissions_controller. + sleep(1) + @socket.close + end kill_container(container) end #ensure @@ -274,6 +280,7 @@ class DockerClient Rails.logger.debug('exiting container ' + container.to_s) # exit the timeout thread if it is still alive exit_thread_if_alive + @socket.close # if we use pooling and recylce the containers, put it back. otherwise, destroy it. (DockerContainerPool.config[:active] && RECYCLE_CONTAINERS) ? self.class.return_container(container, @execution_environment) : self.class.destroy_container(container) end diff --git a/lib/junit_adapter.rb b/lib/junit_adapter.rb index c5b57184..6230a785 100644 --- a/lib/junit_adapter.rb +++ b/lib/junit_adapter.rb @@ -2,7 +2,7 @@ class JunitAdapter < TestingFrameworkAdapter COUNT_REGEXP = /Tests run: (\d+)/ FAILURES_REGEXP = /Failures: (\d+)/ SUCCESS_REGEXP = /OK \((\d+) test[s]?\)/ - ASSERTION_ERROR_REGEXP = /java\.lang\.AssertionError:\s(.​*)|org\.junit\.ComparisonFailure:\s(.*​)/ + ASSERTION_ERROR_REGEXP = /java\.lang\.AssertionError:\s(.*)|org\.junit\.ComparisonFailure:\s(.*)/ def self.framework_name 'JUnit' diff --git a/lib/user_group_separator.rb b/lib/user_group_separator.rb new file mode 100644 index 00000000..c49ffe44 --- /dev/null +++ b/lib/user_group_separator.rb @@ -0,0 +1,40 @@ +class UserGroupSeparator + + # seperates user into 20% no intervention, 20% break intervention, 60% rfc intervention + def self.getInterventionGroup(user) + lastDigitId = user.id % 10 + if lastDigitId < 2 # 0,1 + :no_intervention + elsif lastDigitId < 4 # 2,3 + :break_intervention + else # 4,5,6,7,8,9 + :rfc_intervention + end + end + + # seperates user into 20% dummy assignment, 20% random assignemnt, 60% recommended assignment + def self.getProxyExerciseGroup(user) + lastDigitCreatedAt = user.created_at.to_i % 10 + if lastDigitCreatedAt < 2 # 0,1 + :dummy_assigment + elsif lastDigitCreatedAt < 4 # 2,3 + :random_assigment + else # 4,5,6,7,8,9 + :recommended_assignment + end + end + + def self.getGroupWeek2Testing(user) + groupById = user.id % 4 + if groupById == 0 + :group_a + elsif groupById == 1 + :group_b + elsif groupById == 2 + :group_c + else # 3 + :group_d + end + end + +end \ No newline at end of file diff --git a/lib/xikolo/client.rb b/lib/xikolo/client.rb index 06e4ecf4..4e6f22b2 100644 --- a/lib/xikolo/client.rb +++ b/lib/xikolo/client.rb @@ -10,7 +10,7 @@ class Xikolo::Client end def self.user_profile_url(user_id) - return url + 'users/' + user_id + return url + 'v2/users/' + user_id end def self.post_request(url, params) @@ -38,11 +38,11 @@ class Xikolo::Client end def self.accept - 'application/vnd.xikolo.v1, application/json' + 'application/vnd.xikolo.v1, application/vnd.api+json, application/json' end def self.token - 'Token token="'+Rails.application.secrets.openhpi_api_token+'"' + 'Token token='+Rails.application.secrets.openhpi_api_token#+'"' end private diff --git a/lib/xikolo/user_client.rb b/lib/xikolo/user_client.rb index 63412e46..c681c999 100644 --- a/lib/xikolo/user_client.rb +++ b/lib/xikolo/user_client.rb @@ -4,12 +4,10 @@ class Xikolo::UserClient # return default values if user is not found or if there is a server issue: if user - if user['display_name'].present? - name = user['display_name'] - else - name = user['first_name'] - end - return {display_name: name, user_visual: user['user_visual'], language: user['language']} + name = user.dig('data', 'attributes', 'name') || "User " + user_id + user_visual = user.dig('data', 'attributes', 'avatar_url') || ActionController::Base.helpers.image_path('default.png') + language = user.dig('data', 'attributes', 'language') || "DE" + return {display_name: name, user_visual: user_visual, language: language} else return {display_name: "User " + user_id, user_visual: ActionController::Base.helpers.image_path('default.png'), language: "DE"} end diff --git a/spec/concerns/lti_spec.rb b/spec/concerns/lti_spec.rb index 95181d3b..c03ef9a5 100644 --- a/spec/concerns/lti_spec.rb +++ b/spec/concerns/lti_spec.rb @@ -165,6 +165,7 @@ describe Lti do it 'stores data in the session' do controller.instance_variable_set(:@current_user, FactoryGirl.create(:external_user)) + controller.instance_variable_set(:@exercise, FactoryGirl.create(:fibonacci)) expect(controller.session).to receive(:[]=).with(:consumer_id, anything) expect(controller.session).to receive(:[]=).with(:external_user_id, anything) controller.send(:store_lti_session_data, consumer: FactoryGirl.build(:consumer), parameters: parameters) @@ -172,6 +173,8 @@ describe Lti do it 'it creates an LtiParameter Object' do before_count = LtiParameter.count + controller.instance_variable_set(:@current_user, FactoryGirl.create(:external_user)) + controller.instance_variable_set(:@exercise, FactoryGirl.create(:fibonacci)) controller.send(:store_lti_session_data, consumer: FactoryGirl.build(:consumer), parameters: parameters) expect(LtiParameter.count).to eq(before_count + 1) end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index ffa957ea..50fef8d6 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -28,6 +28,7 @@ describe SessionsController do describe 'POST #create_through_lti' do let(:exercise) { FactoryGirl.create(:dummy) } + let(:exercise2) { FactoryGirl.create(:dummy) } let(:nonce) { SecureRandom.hex } before(:each) { I18n.locale = I18n.default_locale } @@ -129,6 +130,23 @@ describe SessionsController do request expect(controller).to redirect_to(implement_exercise_path(exercise.id)) end + + it 'redirects to recommended exercise if requested token of proxy exercise' do + FactoryGirl.create(:proxy_exercise, exercises: [exercise]) + post :create_through_lti, custom_locale: locale, custom_token: ProxyExercise.first.token, oauth_consumer_key: consumer.oauth_key, oauth_nonce: nonce, oauth_signature: SecureRandom.hex, user_id: user.external_id + expect(controller).to redirect_to(implement_exercise_path(exercise.id)) + end + + it 'recommends only exercises who are 1 degree more complicated than what user has seen' do + # dummy user has no exercises finished, therefore his highest difficulty is 0 + FactoryGirl.create(:proxy_exercise, exercises: [exercise, exercise2]) + exercise.expected_difficulty = 3 + exercise.save + exercise2.expected_difficulty = 1 + exercise2.save + post :create_through_lti, custom_locale: locale, custom_token: ProxyExercise.first.token, oauth_consumer_key: consumer.oauth_key, oauth_nonce: nonce, oauth_signature: SecureRandom.hex, user_id: user.external_id + expect(controller).to redirect_to(implement_exercise_path(exercise2.id)) + end end end diff --git a/spec/factories/proxy_exercise.rb b/spec/factories/proxy_exercise.rb new file mode 100644 index 00000000..9c9974d6 --- /dev/null +++ b/spec/factories/proxy_exercise.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :proxy_exercise, class: ProxyExercise do + token 'dummytoken' + title 'Dummy' + end + +end