diff --git a/app/assets/javascripts/editor/editor.js.erb b/app/assets/javascripts/editor/editor.js.erb index 0be56058..0b446c7c 100644 --- a/app/assets/javascripts/editor/editor.js.erb +++ b/app/assets/javascripts/editor/editor.js.erb @@ -323,6 +323,9 @@ configureEditors: function () { var button = $('#requestComments'); button.prop('disabled', true); button.on('click', function () { + if ($('#editor').data('show-interventions') == true){ + $('#rfc_intervention_text').hide() + } $('#comment-modal').modal('show'); }); @@ -571,6 +574,80 @@ configureEditors: function () { }, + /** + * interventions + * */ + initializeInterventionTimer: function() { + $.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 timeUntilBreak = 20 * 60 * 1000; + var minTimeUntilAskQuestion = 15 * 60 * 1000; + + if ((accumulatedWorkTimeUser - percentile75) > 0) { + // working time is already over 75 percentile + var timeUntilAskQuestion = minTimeUntilAskQuestion; + } else { + // working time is less than 75 percentile + // ensure we give user at least 10 minutes before we bother the user + var timeUntilAskForRFC = (percentile75 - accumulatedWorkTimeUser) > minTimeUntilAskQuestion ? (percentile75 - accumulatedWorkTimeUser) : minTimeUntilAskQuestion; + } + + // if notifications are too close to each other, ensure some time differences between them + if (Math.abs(timeUntilAskForRFC - timeUntilBreak) < 5 * 1000 * 60){ + timeUntilBreak = timeUntilBreak * 2; + } + + setTimeout(function() { + $('#break-intervention-modal').modal('show'); + $.ajax({ + data: { + intervention_type: 'BreakIntervention' + }, + dataType: 'json', + type: 'POST', + url: $('#editor').data('intervention-save-url')}); + }, timeUntilBreak); + + + setTimeout(function() { + var button = $('#requestComments'); + 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')}); + }; + }, timeUntilAskForRFC); + } + }); + }, + + initializeSearchButton: function(){ + $('#btn-search-col').button().click(function(){ + var search = $('#search-col').val(); + var course_token = $('#sidebar-collapsed').data('course_token') + window.open(`https://open.hpi.de/courses/${course_token}/pinboard?query=${search}`, '_blank'); + }) + + $('#sidebar-search-collapsed').on('click',this.handleSideBarToggle.bind(this)); + }, + initializeEverything: function() { this.initializeRegexes(); @@ -585,6 +662,10 @@ configureEditors: function () { this.initializeDescriptionToggle(); this.initializeSideBarTooltips(); this.initializeTooltips(); + if ($('#editor').data('show-interventions') == true){ + this.initializeInterventionTimer(); + } + this.initializeSearchButton(); this.initPrompt(); this.renderScore(); this.showFirstFile(); 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/exercises_controller.rb b/app/controllers/exercises_controller.rb index 79314208..62799e27 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, :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,13 @@ class ExercisesController < ApplicationController def implement redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists? + user_got_enough_interventions = UserExerciseIntervention.where(exercise: @exercise, user: current_user).count >= max_intervention_count + is_java_course = @course_token && @course_token.eql?(java_course_token) + + @show_interventions = (!is_java_course || user_got_enough_interventions) ? "false" : "true" + + @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 +183,44 @@ 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["lis_outcome_service_url"] + @course_token = + if match = lti_json.match(/^.*courses\/([a-z0-9\-]+)\/sections/) + match.captures.first + else + java_course_token + 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 index @search = policy_scope(Exercise).search(params[:q]) @exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page]) @@ -174,6 +245,8 @@ class ExercisesController < ApplicationController def new @exercise = Exercise.new + collect_set_and_unset_exercise_tags + authorize! end @@ -201,6 +274,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 +335,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 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/searches_controller.rb b/app/controllers/searches_controller.rb new file mode 100644 index 00000000..8af6b364 --- /dev/null +++ b/app/controllers/searches_controller.rb @@ -0,0 +1,34 @@ +class SearchesController < ApplicationController + include CommonBehavior + + def authorize! + authorize(@search || @searchs) + end + private :authorize! + + + def create + @search = Search.new(search_params) + @search.user = current_user + authorize! + + respond_to do |format| + if @search.save + path = implement_exercise_path(@search.exercise) + respond_with_valid_object(format, path: path, status: :created) + end + end + end + + def search_params + params[:search].permit(:search, :exercise_id) + end + private :search_params + + def index + @search = policy_scope(ProxyExercise).search(params[:q]) + @searches = @search.result.order(:title).paginate(page: params[:page]) + authorize! + end + +end \ No newline at end of file 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/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/models/concerns/user.rb b/app/models/concerns/user.rb index ee72a715..28601cdf 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 diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 29f260c2..15c740f4 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,6 +57,10 @@ 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, @@ -58,7 +71,7 @@ class Exercise < ActiveRecord::Base 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,35 @@ class Exercise < ActiveRecord::Base """ end + def get_quantiles(quantiles) + quantiles_str = "[" + quantiles.join(",") + "]" + result = self.class.connection.execute(""" + SELECT unnest(PERCENTILE_CONT(ARRAY#{quantiles_str}) WITHIN GROUP (ORDER BY working_time)) + FROM + ( + 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 + FROM + (SELECT user_id, + 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=#{self.id} AND user_type = 'ExternalUser') AS foo) AS bar + GROUP BY user_id + ) AS foo + """) + 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 +130,25 @@ class Exercise < ActiveRecord::Base @working_time_statistics[user_id]["working_time"] end - def average_working_time_for_only(user_id) - self.class.connection.execute(""" + def accumulated_working_time_for_only(user) + user_type = user.external_user? ? "ExternalUser" : "InternalUser" + Time.parse(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 + (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}) AS foo) AS bar - """).first["working_time"] + WHERE exercise_id=#{id} and user_id=#{user.id} and user_type='#{user_type}') AS foo) AS bar + """).first["working_time"] || "00:00:00").seconds_since_midnight 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 +206,12 @@ 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 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/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..11b5a545 --- /dev/null +++ b/app/models/proxy_exercise.rb @@ -0,0 +1,220 @@ +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 + Rails.logger.debug("find new matching exercise for user #{user.id}" ) + matching_exercise = + 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 + 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) + exercises_user_has_accessed = user.submissions.where("cause IN ('submit','assess')").map{|s| s.exercise}.uniq + 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 execises + potential_recommended_exercises = [] + exercises.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 + 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) + points_ratio = ex.maximum_score(user) / ex.maximum_score.to_f + 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..d3ec09d5 --- /dev/null +++ b/app/models/user_exercise_feedback.rb @@ -0,0 +1,8 @@ +class UserExerciseFeedback < ActiveRecord::Base + + belongs_to :user, polymorphic: true + belongs_to :exercise + + validates :user_id, uniqueness: { scope: [:exercise_id, :user_type] } + +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..6377488b 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?, :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/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/views/exercises/_editor.html.slim b/app/views/exercises/_editor.html.slim index 56986673..8a43b613 100644 --- a/app/views/exercises/_editor.html.slim +++ b/app/views/exercises/_editor.html.slim @@ -1,7 +1,8 @@ - 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_interventions = @show_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-show-interventions=show_interventions 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 +23,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..6a90b60d 100644 --- a/app/views/exercises/_editor_file_tree.html.slim +++ b/app/views/exercises/_editor_file_tree.html.slim @@ -1,4 +1,4 @@ -div id='sidebar-collapsed' class=(@exercise.hide_file_tree ? '' : 'hidden') +div id='sidebar-collapsed' class=(@exercise.hide_file_tree ? '' : 'hidden') data-course_token=@course_token = render('editor_button', classes: 'btn-block btn-primary btn-sm', data: {:'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-plus-square', id: 'sidebar-collapse-collapsed', label:'', title:t('exercises.editor.expand_action_sidebar')) - if @exercise.allow_file_creation and not @exercise.hide_file_tree? @@ -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 + = 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,16 @@ 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 + = form_for(@search, multipart: true, target: "_blank") do |f| + .input-group.enforce-top-margin + = f.hidden_field :exercise_id + .enforce-right-margin + = f.text_field(:search, class: 'form-control', id: "search-col", required: true, placeholder: t('search.search_in_forum')) + .input-group-btn + = button_tag(class: 'btn btn-primary', id: 'btn-search-col', model: @search.class.model_name.human) 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..0811b447 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 + 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..0db575e5 100644 --- a/app/views/exercises/_request_comment_dialogcontent.html.slim +++ b/app/views/exercises/_request_comment_dialogcontent.html.slim @@ -1,4 +1,7 @@ +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. 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/searches/destroy.html.erb b/app/views/searches/destroy.html.erb new file mode 100644 index 00000000..e69de29b 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/config/locales/de.yml b/config/locales/de.yml index 5b4329df..3b5dd89a 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 @@ -91,6 +100,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 +124,9 @@ de: exercise: one: Aufgabe other: Aufgaben + proxy_exercise: + one: Proxy Aufgabe + other: Proxy Aufgaben external_user: one: Externer Nutzer other: Externe Nutzer @@ -259,7 +275,12 @@ de: 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 eine Pause machen um auf neue Gedanken zu kommen?" index: clone: Duplizieren implement: Implementieren @@ -290,6 +311,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 +351,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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 5541d68f..213624e8 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 @@ -112,6 +121,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 +145,9 @@ en: exercise: one: Exercise other: Exercises + proxy_exercise: + one: Proxy Exercise + other: Proxy Exercises external_user: one: External User other: External Users @@ -281,6 +297,11 @@ en: 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 +332,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 +372,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 diff --git a/config/routes.rb b/config/routes.rb index b4606f74..a33369d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,12 +60,46 @@ Rails.application.routes.draw do member do post :clone get :implement + get :working_times + post :intervention 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 :searches do + member do + post :clone + 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/deleteme.txt b/deleteme.txt new file mode 100644 index 00000000..e69de29b 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