Add live dashboard for teachers in the context of an exercise
This commit also adds the fundamentals for ActionCable
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
//= require turbolinks
|
||||
//= require pagedown_bootstrap
|
||||
//= require d3
|
||||
//= require rails-timeago
|
||||
//= require locales/jquery.timeago.de.js
|
||||
//
|
||||
// lib/assets
|
||||
//= require flash
|
||||
|
@@ -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;
|
||||
};
|
||||
|
13
app/assets/javascripts/cable.js
Normal file
13
app/assets/javascripts/cable.js
Normal 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);
|
33
app/assets/javascripts/channels/la_exercises.js
Normal file
33
app/assets/javascripts/channels/la_exercises.js
Normal 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"));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
@@ -113,3 +113,7 @@ span.caret {
|
||||
-webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);
|
||||
}
|
||||
}
|
||||
|
||||
.table-row-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
30
app/channels/application_cable/connection.rb
Normal file
30
app/channels/application_cable/connection.rb
Normal 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
|
16
app/channels/la_exercises_channel.rb
Normal file
16
app/channels/la_exercises_channel.rb
Normal 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
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
15
app/helpers/action_cable_helper.rb
Normal file
15
app/helpers/action_cable_helper.rb
Normal 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
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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|
|
||||
|
@@ -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
|
||||
|
21
app/views/exercises/study_group_dashboard.html.slim
Normal file
21
app/views/exercises/study_group_dashboard.html.slim
Normal 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)
|
@@ -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'
|
||||
|
15
app/views/request_for_comments/_list_entry.html.slim
Normal file
15
app/views/request_for_comments/_list_entry.html.slim
Normal 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
|
Reference in New Issue
Block a user