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

@ -37,6 +37,7 @@ gem 'rest-client'
gem 'rubyzip'
gem 'mnemosyne-ruby'
gem 'whenever', require: false
gem 'rails-timeago'
group :development, :staging do
gem 'bootsnap', require: false

View File

@ -255,6 +255,9 @@ GEM
rails-i18n (5.1.3)
i18n (>= 0.7, < 2)
railties (>= 5.0, < 6)
rails-timeago (2.17.1)
actionpack (>= 3.1)
activesupport (>= 3.1)
railties (5.2.2)
actionpack (= 5.2.2)
activesupport (= 5.2.2)
@ -437,6 +440,7 @@ DEPENDENCIES
rails (= 5.2.2)
rails-controller-testing
rails-i18n
rails-timeago
ransack
rest-client
rspec-autotest

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

View File

@ -28,5 +28,7 @@ module CodeOcean
config.autoload_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('lib')
config.assets.precompile += %w( markdown-buttons.png )
config.action_cable.mount_path = '/cable'
end
end

View File

@ -101,6 +101,7 @@ de:
files: Dateien
score: Punktzahl
user: Autor
study_group: Lerngruppe
study_group:
name: Name
external_id: Externe ID
@ -347,6 +348,7 @@ de:
implement: Implementieren
test_files: Test-Dateien
feedback: Feedback
study_group_dashboard: Live Dashboard
statistics:
average_score: Durchschnittliche Punktzahl
final_submissions: Finale Abgaben
@ -365,6 +367,9 @@ de:
failure: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.
full_score_redirect_to_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ein anderer Teilnehmer hat eine Frage zu der von Ihnen gelösten Aufgabe. Er würde sich sicherlich sehr über ihre Hilfe und Kommentare freuen.
full_score_redirect_to_own_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ihre Frage ist damit wahrscheinlich gelöst? Falls ja, fügen Sie doch den entscheidenden Kniff als Antwort hinzu und markieren die Frage als gelöst, bevor sie das Fenster schließen.
study_group_dashboard:
live_dashboard: Live Dashboard
related_requests_for_comments: Zugehörige Kommentaranfragen
external_users:
statistics:
no_data_available: Keine Daten verfügbar.

View File

@ -101,6 +101,7 @@ en:
files: Files
score: Score
user: Author
study_group: Study Group
study_group:
name: Name
external_id: External ID
@ -347,6 +348,7 @@ en:
implement: Implement
test_files: Test Files
feedback: Feedback
study_group_dashboard: Live Dashboard
statistics:
average_score: Average Score
final_submissions: Final Submissions
@ -365,6 +367,9 @@ en:
failure: An error occurred while transmitting your score. Please try again later.
full_score_redirect_to_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Another participant has a question concerning the exercise you just solved. Your help and comments will be greatly appreciated!
full_score_redirect_to_own_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Your question concerning the exercise is solved? If so, please share the essential insight with your fellows and mark the question as solved, before you close this window!
study_group_dashboard:
live_dashboard: Live Dashboard
related_requests_for_comments: Related Requests for Comments
external_users:
statistics:
no_data_available: No data available.

View File

@ -83,6 +83,7 @@ Rails.application.routes.draw do
get :feedback
get :reload
post :submit
get 'study_group_dashboard/:study_group_id', to: 'exercises#study_group_dashboard'
end
end
@ -151,4 +152,5 @@ Rails.application.routes.draw do
post "/evaluate", to: 'remote_evaluation#evaluate', via: [:post]
mount ActionCable.server => '/cable'
end

View File

@ -0,0 +1,6 @@
class AddIndicesForRequestForComments < ActiveRecord::Migration[5.2]
def change
add_index :request_for_comments, :submission_id
add_index :request_for_comments, :exercise_id
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_11_29_093207) do
ActiveRecord::Schema.define(version: 2019_02_13_131802) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -292,6 +292,8 @@ ActiveRecord::Schema.define(version: 2018_11_29_093207) do
t.text "thank_you_note"
t.boolean "full_score_reached", default: false
t.integer "times_featured", default: 0
t.index ["exercise_id"], name: "index_request_for_comments_on_exercise_id"
t.index ["submission_id"], name: "index_request_for_comments_on_submission_id"
end
create_table "searches", force: :cascade do |t|
@ -415,4 +417,5 @@ ActiveRecord::Schema.define(version: 2018_11_29_093207) do
t.index ["user_type", "user_id"], name: "index_user_proxy_exercise_exercises_on_user_type_and_user_id"
end
add_foreign_key "submissions", "study_groups"
end