Add live dashboard for teachers in the context of an exercise

This commit also adds the fundamentals for ActionCable
This commit is contained in:
Sebastian Serth
2019-02-15 09:32:53 +01:00
parent 6e03939c10
commit d63700c7db
27 changed files with 215 additions and 7 deletions

View File

@@ -14,6 +14,8 @@
//= require turbolinks
//= require pagedown_bootstrap
//= require d3
//= require rails-timeago
//= require locales/jquery.timeago.de.js
//
// lib/assets
//= require flash

View File

@@ -23,3 +23,9 @@ $.fn.scrollTo = function(selector) {
scrollTop: $(selector).offset().top - $(this).offset().top + $(this).scrollTop()
}, ANIMATION_DURATION);
};
$.fn.replaceWithPush = function(a) {
const $a = $(a);
this.replaceWith($a);
return $a;
};

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,33 @@
$(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
let $row = $('tr[data-id="' + data.id + '"]');
if ($row.length === 0) {
$row = $($('#posted_rfcs')[0].insertRow(0));
}
$row = $row.replaceWithPush(data.html);
$row.find('time').timeago();
$row.click(function () {
Turbolinks.visit($(this).data("href"));
});
}
});
}
});

View File

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

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

@@ -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]
@@ -475,4 +475,13 @@ 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)
end
end

View File

@@ -143,7 +143,7 @@ class RequestForCommentsController < ApplicationController
# Never trust parameters from the scary internet, only allow the white list through.
def request_for_comment_params
# 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, study_group_id: session[:study_group_id])
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
def comment_params

View File

@@ -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,15 @@
module ActionCableHelper
def trigger_rfc_action_cable
if submission.study_group_id.present?
ActionCable.server.broadcast(
"la_exercises_#{exercise_id}_channel_study_group_#{submission.study_group_id}",
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
RequestForComment.find_by(submission: file.context).trigger_rfc_action_cable
end
end

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

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

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

@@ -0,0 +1,21 @@
h1
= t('.live_dashboard')
div.teacher_dashboard data-exercise-id="#{@exercise.id}" data-study-group-id="#{@study_group_id}"
h4
= 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,7 @@ html lang='en'
= javascript_include_tag('application', 'data-turbolinks-track': true)
= yield(:head)
= csrf_meta_tags
= timeago_script_tag
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