Merge pull request #261 from openHPI/feature/la-dashboard

Add LA dashboard architecture
This commit is contained in:
rteusner
2019-03-12 14:30:25 +01:00
committed by GitHub
60 changed files with 4120 additions and 3042 deletions

View File

@@ -13,7 +13,10 @@
//= require jquery_ujs
//= require turbolinks
//= require pagedown_bootstrap
//= require d3
//= require rails-timeago
//= require locales/jquery.timeago.de.js
//= require i18n
//= require i18n/translations
//
// lib/assets
//= require flash

View File

@@ -23,3 +23,14 @@ $.fn.scrollTo = function(selector) {
scrollTop: $(selector).offset().top - $(this).offset().top + $(this).scrollTop()
}, ANIMATION_DURATION);
};
// Same as $.replaceWith, just returns the new element instead of the deleted one
$.fn.replaceWithAndReturnNewElement = function(a) {
const $a = $(a);
this.replaceWith($a);
return $a;
};
// Disable the use of web workers for JStree due to JS error
// See https://github.com/vakata/jstree/issues/1717 for details
$.jstree.defaults.core.worker = false;

View File

@@ -0,0 +1,13 @@
// Action Cable provides the framework to deal with WebSockets in Rails.
// You can generate new channels where WebSocket features live using the `rails generate channel` command.
//
//= require action_cable
//= require_self
//= require_tree ./channels
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);

View File

@@ -0,0 +1,250 @@
$(document).on('turbolinks:load', function() {
if ($.isController('exercises') && $('.teacher_dashboard').isPresent()) {
const exercise_id = $('.teacher_dashboard').data().exerciseId;
const study_group_id = $('.teacher_dashboard').data().studyGroupId;
const specific_channel = { channel: "LaExercisesChannel", exercise_id: exercise_id, study_group_id: study_group_id };
App.la_exercise = App.cable.subscriptions.create(specific_channel, {
connected: function () {
// Called when the subscription is ready for use on the server
},
disconnected: function () {
// Called when the subscription has been terminated by the server
},
received: function (data) {
// Called when there's incoming data on the websocket for this channel
if (data.type === 'rfc') {
handleNewRfCdata(data);
} else if (data.type === 'working_times') {
handleWorkingTimeUpdate(data.working_time_data)
}
}
});
function handleNewRfCdata(data) {
let $row = $('tr[data-id="' + data.id + '"]');
if ($row.length === 0) {
$row = $($('#posted_rfcs')[0].insertRow(0));
}
$row = $row.replaceWithAndReturnNewElement(data.html);
$row.find('time').timeago();
$row.click(function () {
Turbolinks.visit($(this).data("href"));
});
}
function handleWorkingTimeUpdate(data) {
const user_progress = data['user_progress'];
const additional_user_data = data['additional_user_data'];
const user = additional_user_data[additional_user_data.length - 1][0];
const position = userPosition[user.type + user.id]; // TODO validate: will result in undef. if not existent.
// TODO: Do update
}
const graph_data = $('#initial_graph_data').data('graph_data');
let userPosition = {};
drawGraph(graph_data);
function drawGraph(graph_data) {
const user_progress = graph_data['user_progress'];
const additional_user_data = graph_data['additional_user_data'];
function get_minutes (time_stamp) {
try {
hours = time_stamp.split(":")[0];
minutes = time_stamp.split(":")[1];
seconds = time_stamp.split(":")[2];
seconds /= 60;
minutes = parseFloat(hours * 60) + parseInt(minutes) + seconds;
if (minutes > 0){
return minutes;
} else{
return parseFloat(seconds/60);
}
} catch (err) {
return 0;
}
}
function learners_name(index) {
return additional_user_data[additional_user_data.length - 1][index]["name"] + ", ID: " + additional_user_data[additional_user_data.length - 1][index]["id"];
}
function learners_time(group, index) {
if (user_progress[group] !== null && user_progress[group] !== undefined && user_progress[group][index] !== null) {
return user_progress[group][index]
} else {
return 0;
}
}
if (user_progress.length === 0) {
// No data available
$('#no_chart_data').removeClass("d-none");
return;
}
const margin = ({top: 20, right: 20, bottom: 150, left: 80});
const width = $('#chart_stacked').width();
const height = 500;
const users = user_progress[0].length; // # of users
const n = user_progress.length; // # of different sub bars, called buckets
let working_times_in_minutes = d3.range(n).map((index) => {
if (user_progress[index] !== null) {
return user_progress[index].map((time) => get_minutes(time))
} else return new Array(users).fill(0);
});
let xAxis = svg => svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0).tickFormat((index) => learners_name(index)));
let yAxis = svg => svg.append("g")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(y).tickSizeOuter(0).tickFormat((index) => index));
let color = d3.scaleSequential(d3.interpolateRdYlGn)
.domain([-0.5 * n, 1.5 * n]);
let userAxis = d3.range(users); // the x-values shared by all series
// Calculate the corresponding start and end value of each value;
const yBarValuesGrouped = d3.stack()
.keys(d3.range(n))
(d3.transpose(working_times_in_minutes)) // stacked working_times_in_minutes
.map((data, i) => data.map(([y0, y1]) => [y0, y1, i]));
const maxYSingleBar = d3.max(working_times_in_minutes, y => d3.max(y));
const maxYBarStacked = d3.max(yBarValuesGrouped, y => d3.max(y, d => d[1]));
let x = d3.scaleBand()
.domain(userAxis)
.rangeRound([margin.left, width - margin.right])
.padding(0.08);
let y = d3.scaleLinear()
.domain([0, maxYBarStacked])
.range([height - margin.bottom, margin.top]);
const svg = d3.select("#chart_stacked")
.append("svg")
.attr("width", '100%')
.attr("height", '100%')
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("preserveAspectRatio","xMinYMin meet");
const rect = svg.selectAll("g")
.data(yBarValuesGrouped)
.enter().append("g")
.attr("fill", (d, i) => color(i))
.selectAll("rect")
.data(d => d)
.join("rect")
.attr("x", (d, i) => x(i))
.attr("y", height - margin.bottom)
.attr("width", x.bandwidth())
.attr("height", 0)
.attr("class", (d) => "bar-stacked-"+d[2]);
svg.append("g")
.attr("class", "x axis")
.call(xAxis)
.selectAll("text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", function(d) {
return "rotate(-45)"
});
svg.append("g")
.attr("class", "y axis")
.call(yAxis);
// Y Axis Label
svg.append("text")
.attr("transform", "rotate(-90)")
.attr("x", (-height - margin.top + margin.bottom) / 2)
.attr("dy", "+2em")
.style("text-anchor", "middle")
.text(I18n.t('exercises.study_group_dashboard.time_spent_in_minutes'))
.style('font-size', 14);
// X Axis Label
svg.append("text")
.attr("class", "x axis")
.attr("text-anchor", "middle")
.attr("x", (width + margin.left - margin.right) / 2)
.attr("y", height)
.attr("dy", '-1em')
.text(I18n.t('exercises.study_group_dashboard.learner'))
.style('font-size', 14);
let tip = d3.tip()
.attr('class', 'd3-tip')
.offset([-10, 0])
.html(function(d, i, a) {
return "<strong>Student: </strong><span style='color:orange'>" + learners_name(i) + "</span><br/>" +
"0: " + learners_time(0, i) + "<br/>" +
"1: " + learners_time(1, i) + "<br/>" +
"2: " + learners_time(2, i) + "<br/>" +
"3: " + learners_time(3, i) + "<br/>" +
"4: " + learners_time(4, i);
});
svg.call(tip);
rect.on('mouseenter', tip.show)
.on('mouseout', tip.hide);
function transitionGrouped() {
// Show all sub-bars next to each other
y.domain([0, maxYSingleBar]);
rect.transition()
.duration(500)
.delay((d, i) => i * 20)
.attr("x", (d, i) => x(i) + x.bandwidth() / n * d[2])
.attr("width", x.bandwidth() / n)
.transition()
.attr("y", d => y(d[1] - d[0]))
.attr("height", d => y(0) - y(d[1] - d[0]));
}
function transitionStacked() {
// Show all sub-bars on top of each other
y.domain([0, maxYBarStacked]);
rect.transition()
.duration(500)
.delay((d, i) => i * 20)
.attr("y", d => y(d[1]))
.attr("height", d => y(d[0]) - y(d[1]))
.transition()
.attr("x", (d, i) => x(i))
.attr("width", x.bandwidth());
}
$('#no_chart_data').addClass("d-none");
transitionStacked();
// ToDo: Add button to switch using transitionGrouped();
buildDictionary(additional_user_data);
}
function buildDictionary(users) {
users[users.length - 1].forEach(function(user, index) {
userPosition[user.type + user.id] = index;
});
}
}
});

View File

@@ -113,3 +113,7 @@ span.caret {
-webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);
}
}
.table-row-clickable {
cursor: pointer;
}

View File

@@ -84,6 +84,10 @@ div#chart_2 {
background-color: #FAFAFA;
}
div#chart_stacked {
max-height: 500px;
background-color: #FAFAFA;
}
a.file-heading {
color: black !important;
@@ -115,7 +119,7 @@ a.file-heading {
.d3-tip:after {
box-sizing: border-box;
display: inline;
font-size: 10px;
font-size: 14px;
width: 100%;
line-height: 1;
color: rgba(0, 0, 0, 0.8);
@@ -126,7 +130,7 @@ a.file-heading {
/* Style northward tooltips differently */
.d3-tip.n:after {
margin: -1px 0 0 0;
margin: -3px 0 0 0;
top: 100%;
left: 0;
}

View File

@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@@ -0,0 +1,30 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
def disconnect
# Any cleanup work needed when the cable connection is cut.
end
private
def session
# `session` is not available here, so that we need to use `cookies.encrypted` instead
cookies.encrypted[Rails.application.config.session_options[:key]].symbolize_keys
end
def find_verified_user
# Finding the current_user is similar to the code used in application_controller.rb#current_user
current_user = ExternalUser.find_by(id: session[:external_user_id]) || InternalUser.find_by(id: session[:user_id])
if current_user
current_user
else
reject_unauthorized_connection
end
end
end
end

View File

@@ -0,0 +1,16 @@
class LaExercisesChannel < ApplicationCable::Channel
def subscribed
stream_from specific_channel
end
def unsubscribed
stop_all_streams
end
private
def specific_channel
reject unless StudyGroupPolicy.new(current_user, StudyGroup.find_by(id: params[:study_group_id])).stream_la?
"la_exercises_#{params[:exercise_id]}_channel_study_group_#{params[:study_group_id]}"
end
end

View File

@@ -168,6 +168,7 @@ module Lti
end
group.users |= [@current_user] # add current user if not already member of the group
group.save
session[:study_group_id] = group.id
end
def set_embedding_options

View File

@@ -16,7 +16,8 @@ module SubmissionParameters
current_user_id = current_user.id
current_user_class_name = current_user.class.name
end
submission_params = params[:submission].present? ? params[:submission].permit(:cause, :exercise_id, files_attributes: file_attributes).merge(user_id: current_user_id, user_type: current_user_class_name) : {}
# The study_group_id might not be present in the session (e.g. for internal users), resulting in session[:study_group_id] = nil which is intended.
submission_params = params[:submission].present? ? params[:submission].permit(:cause, :exercise_id, files_attributes: file_attributes).merge(user_id: current_user_id, user_type: current_user_class_name, study_group_id: session[:study_group_id]) : {}
reject_illegal_file_attributes!(submission_params)
submission_params
end

View File

@@ -40,7 +40,7 @@ class ExecutionEnvironmentsController < ApplicationController
FROM
(SELECT user_id,
exercise_id,
CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
FROM
(SELECT user_id,
exercise_id,

View File

@@ -7,7 +7,7 @@ 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_and_authorize, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload, :feedback]
before_action :set_exercise_and_authorize, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload, :feedback, :study_group_dashboard]
before_action :set_external_user_and_authorize, only: [:statistics]
before_action :set_file_types, only: [:create, :edit, :new, :update]
before_action :set_course_token, only: [:implement]
@@ -266,7 +266,7 @@ class ExercisesController < ApplicationController
end
def index
@search = policy_scope(Exercise).search(params[:q])
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page])
authorize!
end
@@ -319,7 +319,7 @@ class ExercisesController < ApplicationController
private :set_file_types
def collect_set_and_unset_exercise_tags
@search = policy_scope(Tag).search(params[:q])
@search = policy_scope(Tag).ransack(params[:q])
@tags = @search.result.order(:name)
checked_exercise_tags = @exercise.exercise_tags
checked_tags = checked_exercise_tags.collect{|e| e.tag}.to_set
@@ -343,7 +343,7 @@ class ExercisesController < ApplicationController
@all_events = (@submissions + interventions).sort_by { |a| a.created_at }
@deltas = @all_events.map.with_index do |item, index|
delta = item.created_at - @all_events[index - 1].created_at if index > 0
if delta == nil or delta > 10 * 60 then 0 else delta end
if delta == nil or delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS then 0 else delta end
end
@working_times_until = []
@all_events.each_with_index do |_, index|
@@ -475,4 +475,15 @@ class ExercisesController < ApplicationController
end
end
def study_group_dashboard
authorize!
@study_group_id = params[:study_group_id]
@request_for_comments = RequestForComment.
where(exercise: @exercise).includes(:submission).
where(submissions: {study_group_id: @study_group_id}).
order(created_at: :desc)
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
end
end

View File

@@ -27,7 +27,7 @@ class ExternalUsersController < ApplicationController
score,
id,
CASE
WHEN working_time >= '0:05:00' THEN '0'
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
ELSE working_time
END AS working_time_new
FROM

View File

@@ -60,7 +60,7 @@ class InternalUsersController < ApplicationController
end
def index
@search = InternalUser.search(params[:q])
@search = InternalUser.ransack(params[:q])
@users = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
authorize!
end

View File

@@ -33,7 +33,7 @@ class ProxyExercisesController < ApplicationController
end
def edit
@search = policy_scope(Exercise).search(params[:q])
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.order(:title)
authorize!
end
@@ -44,14 +44,14 @@ class ProxyExercisesController < ApplicationController
private :proxy_exercise_params
def index
@search = policy_scope(ProxyExercise).search(params[:q])
@search = policy_scope(ProxyExercise).ransack(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])
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.order(:title)
authorize!
end
@@ -63,8 +63,8 @@ class ProxyExercisesController < ApplicationController
private :set_exercise_and_authorize
def show
@search = @proxy_exercise.exercises.search
@exercises = @proxy_exercise.exercises.search.result.order(:title) #@search.result.order(:title)
@search = @proxy_exercise.exercises.ransack
@exercises = @proxy_exercise.exercises.ransack.result.order(:title) #@search.result.order(:title)
end
#we might want to think about auth here

View File

@@ -15,7 +15,7 @@ class RequestForCommentsController < ApplicationController
@search = RequestForComment
.last_per_user(2)
.with_last_activity
.search(params[:q])
.ransack(params[:q])
@request_for_comments = @search.result
.order('created_at DESC')
.paginate(page: params[:page], total_entries: @search.result.length)
@@ -27,7 +27,7 @@ class RequestForCommentsController < ApplicationController
@search = RequestForComment
.with_last_activity
.where(user_id: current_user.id)
.search(params[:q])
.ransack(params[:q])
@request_for_comments = @search.result
.order('created_at DESC')
.paginate(page: params[:page])
@@ -40,7 +40,7 @@ class RequestForCommentsController < ApplicationController
.with_last_activity
.joins(:comments) # we don't need to outer join here, because we know the user has commented on these
.where(comments: {user_id: current_user.id})
.search(params[:q])
.ransack(params[:q])
@request_for_comments = @search.result
.order('last_comment DESC')
.paginate(page: params[:page])
@@ -83,17 +83,10 @@ class RequestForCommentsController < ApplicationController
authorize!
end
# GET /request_for_comments/new
def new
@request_for_comment = RequestForComment.new
authorize!
end
# GET /request_for_comments/1/edit
def edit
end
# POST /request_for_comments
# POST /request_for_comments.json
def create
# Consider all requests as JSON
@@ -149,7 +142,7 @@ class RequestForCommentsController < ApplicationController
# Never trust parameters from the scary internet, only allow the white list through.
def request_for_comment_params
# 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.
# The study_group_id might not be present in the session (e.g. for internal users), resulting in session[:study_group_id] = nil which is intended.
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

View File

@@ -4,17 +4,17 @@ class StudyGroupsController < ApplicationController
before_action :set_group, only: MEMBER_ACTIONS
def index
@search = StudyGroup.search(params[:q])
@search = StudyGroup.ransack(params[:q])
@study_groups = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
authorize!
end
def show
@search = @study_group.users.search(params[:q])
@search = @study_group.users.ransack(params[:q])
end
def edit
@search = @study_group.users.search(params[:q])
@search = @study_group.users.ransack(params[:q])
@members = StudyGroupMembership.where(user: @search.result, study_group: @study_group)
end

View File

@@ -106,7 +106,7 @@ class SubmissionsController < ApplicationController
end
def index
@search = Submission.search(params[:q])
@search = Submission.ransack(params[:q])
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page])
authorize!
end
@@ -201,6 +201,8 @@ class SubmissionsController < ApplicationController
save_run_output
if @run_output.blank?
@raw_output ||= ''
@run_output ||= ''
parse_message t('exercises.implement.no_output', timestamp: l(Time.now, format: :short)), 'stdout', tubesock
end

View File

@@ -0,0 +1,30 @@
module ActionCableHelper
def trigger_rfc_action_cable
# Context: RfC
if submission.study_group_id.present?
ActionCable.server.broadcast(
"la_exercises_#{exercise_id}_channel_study_group_#{submission.study_group_id}",
type: :rfc,
id: id,
html: (ApplicationController.render(partial: 'request_for_comments/list_entry',
locals: {request_for_comment: self})))
end
end
def trigger_rfc_action_cable_from_comment
# Context: Comment
RequestForComment.find_by(submission: file.context).trigger_rfc_action_cable
end
def trigger_working_times_action_cable
# Context: Submission
if study_group_id.present?
ActionCable.server.broadcast(
"la_exercises_#{exercise_id}_channel_study_group_#{study_group_id}",
type: :working_times,
working_time_data: exercise.get_working_times_for_study_group(study_group_id, user))
end
end
end
# TODO: Check if any user is connected and prevent preparing the data otherwise

View File

@@ -1,5 +1,8 @@
module StatisticsHelper
WORKING_TIME_DELTA_IN_SECONDS = 5.minutes
WORKING_TIME_DELTA_IN_SQL_INTERVAL = "'0:05:00'" # yes, a string with quotes
def statistics_data
[
{

View File

@@ -13,7 +13,9 @@ import 'bootstrap/dist/js/bootstrap.bundle.min';
import 'chosen-js/chosen.jquery';
import 'jstree';
import 'underscore';
import 'd3'
window._ = _; // Publish underscore's `_` in global namespace
window.d3 = d3; // Publish d3 in global namespace
// CSS
import 'chosen-js/chosen.css';

View File

@@ -1,8 +1,12 @@
class Comment < ApplicationRecord
# inherit the creation module: encapsulates that this is a polymorphic user, offers some aliases and makes sure that all necessary attributes are set.
include Creation
include ActionCableHelper
attr_accessor :username, :date, :updated, :editable
belongs_to :file, class_name: 'CodeOcean::File'
belongs_to :user, polymorphic: true
after_save :trigger_rfc_action_cable_from_comment
end

View File

@@ -76,7 +76,7 @@ class Exercise < ApplicationRecord
(SELECT user_id,
user_type,
score,
CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
FROM
(SELECT user_id,
user_type,
@@ -90,6 +90,145 @@ class Exercise < ApplicationRecord
"
end
def study_group_working_time_query(exercise_id, study_group_id, additional_filter)
"""
WITH working_time_between_submissions AS (
SELECT submissions.user_id,
submissions.user_type,
score,
created_at,
(created_at - lag(created_at) over (PARTITION BY submissions.user_type, submissions.user_id, exercise_id
ORDER BY created_at)) AS working_time
FROM submissions
WHERE exercise_id = #{exercise_id} AND study_group_id = #{study_group_id} #{additional_filter}),
working_time_with_deltas_ignored AS (
SELECT user_id,
user_type,
score,
sum(CASE WHEN score IS NOT NULL THEN 1 ELSE 0 END)
over (ORDER BY user_type, user_id, created_at ASC) AS change_in_score,
created_at,
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_filtered
FROM working_time_between_submissions
),
working_times_with_score_expanded AS (
SELECT user_id,
user_type,
created_at,
working_time_filtered,
first_value(score)
over (PARTITION BY user_type, user_id, change_in_score ORDER BY created_at ASC) AS corrected_score
FROM working_time_with_deltas_ignored
),
working_times_with_duplicated_last_row_per_score AS (
SELECT *
FROM working_times_with_score_expanded
UNION ALL
-- Duplicate last row per user and score and make it unique by setting another created_at timestamp.
-- In addition, the working time is set to zero in order to prevent getting a wrong time.
-- This duplication is needed, as we will shift the scores and working times by one and need to ensure not to loose any information.
SELECT DISTINCT ON (user_type, user_id, corrected_score) user_id,
user_type,
created_at + INTERVAL '1us',
'00:00:00' as working_time_filtered,
corrected_score
FROM working_times_with_score_expanded
),
working_times_with_score_not_null_and_shifted AS (
SELECT user_id,
user_type,
coalesce(lag(corrected_score) over (PARTITION BY user_type, user_id ORDER BY created_at ASC),
0) AS shifted_score,
created_at,
working_time_filtered
FROM working_times_with_duplicated_last_row_per_score
),
working_times_to_be_sorted AS (
SELECT user_id,
user_type,
shifted_score AS score,
MIN(created_at) AS start_time,
SUM(working_time_filtered) AS working_time_per_score,
SUM(SUM(working_time_filtered)) over (PARTITION BY user_type, user_id) AS total_working_time
FROM working_times_with_score_not_null_and_shifted
GROUP BY user_id, user_type, score
),
working_times_with_index AS (
SELECT (dense_rank() over (ORDER BY total_working_time, user_type, user_id ASC) - 1) AS index,
user_id,
user_type,
score,
start_time,
working_time_per_score,
total_working_time
FROM working_times_to_be_sorted)
SELECT index,
user_id,
user_type,
name,
score,
start_time,
working_time_per_score,
total_working_time
FROM working_times_with_index
JOIN external_users ON user_type = 'ExternalUser' AND user_id = external_users.id
UNION ALL
SELECT index,
user_id,
user_type,
name,
score,
start_time,
working_time_per_score,
total_working_time
FROM working_times_with_index
JOIN internal_users ON user_type = 'InternalUser' AND user_id = internal_users.id
ORDER BY index, score ASC;
"""
end
def get_working_times_for_study_group(study_group_id, user = nil)
user_progress = []
additional_user_data = []
max_bucket = 4
maximum_score = self.maximum_score
if user.blank?
additional_filter = ''
else
additional_filter = "AND user_id = #{user.id} AND user_type = '#{user.class.name}'"
end
results = self.class.connection.execute(study_group_working_time_query(id, study_group_id, additional_filter)).each do |tuple|
if tuple['score'] <= maximum_score
bucket = tuple['score'] / maximum_score * max_bucket
else
bucket = max_bucket # maximum_score / maximum_score will always be 1
end
user_progress[bucket] ||= []
additional_user_data[bucket] ||= []
additional_user_data[max_bucket + 1] ||= []
user_progress[bucket][tuple['index']] = tuple["working_time_per_score"]
additional_user_data[bucket][tuple['index']] = {start_time: tuple["start_time"], score: tuple["score"]}
additional_user_data[max_bucket + 1][tuple['index']] = {id: tuple['user_id'], type: tuple['user_type'], name: tuple['name']}
end
if results.ntuples > 0
first_index = results[0]['index']
last_index = results[results.ntuples-1]['index']
buckets = last_index - first_index
user_progress.each do |timings_array|
if timings_array.present? && timings_array.length != buckets + 1
timings_array[buckets] = nil
end
end
end
{user_progress: user_progress, additional_user_data: additional_user_data}
end
def get_quantiles(quantiles)
quantiles_str = "[" + quantiles.join(",") + "]"
result = self.class.connection.execute("""
@@ -180,7 +319,7 @@ class Exercise < ApplicationRecord
exercise_id,
max_score,
CASE
WHEN working_time >= '0:05:00' THEN '0'
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
ELSE working_time
END AS working_time_new
FROM all_working_times_until_max ), result AS
@@ -274,7 +413,7 @@ class Exercise < ApplicationRecord
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
SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} 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

View File

@@ -1,5 +1,7 @@
class RequestForComment < ApplicationRecord
include Creation
include ActionCableHelper
belongs_to :submission
belongs_to :exercise
belongs_to :file, class_name: 'CodeOcean::File'
@@ -10,6 +12,8 @@ class RequestForComment < ApplicationRecord
scope :unsolved, -> { where(solved: [false, nil]) }
scope :in_range, -> (from, to) { where(created_at: from..to) }
after_save :trigger_rfc_action_cable
def self.last_per_user(n = 5)
from("(#{row_number_user_sql}) as request_for_comments")
.where("row_number <= ?", n)

View File

@@ -1,12 +1,12 @@
# frozen_string_literal: true
class StudyGroup < ApplicationRecord
has_many :study_group_memberships
has_many :study_group_memberships, dependent: :destroy
# 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
has_many :submissions, dependent: :nullify
belongs_to :consumer
def to_s

View File

@@ -1,6 +1,7 @@
class Submission < ApplicationRecord
include Context
include Creation
include ActionCableHelper
CAUSES = %w(assess download file render run save submit test autosave requestComments remoteAssess)
FILENAME_URL_PLACEHOLDER = '{filename}'
@@ -20,6 +21,8 @@ class Submission < ApplicationRecord
validates :cause, inclusion: {in: CAUSES}
validates :exercise_id, presence: true
after_save :trigger_working_times_action_cable
MAX_COMMENTS_ON_RECOMMENDED_RFC = 5
def build_files_hash(files, attribute)

View File

@@ -25,6 +25,22 @@ class ApplicationPolicy
end
private :no_one
def everyone_in_study_group
study_group = @record.study_group
return false if study_group.blank?
users_in_same_study_group = study_group.users
return false if users_in_same_study_group.blank?
users_in_same_study_group.include? @user
end
private :everyone_in_study_group
def teacher_in_study_group
teacher? && everyone_in_study_group
end
private :teacher_in_study_group
def initialize(user, record)
@user = user
@record = record

View File

@@ -3,8 +3,8 @@ class ExercisePolicy < AdminOrAuthorPolicy
admin?
end
def show?
admin? || teacher?
[:show?, :study_group_dashboard?].each do |action|
define_method(action) { admin? || teacher? }
end
[:clone?, :destroy?, :edit?, :statistics?, :update?, :feedback?].each do |action|

View File

@@ -3,8 +3,8 @@ class StudyGroupPolicy < AdminOnlyPolicy
admin? || teacher?
end
[:show?, :destroy?, :edit?, :update?].each do |action|
define_method(action) { admin? || @user.teacher? && @record.users.include?(@user) }
[:show?, :destroy?, :edit?, :update?, :stream_la?].each do |action|
define_method(action) { admin? || @user.teacher? && @record.present? && @record.users.include?(@user) }
end
class Scope < Scope

View File

@@ -12,14 +12,8 @@ class SubmissionPolicy < ApplicationPolicy
admin?
end
def everyone_in_study_group
users_in_same_study_group = @record.study_groups.users
users_in_same_study_group.include? @user
end
private :everyone_in_study_group
def teacher_in_study_group
teacher? && everyone_in_study_group
def show_study_group?
admin? || teacher_in_study_group
end
private :teacher_in_study_group
end

View File

@@ -58,7 +58,7 @@ h1 = "#{@exercise} (external user #{@external_user})"
td =
td =
td = @working_times_until[index] if index > 0
p = t('.addendum')
p = t('.addendum', delta: StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS / 60)
.d-none#wtimes data-working_times=ActiveSupport::JSON.encode(@working_times_until);
div#progress_chart.col-lg-12
.graph-functions-2

View File

@@ -0,0 +1,37 @@
- content_for :head do
// Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326.
Otherwise, code might not be highlighted correctly (race condition)
meta name='turbolinks-visit-control' content='reload'
= javascript_pack_tag('d3-tip', 'data-turbolinks-track': true)
h1
= t('.live_dashboard')
div.teacher_dashboard data-exercise-id="#{@exercise.id}" data-study-group-id="#{@study_group_id}"
h4.mt-4
= t('.time_spent_per_learner')
.d-none#initial_graph_data data-graph_data=ActiveSupport::JSON.encode(@graph_data);
div.w-100#chart_stacked
.d-none.badge-info.container.py-2#no_chart_data
i class="fa fa-info" aria-hidden="true"
= t('.no_data_yet')
h4.mt-4
= t('.related_requests_for_comments')
.table-responsive
table.table.table-hover.mt-4
thead
tr
th.text-center
i.mr-0 class="fa fa-lightbulb-o" aria-hidden="true" title = t('request_for_comments.solved')
th.text-center
i.mr-0 class="fa fa-comment" aria-hidden="true" title = t('request_for_comments.comments') align="center"
th.col-12 = t('activerecord.attributes.request_for_comments.question')
th = t('activerecord.attributes.request_for_comments.username')
th.text-nowrap = t('activerecord.attributes.request_for_comments.requested_at')
tbody#posted_rfcs
= render(partial: 'request_for_comments/list_entry', collection: @request_for_comments, as: :request_for_comment)

View File

@@ -5,6 +5,7 @@ html lang='en'
meta name='viewport' content='width=device-width, initial-scale=1'
title = application_name
link href=asset_path('favicon.png') rel='icon' type='image/png'
= action_cable_meta_tag
= stylesheet_pack_tag('application', media: 'all', 'data-turbolinks-track': true)
= stylesheet_pack_tag('stylesheets', media: 'all', 'data-turbolinks-track': true)
= stylesheet_link_tag('application', media: 'all', 'data-turbolinks-track': true)
@@ -12,6 +13,10 @@ html lang='en'
= javascript_include_tag('application', 'data-turbolinks-track': true)
= yield(:head)
= csrf_meta_tags
= timeago_script_tag
script type="text/javascript"
| I18n.defaultLocale = "#{I18n.default_locale}";
| I18n.locale = "#{I18n.locale}";
body
- unless @embed_options[:hide_navbar]
nav.navbar.navbar-dark.bg-dark.navbar-expand-md.mb-4.py-1 role='navigation'

View File

@@ -0,0 +1,15 @@
tr.table-row-clickable data-id=request_for_comment.id data-href=request_for_comment_path(request_for_comment)
td.p-2
- if request_for_comment.solved?
span.fa.fa-check.fa-2x.text-success aria-hidden="true"
- elsif request_for_comment.full_score_reached
span.fa.fa-check.fa-2x style="color:darkgrey" aria-hidden="true"
- else
= ''
td.text-center = request_for_comment.comments_count
- if request_for_comment.has_attribute?(:question) && request_for_comment.question.present?
td.text-primary = truncate(request_for_comment.question, length: 200)
- else
td.text-black-50.font-italic = t('request_for_comments.no_question')
td = request_for_comment.user
td = timeago_tag request_for_comment.created_at

View File

@@ -9,6 +9,9 @@
- testruns = Testrun.where(:submission_id => @request_for_comment.submission)
= link_to_if(policy(user).show?, user.displayname, user)
| | #{@request_for_comment.created_at.localtime}
- if @request_for_comment.submission.study_group.present? && policy(@request_for_comment.submission).show_study_group?
= ' | '
= link_to_if(policy(@request_for_comment.submission.study_group).show?, @request_for_comment.submission.study_group, @request_for_comment.submission.study_group)
.rfc
.description
h5

View File

@@ -9,6 +9,7 @@ h1 = @submission
= row(label: 'submission.exercise', value: link_to_if(policy(@submission.exercise).show?, @submission.exercise, @submission.exercise))
= row(label: 'submission.user', value: link_to_if(policy(@submission.user).show?, @submission.user, @submission.user))
= row(label: 'submission.study_group', value: link_to_if(policy(@submission.study_group).show?, @submission.study_group, @submission.study_group))
= row(label: 'submission.cause', value: t("submissions.causes.#{@submission.cause}"))
= row(label: 'submission.score', value: @submission.score)

View File

@@ -1,5 +1,5 @@
== t('mailers.user_mailer.send_thank_you_note.body',
receiver_displayname: @receiver_displayname,
link_to_comment: link_to(@rfc_link, @rfc_link),
author: @author.displayname,
author: @author,
thank_you_note: @thank_you_note )