diff --git a/Gemfile b/Gemfile index d6b33962..fe43b8dc 100644 --- a/Gemfile +++ b/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 diff --git a/Gemfile.lock b/Gemfile.lock index 27d9cffe..b3a0cca2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 4a3878f5..a4b7622f 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -14,6 +14,8 @@ //= require turbolinks //= require pagedown_bootstrap //= require d3 +//= require rails-timeago +//= require locales/jquery.timeago.de.js // // lib/assets //= require flash diff --git a/app/assets/javascripts/base.js b/app/assets/javascripts/base.js index 08e9afd1..d3dcdcee 100644 --- a/app/assets/javascripts/base.js +++ b/app/assets/javascripts/base.js @@ -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; +}; diff --git a/app/assets/javascripts/cable.js b/app/assets/javascripts/cable.js new file mode 100644 index 00000000..739aa5f0 --- /dev/null +++ b/app/assets/javascripts/cable.js @@ -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); diff --git a/app/assets/javascripts/channels/la_exercises.js b/app/assets/javascripts/channels/la_exercises.js new file mode 100644 index 00000000..fe84d8f3 --- /dev/null +++ b/app/assets/javascripts/channels/la_exercises.js @@ -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")); + }); + } + }); + } +}); diff --git a/app/assets/stylesheets/base.css.scss b/app/assets/stylesheets/base.css.scss index 0d8a9413..68c78010 100644 --- a/app/assets/stylesheets/base.css.scss +++ b/app/assets/stylesheets/base.css.scss @@ -113,3 +113,7 @@ span.caret { -webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); } } + +.table-row-clickable { + cursor: pointer; +} diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 00000000..d6726972 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 00000000..d277ba3e --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -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 diff --git a/app/channels/la_exercises_channel.rb b/app/channels/la_exercises_channel.rb new file mode 100644 index 00000000..363da89b --- /dev/null +++ b/app/channels/la_exercises_channel.rb @@ -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 diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 079500cc..c5efa532 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -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 diff --git a/app/controllers/request_for_comments_controller.rb b/app/controllers/request_for_comments_controller.rb index 64301965..6f396d41 100644 --- a/app/controllers/request_for_comments_controller.rb +++ b/app/controllers/request_for_comments_controller.rb @@ -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 diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index bc3ec4a6..57f3087d 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -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 diff --git a/app/helpers/action_cable_helper.rb b/app/helpers/action_cable_helper.rb new file mode 100644 index 00000000..c27c2b2f --- /dev/null +++ b/app/helpers/action_cable_helper.rb @@ -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 diff --git a/app/models/comment.rb b/app/models/comment.rb index b3be54f9..10ec7edd 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -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 diff --git a/app/models/request_for_comment.rb b/app/models/request_for_comment.rb index b4fa819b..86bb1976 100644 --- a/app/models/request_for_comment.rb +++ b/app/models/request_for_comment.rb @@ -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) diff --git a/app/policies/exercise_policy.rb b/app/policies/exercise_policy.rb index 63249b1f..662349fe 100644 --- a/app/policies/exercise_policy.rb +++ b/app/policies/exercise_policy.rb @@ -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| diff --git a/app/policies/study_group_policy.rb b/app/policies/study_group_policy.rb index afdf8bc5..3dd29aeb 100644 --- a/app/policies/study_group_policy.rb +++ b/app/policies/study_group_policy.rb @@ -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 diff --git a/app/views/exercises/study_group_dashboard.html.slim b/app/views/exercises/study_group_dashboard.html.slim new file mode 100644 index 00000000..f5624ab9 --- /dev/null +++ b/app/views/exercises/study_group_dashboard.html.slim @@ -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) diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 6714534d..c5279faa 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -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' diff --git a/app/views/request_for_comments/_list_entry.html.slim b/app/views/request_for_comments/_list_entry.html.slim new file mode 100644 index 00000000..bb96c5c5 --- /dev/null +++ b/app/views/request_for_comments/_list_entry.html.slim @@ -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 diff --git a/config/application.rb b/config/application.rb index e778b4e6..3499ef3c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/config/locales/de.yml b/config/locales/de.yml index c94224fc..630e8e80 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -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. diff --git a/config/locales/en.yml b/config/locales/en.yml index b9df135c..c48530e6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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. diff --git a/config/routes.rb b/config/routes.rb index 2fcf7d6b..42cee5a3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20190213131802_add_indices_for_request_for_comments.rb b/db/migrate/20190213131802_add_indices_for_request_for_comments.rb new file mode 100644 index 00000000..f0c0dbd4 --- /dev/null +++ b/db/migrate/20190213131802_add_indices_for_request_for_comments.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 406e7d2e..feb96e00 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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