diff --git a/app/assets/javascripts/working_time_graphs.js b/app/assets/javascripts/working_time_graphs.js index 69b02e9f..10ec05b1 100644 --- a/app/assets/javascripts/working_time_graphs.js +++ b/app/assets/javascripts/working_time_graphs.js @@ -1,18 +1,16 @@ $(document).on('turbolinks:load', function() { - // http://localhost:3333/exercises/38/statistics good for testing - // originally at--> localhost:3333/exercises/69/statistics + // /38/statistics good for testing - if ($.isController('exercises') && $('.graph-functions').isPresent()) { + if ($.isController('exercises') && $('.working-time-graphs').isPresent()) { var working_times = $('#data').data('working-time'); - function get_minutes (time_stamp){ + function get_minutes (timestamp){ try{ - hours = time_stamp.split(":")[0]; - minutes = time_stamp.split(":")[1]; - seconds = time_stamp.split(":")[2]; + hours = timestamp.split(":")[0]; + minutes = timestamp.split(":")[1]; + seconds = timestamp.split(":")[2]; - minutes = parseFloat(hours * 60) + parseInt(minutes); - return minutes + return parseFloat(hours * 60) + parseInt(minutes); } catch (err){ return 0; } @@ -20,7 +18,7 @@ $(document).on('turbolinks:load', function() { } // GET ALL THE DATA ------------------------------------------------------------------------------ - minutes_array = _.map(working_times,function(item){return get_minutes(item)}); + minutes_array = _.map(working_times, function(item){return get_minutes(item)}); minutes_array_length = minutes_array.length; maximum_minutes = _.max(minutes_array); @@ -39,7 +37,7 @@ $(document).on('turbolinks:load', function() { } function getWidth() { - if (self.innerHeight) { + if (self.innerWidth) { return self.innerWidth; } @@ -53,15 +51,12 @@ $(document).on('turbolinks:load', function() { } // DRAW THE LINE GRAPH ------------------------------------------------------------------------------ - function draw_line_graph() { + function drawLineGraph() { var width_ratio = .8; - if (getWidth()*width_ratio > 1000){ - width_ratio = 1000/getWidth(); + if (getWidth() * width_ratio > 1000){ + width_ratio = 1000 / getWidth(); } - var height_ratio = .7; // percent of height - - // currently sets as percentage of window width, however, unfortunately - // is not yet responsive + var height_ratio = .7; var margin = {top: 100, right: 20, bottom: 70, left: 70},//30,50 width = (getWidth() * width_ratio) - margin.left - margin.right, @@ -139,96 +134,91 @@ $(document).on('turbolinks:load', function() { svg.append("path") .datum(minutes_count) .attr("class", "line") - .attr('id', 'myPath')// new + .attr('id', 'myPath') .attr("stroke", "orange") .attr("stroke-width", 5) - .attr("fill", "none")// end new - .attr("d", line);//--- - //.on("mousemove", mMove)//new again - //.append("title"); - - // function type(d) { - // d.frequency = +d.frequency; - // return d; - // } + .attr("fill", "none") + .attr("d", line); } - draw_line_graph(); - - // THIS SHOULD DISPLAY THE X AND Y VALUES BUT - // THE RESULTS ARE WRONG AT THE END FOR SOME REASON - - //function mMove() { - // var x_width = getWidth() * width_ratio; - // //var x_value = m[0]*(minutes_count.length/x_width); - // - // var y_height = x_width * height_ratio; - // //var y_value = (((y_height - m[1])/y_height)*100); - // - // //console.log('y is: ' + y_value); - // var m = d3.mouse(this); - // d3.select("#myPath").select("title") - // .text((y_height-m[1])/(y_height) * 100 + "% of Students" +"\n"+ - // (m[0]*(minutes_count.length/x_width)) +" Minutes");//text(m[1]); - //} + drawLineGraph(); // DRAW THE SECOND GRAPH ------------------------------------------------------------------------------ - function draw_bar_graph() { - var number_of_bars = 40; - var group_increment = Math.ceil(maximum_minutes / number_of_bars); // range in minutes - var group_ranges = group_increment; // just for the start - var minutes_array_for_bar = []; + function drawBarGraph() { + var groupWidth = 5; + var groupRanges = 0; + var workingTimeGroups = []; do { - var section_value = 0; + var clusterCount = 0; for (var i = 0; i < minutes_array.length; i++) { - if ((minutes_array[i] < group_ranges) && (minutes_array[i] >= (group_ranges - group_increment))) { - section_value++; + if ((minutes_array[i] >= groupRanges) && (minutes_array[i] < (groupRanges + groupWidth))) { + clusterCount++; } } - minutes_array_for_bar.push(section_value); - group_ranges += group_increment; + workingTimeGroups.push(clusterCount); + groupRanges += groupWidth; } - while (group_ranges < maximum_minutes + group_increment); + while (groupRanges < maximum_minutes); + console.log(maximum_minutes); - //console.log(minutes_array_for_bar); // this var used as the bars - //minutes_array_for_bar = [39, 20, 28, 20, 39, 34, 26, 23, 16, 8]; - - var max_of_array = Math.max.apply(Math, minutes_array_for_bar); - var min_of_array = Math.min.apply(Math, minutes_array_for_bar); + var clusterCount = 0, + sum = 0, + maxVal = 0; + for (var i = 0; i < minutes_array.length; i++) { + if (minutes_array[i] > maximum_minutes) { + currentValue = minutes_array[i]; + sum += currentValue; + if (currentValue > maxVal) { + maxVal = currentValue; + } + clusterCount++; + } + } + // ToDo: Take care of x axis description if this is added + // workingTimeGroups.push(clusterCount); + var maxStudentsInGroup = Math.max.apply(Math, workingTimeGroups); var width_ratio = .8; - if (getWidth()*width_ratio > 1000){ - width_ratio = 1000/getWidth(); - } - var height_ratio = .7; // percent of height - var margin = {top: 100, right: 20, bottom: 70, left: 70},//30,50 + // Scale width to fit into bootsrap container + if (getWidth() * width_ratio > 1000){ + width_ratio = 1000 / getWidth(); + } + + var height_ratio = .7; + + var margin = {top: 100, right: 20, bottom: 70, left: 70}, width = (getWidth() * width_ratio) - margin.left - margin.right, height = (width * height_ratio) - margin.top - margin.bottom; var x = d3.scaleBand() - .range([0, width], .1); - - var y = d3.scaleLinear() - .range([0,height-(margin.top + margin.bottom)]); - + .rangeRound([0, width]) + .paddingInner(0.1) + .domain(workingTimeGroups.map(function (d, i) { + return i * groupWidth; + })); var xAxis = d3.axisBottom(x) - .ticks(10); - - - var yAxis = d3 - .axisLeft(d3.scaleLinear().domain([0,max_of_array]).range([height,0]))//y .ticks(10) - .tickSizeInner(-width); + .tickValues(x.domain().filter(function(d, i){ + return (d % 10) === 0 + })) + .tickFormat(function(d) { return d + "-" + (d + groupWidth) }); + + var y = d3.scaleLinear() + .domain([0, maxStudentsInGroup]) + .range([height, 0]); + + var yAxis = d3.axisLeft(y) + .ticks(10); var tip = d3.tip() .attr('class', 'd3-tip') .offset([-10, 0]) .html(function(d) { - return "Students: " + d + ""; + return "Students: " + d + ""; }); var svg = d3.select("#chart_2").append("svg") @@ -239,17 +229,6 @@ $(document).on('turbolinks:load', function() { svg.call(tip); - x.domain(minutes_array_for_bar.map(function (d, i) { - i++; - var high_side = i * group_increment; - var low_side = high_side - group_increment; - return (low_side+"-"+high_side); - })); - - y.domain(minutes_array_for_bar.map(function (d) { - return (d); - })); - svg.append("g") .attr("class", "x axis") .attr("transform", "translate(0," + height + ")") @@ -269,10 +248,8 @@ $(document).on('turbolinks:load', function() { .attr("transform", "rotate(-90)") .attr("y", 6) .attr("dy", ".71em"); - //.style("text-anchor", "end") - //.text("Students"); - svg.append("text") // y axis label + svg.append("text") .attr("transform", "rotate(-90)") .attr("x", -height / 2) .attr("dy", "-3em") @@ -280,7 +257,7 @@ $(document).on('turbolinks:load', function() { .text("Students") .style('font-size', 14); - svg.append("text")// x axis label + svg.append("text") .attr("class", "x axis") .attr("text-anchor", "middle") .attr("x", width / 2) @@ -289,36 +266,31 @@ $(document).on('turbolinks:load', function() { .text("Working Time (Minutes)") .style('font-size', 14); - y = d3.scaleLinear() - .domain([(0),max_of_array]) - .range([0,height]); - - svg.selectAll(".bar") - .data(minutes_array_for_bar) + .data(workingTimeGroups) .enter().append("rect") .attr("class", "bar") - .attr("x", function(d,i) { var bar_increment = width / minutes_array_for_bar.length; - var bar_x = i * bar_increment; - return (bar_x)}) + .attr("x", function(d, i) { + return x(i * groupWidth); + }) .attr("width", x.bandwidth()) - .attr("y", function(d) { return height - y(d); }) - .attr("height", function(d) { return y(d); }) - .on('mouseover', tip.show) + .attr("y", function(d) { return y(d); }) + .attr("height", function(d) { return height - y(d); }) + .on('mouseenter', tip.show) .on('mouseout', tip.hide); - svg.append("text")// Title + svg.append("text") .attr("class", "x axis") .attr("text-anchor", "middle") - .attr("x", (width / 2))//+300) + .attr("x", (width / 2)) .attr("y", 0) .attr("dy", '-1.5em') .text("Distribution of Time Spent by Students") .style('font-size', 20) .style('text-decoration', 'underline'); - } - draw_bar_graph(); + + drawBarGraph(); } }); diff --git a/app/controllers/concerns/lti.rb b/app/controllers/concerns/lti.rb index a2374c70..b0b31701 100644 --- a/app/controllers/concerns/lti.rb +++ b/app/controllers/concerns/lti.rb @@ -69,6 +69,11 @@ module Lti result end + def mooc_course + # All Xikolo platforms set the custom_course to the course code + params[:custom_course] + end + def refuse_lti_launch(options = {}) return_to_consumer(lti_errorlog: options[:message], lti_errormsg: t('sessions.oauth.failure')) end @@ -154,6 +159,14 @@ module Lti end private :set_current_user + + def set_study_group_membership + return if mooc_course + group = StudyGroup.find_or_create_by(external_id: @provider.resource_link_id, consumer: @consumer) + group.users |= [@current_user] # add current user if not already member of the group + group.save + end + def set_embedding_options @embed_options = {} [:hide_navbar, diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index bd1a16ec..8d7760fc 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, :set_current_user, :require_valid_exercise_token, :set_embedding_options].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, :set_study_group_membership, :set_embedding_options].each do |method_name| before_action(method_name, only: :create_through_lti) end diff --git a/app/models/concerns/user.rb b/app/models/concerns/user.rb deleted file mode 100644 index 78299717..00000000 --- a/app/models/concerns/user.rb +++ /dev/null @@ -1,32 +0,0 @@ -module User - extend ActiveSupport::Concern - - ROLES = %w(admin teacher) - - included do - belongs_to :consumer - has_many :exercises, as: :user - has_many :file_types, as: :user - has_many :submissions, as: :user - has_many :participations, through: :submissions, source: :exercise, 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 - - ROLES.each do |role| - define_method("#{role}?") { try(:role) == role } - end - - [ExternalUser, InternalUser].each do |klass| - define_method("#{klass.name.underscore}?") { is_a?(klass) } - end - - def to_s - displayname - end -end diff --git a/app/models/external_user.rb b/app/models/external_user.rb index fbd3cd3d..e19a269e 100644 --- a/app/models/external_user.rb +++ b/app/models/external_user.rb @@ -1,5 +1,4 @@ -class ExternalUser < ApplicationRecord - include User +class ExternalUser < User validates :consumer_id, presence: true validates :external_id, presence: true diff --git a/app/models/internal_user.rb b/app/models/internal_user.rb index e7adda6d..9cef2cb4 100644 --- a/app/models/internal_user.rb +++ b/app/models/internal_user.rb @@ -1,5 +1,4 @@ -class InternalUser < ApplicationRecord - include User +class InternalUser < User authenticates_with_sorcery! diff --git a/app/models/request_for_comment.rb b/app/models/request_for_comment.rb index fe15409b..b4fa819b 100644 --- a/app/models/request_for_comment.rb +++ b/app/models/request_for_comment.rb @@ -46,11 +46,7 @@ class RequestForComment < ApplicationRecord end def commenters - commenters = [] - comments.distinct.to_a.each {|comment| - commenters.append comment.user - } - commenters.uniq {|user| user.id} + comments.map(&:user).uniq end def self.with_last_activity diff --git a/app/models/study_group.rb b/app/models/study_group.rb new file mode 100644 index 00000000..b86f4969 --- /dev/null +++ b/app/models/study_group.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class StudyGroup < ApplicationRecord + has_many :study_group_memberships + # Use `ExternalUser` as `source_type` for now. + # Using `User` will lead ActiveRecord to access the inexistent table `users`. + # Issue created: https://github.com/rails/rails/issues/34531 + has_many :users, through: :study_group_memberships, source_type: 'ExternalUser' + has_many :submissions + belongs_to :consumer +end diff --git a/app/models/study_group_membership.rb b/app/models/study_group_membership.rb new file mode 100644 index 00000000..1f7aad91 --- /dev/null +++ b/app/models/study_group_membership.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class StudyGroupMembership < ApplicationRecord + belongs_to :user, polymorphic: true + belongs_to :study_group + + validates_uniqueness_of :user_id, :scope => [:user_type, :study_group_id] +end diff --git a/app/models/submission.rb b/app/models/submission.rb index e9d3b235..fe765543 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -6,6 +6,7 @@ class Submission < ApplicationRecord FILENAME_URL_PLACEHOLDER = '{filename}' belongs_to :exercise + belongs_to :study_group, optional: true has_many :testruns has_many :structured_errors diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 00000000..a9c9dab2 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,36 @@ +class User < ApplicationRecord + self.abstract_class = true + + ROLES = %w(admin teacher) + + belongs_to :consumer + has_many :study_group_memberships, as: :user + has_many :study_groups, through: :study_group_memberships, as: :user + has_many :exercises, as: :user + has_many :file_types, as: :user + has_many :submissions, as: :user + has_many :participations, through: :submissions, source: :exercise, 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)') } + + ROLES.each do |role| + define_method("#{role}?") { try(:role) == role } + end + + def internal_user? + is_a?(InternalUser) + end + + def external_user? + is_a?(ExternalUser) + end + + def to_s + displayname + end +end diff --git a/app/views/exercises/statistics.html.slim b/app/views/exercises/statistics.html.slim index c982adff..dfa79b25 100644 --- a/app/views/exercises/statistics.html.slim +++ b/app/views/exercises/statistics.html.slim @@ -33,7 +33,7 @@ h1 = @exercise -working_time_array.push working_time hr .d-none#data data-working-time=ActiveSupport::JSON.encode(working_time_array) - .graph-functions + .working-time-graphs div#chart_1 hr div#chart_2 diff --git a/db/migrate/20181122084546_create_study_groups.rb b/db/migrate/20181122084546_create_study_groups.rb new file mode 100644 index 00000000..63919d75 --- /dev/null +++ b/db/migrate/20181122084546_create_study_groups.rb @@ -0,0 +1,13 @@ +class CreateStudyGroups < ActiveRecord::Migration[5.2] + def change + create_table :study_groups do |t| + t.string :name + t.string :external_id + t.belongs_to :consumer + t.timestamps + end + + add_index :study_groups, [:external_id, :consumer_id], unique: true + end +end + diff --git a/db/migrate/20181122090243_create_study_group_memberships.rb b/db/migrate/20181122090243_create_study_group_memberships.rb new file mode 100644 index 00000000..8cb34dd3 --- /dev/null +++ b/db/migrate/20181122090243_create_study_group_memberships.rb @@ -0,0 +1,8 @@ +class CreateStudyGroupMemberships < ActiveRecord::Migration[5.2] + def change + create_table :study_group_memberships do |t| + t.belongs_to :study_group + t.belongs_to :user, polymorphic: true + end + end +end diff --git a/db/migrate/20181122090244_add_study_group_to_submission.rb b/db/migrate/20181122090244_add_study_group_to_submission.rb new file mode 100644 index 00000000..94cd30d6 --- /dev/null +++ b/db/migrate/20181122090244_add_study_group_to_submission.rb @@ -0,0 +1,5 @@ +class AddStudyGroupToSubmission < ActiveRecord::Migration[5.2] + def change + add_reference :submissions, :study_group, index: true, null: true, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 73cf1e9d..406e7d2e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -320,6 +320,24 @@ ActiveRecord::Schema.define(version: 2018_11_29_093207) do t.index ["submission_id"], name: "index_structured_errors_on_submission_id" end + create_table "study_group_memberships", force: :cascade do |t| + t.bigint "study_group_id" + t.string "user_type" + t.bigint "user_id" + t.index ["study_group_id"], name: "index_study_group_memberships_on_study_group_id" + t.index ["user_type", "user_id"], name: "index_study_group_memberships_on_user_type_and_user_id" + end + + create_table "study_groups", force: :cascade do |t| + t.string "name" + t.string "external_id" + t.bigint "consumer_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["consumer_id"], name: "index_study_groups_on_consumer_id" + t.index ["external_id", "consumer_id"], name: "index_study_groups_on_external_id_and_consumer_id", unique: true + end + create_table "submissions", force: :cascade do |t| t.integer "exercise_id" t.float "score" @@ -328,7 +346,9 @@ ActiveRecord::Schema.define(version: 2018_11_29_093207) do t.datetime "updated_at" t.string "cause", limit: 255 t.string "user_type", limit: 255 + t.bigint "study_group_id" t.index ["exercise_id"], name: "index_submissions_on_exercise_id" + t.index ["study_group_id"], name: "index_submissions_on_study_group_id" t.index ["user_id"], name: "index_submissions_on_user_id" end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index c1ea7ba1..6692e56b 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -66,7 +66,7 @@ describe SessionsController do it 'refuses the LTI launch' do expect_any_instance_of(IMS::LTI::ToolProvider).to receive(:valid_request?).and_return(true) expect(controller).to receive(:refuse_lti_launch).with(message: I18n.t('sessions.oauth.invalid_exercise_token')).and_call_original - post :create_through_lti, params: { custom_token: '', oauth_consumer_key: consumer.oauth_key, oauth_nonce: nonce, oauth_signature: SecureRandom.hex } + post :create_through_lti, params: { custom_token: '', oauth_consumer_key: consumer.oauth_key, oauth_nonce: nonce, oauth_signature: SecureRandom.hex, user_id: '123' } end end