Add live dashboard for teachers in the context of an exercise
This commit also adds the fundamentals for ActionCable
This commit is contained in:
1
Gemfile
1
Gemfile
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
Reference in New Issue
Block a user