Merge pull request #261 from openHPI/feature/la-dashboard
Add LA dashboard architecture
This commit is contained in:
@@ -13,7 +13,10 @@
|
||||
//= require jquery_ujs
|
||||
//= require turbolinks
|
||||
//= require pagedown_bootstrap
|
||||
//= require d3
|
||||
//= require rails-timeago
|
||||
//= require locales/jquery.timeago.de.js
|
||||
//= require i18n
|
||||
//= require i18n/translations
|
||||
//
|
||||
// lib/assets
|
||||
//= require flash
|
||||
|
@@ -23,3 +23,14 @@ $.fn.scrollTo = function(selector) {
|
||||
scrollTop: $(selector).offset().top - $(this).offset().top + $(this).scrollTop()
|
||||
}, ANIMATION_DURATION);
|
||||
};
|
||||
|
||||
// Same as $.replaceWith, just returns the new element instead of the deleted one
|
||||
$.fn.replaceWithAndReturnNewElement = function(a) {
|
||||
const $a = $(a);
|
||||
this.replaceWith($a);
|
||||
return $a;
|
||||
};
|
||||
|
||||
// Disable the use of web workers for JStree due to JS error
|
||||
// See https://github.com/vakata/jstree/issues/1717 for details
|
||||
$.jstree.defaults.core.worker = false;
|
||||
|
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);
|
250
app/assets/javascripts/channels/la_exercises.js
Normal file
250
app/assets/javascripts/channels/la_exercises.js
Normal file
@@ -0,0 +1,250 @@
|
||||
$(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
|
||||
if (data.type === 'rfc') {
|
||||
handleNewRfCdata(data);
|
||||
} else if (data.type === 'working_times') {
|
||||
handleWorkingTimeUpdate(data.working_time_data)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleNewRfCdata(data) {
|
||||
let $row = $('tr[data-id="' + data.id + '"]');
|
||||
if ($row.length === 0) {
|
||||
$row = $($('#posted_rfcs')[0].insertRow(0));
|
||||
}
|
||||
$row = $row.replaceWithAndReturnNewElement(data.html);
|
||||
$row.find('time').timeago();
|
||||
$row.click(function () {
|
||||
Turbolinks.visit($(this).data("href"));
|
||||
});
|
||||
}
|
||||
|
||||
function handleWorkingTimeUpdate(data) {
|
||||
const user_progress = data['user_progress'];
|
||||
const additional_user_data = data['additional_user_data'];
|
||||
|
||||
const user = additional_user_data[additional_user_data.length - 1][0];
|
||||
const position = userPosition[user.type + user.id]; // TODO validate: will result in undef. if not existent.
|
||||
// TODO: Do update
|
||||
}
|
||||
|
||||
const graph_data = $('#initial_graph_data').data('graph_data');
|
||||
let userPosition = {};
|
||||
|
||||
drawGraph(graph_data);
|
||||
|
||||
function drawGraph(graph_data) {
|
||||
const user_progress = graph_data['user_progress'];
|
||||
const additional_user_data = graph_data['additional_user_data'];
|
||||
|
||||
function get_minutes (time_stamp) {
|
||||
try {
|
||||
hours = time_stamp.split(":")[0];
|
||||
minutes = time_stamp.split(":")[1];
|
||||
seconds = time_stamp.split(":")[2];
|
||||
seconds /= 60;
|
||||
minutes = parseFloat(hours * 60) + parseInt(minutes) + seconds;
|
||||
if (minutes > 0){
|
||||
return minutes;
|
||||
} else{
|
||||
return parseFloat(seconds/60);
|
||||
}
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function learners_name(index) {
|
||||
return additional_user_data[additional_user_data.length - 1][index]["name"] + ", ID: " + additional_user_data[additional_user_data.length - 1][index]["id"];
|
||||
}
|
||||
|
||||
function learners_time(group, index) {
|
||||
if (user_progress[group] !== null && user_progress[group] !== undefined && user_progress[group][index] !== null) {
|
||||
return user_progress[group][index]
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (user_progress.length === 0) {
|
||||
// No data available
|
||||
$('#no_chart_data').removeClass("d-none");
|
||||
return;
|
||||
}
|
||||
|
||||
const margin = ({top: 20, right: 20, bottom: 150, left: 80});
|
||||
const width = $('#chart_stacked').width();
|
||||
const height = 500;
|
||||
const users = user_progress[0].length; // # of users
|
||||
const n = user_progress.length; // # of different sub bars, called buckets
|
||||
|
||||
let working_times_in_minutes = d3.range(n).map((index) => {
|
||||
if (user_progress[index] !== null) {
|
||||
return user_progress[index].map((time) => get_minutes(time))
|
||||
} else return new Array(users).fill(0);
|
||||
});
|
||||
|
||||
let xAxis = svg => svg.append("g")
|
||||
.attr("transform", `translate(0,${height - margin.bottom})`)
|
||||
.call(d3.axisBottom(x).tickSizeOuter(0).tickFormat((index) => learners_name(index)));
|
||||
|
||||
let yAxis = svg => svg.append("g")
|
||||
.attr("transform", `translate(${margin.left}, 0)`)
|
||||
.call(d3.axisLeft(y).tickSizeOuter(0).tickFormat((index) => index));
|
||||
|
||||
let color = d3.scaleSequential(d3.interpolateRdYlGn)
|
||||
.domain([-0.5 * n, 1.5 * n]);
|
||||
|
||||
let userAxis = d3.range(users); // the x-values shared by all series
|
||||
|
||||
// Calculate the corresponding start and end value of each value;
|
||||
const yBarValuesGrouped = d3.stack()
|
||||
.keys(d3.range(n))
|
||||
(d3.transpose(working_times_in_minutes)) // stacked working_times_in_minutes
|
||||
.map((data, i) => data.map(([y0, y1]) => [y0, y1, i]));
|
||||
|
||||
const maxYSingleBar = d3.max(working_times_in_minutes, y => d3.max(y));
|
||||
|
||||
const maxYBarStacked = d3.max(yBarValuesGrouped, y => d3.max(y, d => d[1]));
|
||||
|
||||
let x = d3.scaleBand()
|
||||
.domain(userAxis)
|
||||
.rangeRound([margin.left, width - margin.right])
|
||||
.padding(0.08);
|
||||
|
||||
let y = d3.scaleLinear()
|
||||
.domain([0, maxYBarStacked])
|
||||
.range([height - margin.bottom, margin.top]);
|
||||
|
||||
const svg = d3.select("#chart_stacked")
|
||||
.append("svg")
|
||||
.attr("width", '100%')
|
||||
.attr("height", '100%')
|
||||
.attr("viewBox", `0 0 ${width} ${height}`)
|
||||
.attr("preserveAspectRatio","xMinYMin meet");
|
||||
|
||||
const rect = svg.selectAll("g")
|
||||
.data(yBarValuesGrouped)
|
||||
.enter().append("g")
|
||||
.attr("fill", (d, i) => color(i))
|
||||
.selectAll("rect")
|
||||
.data(d => d)
|
||||
.join("rect")
|
||||
.attr("x", (d, i) => x(i))
|
||||
.attr("y", height - margin.bottom)
|
||||
.attr("width", x.bandwidth())
|
||||
.attr("height", 0)
|
||||
.attr("class", (d) => "bar-stacked-"+d[2]);
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "x axis")
|
||||
.call(xAxis)
|
||||
.selectAll("text")
|
||||
.style("text-anchor", "end")
|
||||
.attr("dx", "-.8em")
|
||||
.attr("dy", ".15em")
|
||||
.attr("transform", function(d) {
|
||||
return "rotate(-45)"
|
||||
});
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis);
|
||||
|
||||
// Y Axis Label
|
||||
svg.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", (-height - margin.top + margin.bottom) / 2)
|
||||
.attr("dy", "+2em")
|
||||
.style("text-anchor", "middle")
|
||||
.text(I18n.t('exercises.study_group_dashboard.time_spent_in_minutes'))
|
||||
.style('font-size', 14);
|
||||
|
||||
// X Axis Label
|
||||
svg.append("text")
|
||||
.attr("class", "x axis")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("x", (width + margin.left - margin.right) / 2)
|
||||
.attr("y", height)
|
||||
.attr("dy", '-1em')
|
||||
.text(I18n.t('exercises.study_group_dashboard.learner'))
|
||||
.style('font-size', 14);
|
||||
|
||||
let tip = d3.tip()
|
||||
.attr('class', 'd3-tip')
|
||||
.offset([-10, 0])
|
||||
.html(function(d, i, a) {
|
||||
return "<strong>Student: </strong><span style='color:orange'>" + learners_name(i) + "</span><br/>" +
|
||||
"0: " + learners_time(0, i) + "<br/>" +
|
||||
"1: " + learners_time(1, i) + "<br/>" +
|
||||
"2: " + learners_time(2, i) + "<br/>" +
|
||||
"3: " + learners_time(3, i) + "<br/>" +
|
||||
"4: " + learners_time(4, i);
|
||||
});
|
||||
|
||||
svg.call(tip);
|
||||
|
||||
rect.on('mouseenter', tip.show)
|
||||
.on('mouseout', tip.hide);
|
||||
|
||||
function transitionGrouped() {
|
||||
// Show all sub-bars next to each other
|
||||
y.domain([0, maxYSingleBar]);
|
||||
|
||||
rect.transition()
|
||||
.duration(500)
|
||||
.delay((d, i) => i * 20)
|
||||
.attr("x", (d, i) => x(i) + x.bandwidth() / n * d[2])
|
||||
.attr("width", x.bandwidth() / n)
|
||||
.transition()
|
||||
.attr("y", d => y(d[1] - d[0]))
|
||||
.attr("height", d => y(0) - y(d[1] - d[0]));
|
||||
}
|
||||
|
||||
function transitionStacked() {
|
||||
// Show all sub-bars on top of each other
|
||||
y.domain([0, maxYBarStacked]);
|
||||
|
||||
rect.transition()
|
||||
.duration(500)
|
||||
.delay((d, i) => i * 20)
|
||||
.attr("y", d => y(d[1]))
|
||||
.attr("height", d => y(d[0]) - y(d[1]))
|
||||
.transition()
|
||||
.attr("x", (d, i) => x(i))
|
||||
.attr("width", x.bandwidth());
|
||||
}
|
||||
|
||||
$('#no_chart_data').addClass("d-none");
|
||||
transitionStacked();
|
||||
// ToDo: Add button to switch using transitionGrouped();
|
||||
|
||||
buildDictionary(additional_user_data);
|
||||
}
|
||||
|
||||
function buildDictionary(users) {
|
||||
users[users.length - 1].forEach(function(user, index) {
|
||||
userPosition[user.type + user.id] = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
@@ -113,3 +113,7 @@ span.caret {
|
||||
-webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);
|
||||
}
|
||||
}
|
||||
|
||||
.table-row-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@@ -84,6 +84,10 @@ div#chart_2 {
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
div#chart_stacked {
|
||||
max-height: 500px;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
a.file-heading {
|
||||
color: black !important;
|
||||
@@ -115,7 +119,7 @@ a.file-heading {
|
||||
.d3-tip:after {
|
||||
box-sizing: border-box;
|
||||
display: inline;
|
||||
font-size: 10px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
line-height: 1;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
@@ -126,7 +130,7 @@ a.file-heading {
|
||||
|
||||
/* Style northward tooltips differently */
|
||||
.d3-tip.n:after {
|
||||
margin: -1px 0 0 0;
|
||||
margin: -3px 0 0 0;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
}
|
||||
|
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
|
@@ -168,6 +168,7 @@ module Lti
|
||||
end
|
||||
group.users |= [@current_user] # add current user if not already member of the group
|
||||
group.save
|
||||
session[:study_group_id] = group.id
|
||||
end
|
||||
|
||||
def set_embedding_options
|
||||
|
@@ -16,7 +16,8 @@ module SubmissionParameters
|
||||
current_user_id = current_user.id
|
||||
current_user_class_name = current_user.class.name
|
||||
end
|
||||
submission_params = params[:submission].present? ? params[:submission].permit(:cause, :exercise_id, files_attributes: file_attributes).merge(user_id: current_user_id, user_type: current_user_class_name) : {}
|
||||
# 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.
|
||||
submission_params = params[:submission].present? ? params[:submission].permit(:cause, :exercise_id, files_attributes: file_attributes).merge(user_id: current_user_id, user_type: current_user_class_name, study_group_id: session[:study_group_id]) : {}
|
||||
reject_illegal_file_attributes!(submission_params)
|
||||
submission_params
|
||||
end
|
||||
|
@@ -40,7 +40,7 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
|
||||
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
|
@@ -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]
|
||||
@@ -266,7 +266,7 @@ class ExercisesController < ApplicationController
|
||||
end
|
||||
|
||||
def index
|
||||
@search = policy_scope(Exercise).search(params[:q])
|
||||
@search = policy_scope(Exercise).ransack(params[:q])
|
||||
@exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page])
|
||||
authorize!
|
||||
end
|
||||
@@ -319,7 +319,7 @@ class ExercisesController < ApplicationController
|
||||
private :set_file_types
|
||||
|
||||
def collect_set_and_unset_exercise_tags
|
||||
@search = policy_scope(Tag).search(params[:q])
|
||||
@search = policy_scope(Tag).ransack(params[:q])
|
||||
@tags = @search.result.order(:name)
|
||||
checked_exercise_tags = @exercise.exercise_tags
|
||||
checked_tags = checked_exercise_tags.collect{|e| e.tag}.to_set
|
||||
@@ -343,7 +343,7 @@ class ExercisesController < ApplicationController
|
||||
@all_events = (@submissions + interventions).sort_by { |a| a.created_at }
|
||||
@deltas = @all_events.map.with_index do |item, index|
|
||||
delta = item.created_at - @all_events[index - 1].created_at if index > 0
|
||||
if delta == nil or delta > 10 * 60 then 0 else delta end
|
||||
if delta == nil or delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS then 0 else delta end
|
||||
end
|
||||
@working_times_until = []
|
||||
@all_events.each_with_index do |_, index|
|
||||
@@ -475,4 +475,15 @@ 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)
|
||||
|
||||
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
|
||||
end
|
||||
|
||||
end
|
||||
|
@@ -27,7 +27,7 @@ class ExternalUsersController < ApplicationController
|
||||
score,
|
||||
id,
|
||||
CASE
|
||||
WHEN working_time >= '0:05:00' THEN '0'
|
||||
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
|
||||
ELSE working_time
|
||||
END AS working_time_new
|
||||
FROM
|
||||
|
@@ -60,7 +60,7 @@ class InternalUsersController < ApplicationController
|
||||
end
|
||||
|
||||
def index
|
||||
@search = InternalUser.search(params[:q])
|
||||
@search = InternalUser.ransack(params[:q])
|
||||
@users = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
|
||||
authorize!
|
||||
end
|
||||
|
@@ -33,7 +33,7 @@ class ProxyExercisesController < ApplicationController
|
||||
end
|
||||
|
||||
def edit
|
||||
@search = policy_scope(Exercise).search(params[:q])
|
||||
@search = policy_scope(Exercise).ransack(params[:q])
|
||||
@exercises = @search.result.order(:title)
|
||||
authorize!
|
||||
end
|
||||
@@ -44,14 +44,14 @@ class ProxyExercisesController < ApplicationController
|
||||
private :proxy_exercise_params
|
||||
|
||||
def index
|
||||
@search = policy_scope(ProxyExercise).search(params[:q])
|
||||
@search = policy_scope(ProxyExercise).ransack(params[:q])
|
||||
@proxy_exercises = @search.result.order(:title).paginate(page: params[:page])
|
||||
authorize!
|
||||
end
|
||||
|
||||
def new
|
||||
@proxy_exercise = ProxyExercise.new
|
||||
@search = policy_scope(Exercise).search(params[:q])
|
||||
@search = policy_scope(Exercise).ransack(params[:q])
|
||||
@exercises = @search.result.order(:title)
|
||||
authorize!
|
||||
end
|
||||
@@ -63,8 +63,8 @@ class ProxyExercisesController < ApplicationController
|
||||
private :set_exercise_and_authorize
|
||||
|
||||
def show
|
||||
@search = @proxy_exercise.exercises.search
|
||||
@exercises = @proxy_exercise.exercises.search.result.order(:title) #@search.result.order(:title)
|
||||
@search = @proxy_exercise.exercises.ransack
|
||||
@exercises = @proxy_exercise.exercises.ransack.result.order(:title) #@search.result.order(:title)
|
||||
end
|
||||
|
||||
#we might want to think about auth here
|
||||
|
@@ -15,7 +15,7 @@ class RequestForCommentsController < ApplicationController
|
||||
@search = RequestForComment
|
||||
.last_per_user(2)
|
||||
.with_last_activity
|
||||
.search(params[:q])
|
||||
.ransack(params[:q])
|
||||
@request_for_comments = @search.result
|
||||
.order('created_at DESC')
|
||||
.paginate(page: params[:page], total_entries: @search.result.length)
|
||||
@@ -27,7 +27,7 @@ class RequestForCommentsController < ApplicationController
|
||||
@search = RequestForComment
|
||||
.with_last_activity
|
||||
.where(user_id: current_user.id)
|
||||
.search(params[:q])
|
||||
.ransack(params[:q])
|
||||
@request_for_comments = @search.result
|
||||
.order('created_at DESC')
|
||||
.paginate(page: params[:page])
|
||||
@@ -40,7 +40,7 @@ class RequestForCommentsController < ApplicationController
|
||||
.with_last_activity
|
||||
.joins(:comments) # we don't need to outer join here, because we know the user has commented on these
|
||||
.where(comments: {user_id: current_user.id})
|
||||
.search(params[:q])
|
||||
.ransack(params[:q])
|
||||
@request_for_comments = @search.result
|
||||
.order('last_comment DESC')
|
||||
.paginate(page: params[:page])
|
||||
@@ -83,17 +83,10 @@ class RequestForCommentsController < ApplicationController
|
||||
authorize!
|
||||
end
|
||||
|
||||
# GET /request_for_comments/new
|
||||
def new
|
||||
@request_for_comment = RequestForComment.new
|
||||
authorize!
|
||||
end
|
||||
|
||||
# GET /request_for_comments/1/edit
|
||||
def edit
|
||||
end
|
||||
|
||||
# POST /request_for_comments
|
||||
# POST /request_for_comments.json
|
||||
def create
|
||||
# Consider all requests as JSON
|
||||
@@ -149,7 +142,7 @@ class RequestForCommentsController < ApplicationController
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def request_for_comment_params
|
||||
# we are using the current_user.id here, since internal users are not able to create comments. The external_user.id is a primary key and does not require the consumer_id to be unique.
|
||||
# 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)
|
||||
end
|
||||
|
||||
|
@@ -4,17 +4,17 @@ class StudyGroupsController < ApplicationController
|
||||
before_action :set_group, only: MEMBER_ACTIONS
|
||||
|
||||
def index
|
||||
@search = StudyGroup.search(params[:q])
|
||||
@search = StudyGroup.ransack(params[:q])
|
||||
@study_groups = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
|
||||
authorize!
|
||||
end
|
||||
|
||||
def show
|
||||
@search = @study_group.users.search(params[:q])
|
||||
@search = @study_group.users.ransack(params[:q])
|
||||
end
|
||||
|
||||
def edit
|
||||
@search = @study_group.users.search(params[:q])
|
||||
@search = @study_group.users.ransack(params[:q])
|
||||
@members = StudyGroupMembership.where(user: @search.result, study_group: @study_group)
|
||||
end
|
||||
|
||||
|
@@ -106,7 +106,7 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
def index
|
||||
@search = Submission.search(params[:q])
|
||||
@search = Submission.ransack(params[:q])
|
||||
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page])
|
||||
authorize!
|
||||
end
|
||||
@@ -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
|
||||
|
||||
|
30
app/helpers/action_cable_helper.rb
Normal file
30
app/helpers/action_cable_helper.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
module ActionCableHelper
|
||||
def trigger_rfc_action_cable
|
||||
# Context: RfC
|
||||
if submission.study_group_id.present?
|
||||
ActionCable.server.broadcast(
|
||||
"la_exercises_#{exercise_id}_channel_study_group_#{submission.study_group_id}",
|
||||
type: :rfc,
|
||||
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
|
||||
# Context: Comment
|
||||
RequestForComment.find_by(submission: file.context).trigger_rfc_action_cable
|
||||
end
|
||||
|
||||
def trigger_working_times_action_cable
|
||||
# Context: Submission
|
||||
if study_group_id.present?
|
||||
ActionCable.server.broadcast(
|
||||
"la_exercises_#{exercise_id}_channel_study_group_#{study_group_id}",
|
||||
type: :working_times,
|
||||
working_time_data: exercise.get_working_times_for_study_group(study_group_id, user))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: Check if any user is connected and prevent preparing the data otherwise
|
@@ -1,5 +1,8 @@
|
||||
module StatisticsHelper
|
||||
|
||||
WORKING_TIME_DELTA_IN_SECONDS = 5.minutes
|
||||
WORKING_TIME_DELTA_IN_SQL_INTERVAL = "'0:05:00'" # yes, a string with quotes
|
||||
|
||||
def statistics_data
|
||||
[
|
||||
{
|
||||
|
@@ -13,7 +13,9 @@ import 'bootstrap/dist/js/bootstrap.bundle.min';
|
||||
import 'chosen-js/chosen.jquery';
|
||||
import 'jstree';
|
||||
import 'underscore';
|
||||
import 'd3'
|
||||
window._ = _; // Publish underscore's `_` in global namespace
|
||||
window.d3 = d3; // Publish d3 in global namespace
|
||||
|
||||
// CSS
|
||||
import 'chosen-js/chosen.css';
|
||||
|
@@ -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
|
||||
|
@@ -76,7 +76,7 @@ class Exercise < ApplicationRecord
|
||||
(SELECT user_id,
|
||||
user_type,
|
||||
score,
|
||||
CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
|
||||
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
user_type,
|
||||
@@ -90,6 +90,145 @@ class Exercise < ApplicationRecord
|
||||
"
|
||||
end
|
||||
|
||||
def study_group_working_time_query(exercise_id, study_group_id, additional_filter)
|
||||
"""
|
||||
WITH working_time_between_submissions AS (
|
||||
SELECT submissions.user_id,
|
||||
submissions.user_type,
|
||||
score,
|
||||
created_at,
|
||||
(created_at - lag(created_at) over (PARTITION BY submissions.user_type, submissions.user_id, exercise_id
|
||||
ORDER BY created_at)) AS working_time
|
||||
FROM submissions
|
||||
WHERE exercise_id = #{exercise_id} AND study_group_id = #{study_group_id} #{additional_filter}),
|
||||
working_time_with_deltas_ignored AS (
|
||||
SELECT user_id,
|
||||
user_type,
|
||||
score,
|
||||
sum(CASE WHEN score IS NOT NULL THEN 1 ELSE 0 END)
|
||||
over (ORDER BY user_type, user_id, created_at ASC) AS change_in_score,
|
||||
created_at,
|
||||
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_filtered
|
||||
FROM working_time_between_submissions
|
||||
),
|
||||
working_times_with_score_expanded AS (
|
||||
SELECT user_id,
|
||||
user_type,
|
||||
created_at,
|
||||
working_time_filtered,
|
||||
first_value(score)
|
||||
over (PARTITION BY user_type, user_id, change_in_score ORDER BY created_at ASC) AS corrected_score
|
||||
FROM working_time_with_deltas_ignored
|
||||
),
|
||||
working_times_with_duplicated_last_row_per_score AS (
|
||||
SELECT *
|
||||
FROM working_times_with_score_expanded
|
||||
UNION ALL
|
||||
-- Duplicate last row per user and score and make it unique by setting another created_at timestamp.
|
||||
-- In addition, the working time is set to zero in order to prevent getting a wrong time.
|
||||
-- This duplication is needed, as we will shift the scores and working times by one and need to ensure not to loose any information.
|
||||
SELECT DISTINCT ON (user_type, user_id, corrected_score) user_id,
|
||||
user_type,
|
||||
created_at + INTERVAL '1us',
|
||||
'00:00:00' as working_time_filtered,
|
||||
corrected_score
|
||||
FROM working_times_with_score_expanded
|
||||
),
|
||||
working_times_with_score_not_null_and_shifted AS (
|
||||
SELECT user_id,
|
||||
user_type,
|
||||
coalesce(lag(corrected_score) over (PARTITION BY user_type, user_id ORDER BY created_at ASC),
|
||||
0) AS shifted_score,
|
||||
created_at,
|
||||
working_time_filtered
|
||||
FROM working_times_with_duplicated_last_row_per_score
|
||||
),
|
||||
working_times_to_be_sorted AS (
|
||||
SELECT user_id,
|
||||
user_type,
|
||||
shifted_score AS score,
|
||||
MIN(created_at) AS start_time,
|
||||
SUM(working_time_filtered) AS working_time_per_score,
|
||||
SUM(SUM(working_time_filtered)) over (PARTITION BY user_type, user_id) AS total_working_time
|
||||
FROM working_times_with_score_not_null_and_shifted
|
||||
GROUP BY user_id, user_type, score
|
||||
),
|
||||
working_times_with_index AS (
|
||||
SELECT (dense_rank() over (ORDER BY total_working_time, user_type, user_id ASC) - 1) AS index,
|
||||
user_id,
|
||||
user_type,
|
||||
score,
|
||||
start_time,
|
||||
working_time_per_score,
|
||||
total_working_time
|
||||
FROM working_times_to_be_sorted)
|
||||
SELECT index,
|
||||
user_id,
|
||||
user_type,
|
||||
name,
|
||||
score,
|
||||
start_time,
|
||||
working_time_per_score,
|
||||
total_working_time
|
||||
FROM working_times_with_index
|
||||
JOIN external_users ON user_type = 'ExternalUser' AND user_id = external_users.id
|
||||
UNION ALL
|
||||
SELECT index,
|
||||
user_id,
|
||||
user_type,
|
||||
name,
|
||||
score,
|
||||
start_time,
|
||||
working_time_per_score,
|
||||
total_working_time
|
||||
FROM working_times_with_index
|
||||
JOIN internal_users ON user_type = 'InternalUser' AND user_id = internal_users.id
|
||||
ORDER BY index, score ASC;
|
||||
"""
|
||||
end
|
||||
|
||||
def get_working_times_for_study_group(study_group_id, user = nil)
|
||||
user_progress = []
|
||||
additional_user_data = []
|
||||
max_bucket = 4
|
||||
maximum_score = self.maximum_score
|
||||
|
||||
if user.blank?
|
||||
additional_filter = ''
|
||||
else
|
||||
additional_filter = "AND user_id = #{user.id} AND user_type = '#{user.class.name}'"
|
||||
end
|
||||
|
||||
results = self.class.connection.execute(study_group_working_time_query(id, study_group_id, additional_filter)).each do |tuple|
|
||||
if tuple['score'] <= maximum_score
|
||||
bucket = tuple['score'] / maximum_score * max_bucket
|
||||
else
|
||||
bucket = max_bucket # maximum_score / maximum_score will always be 1
|
||||
end
|
||||
|
||||
user_progress[bucket] ||= []
|
||||
additional_user_data[bucket] ||= []
|
||||
additional_user_data[max_bucket + 1] ||= []
|
||||
|
||||
user_progress[bucket][tuple['index']] = tuple["working_time_per_score"]
|
||||
additional_user_data[bucket][tuple['index']] = {start_time: tuple["start_time"], score: tuple["score"]}
|
||||
additional_user_data[max_bucket + 1][tuple['index']] = {id: tuple['user_id'], type: tuple['user_type'], name: tuple['name']}
|
||||
end
|
||||
|
||||
if results.ntuples > 0
|
||||
first_index = results[0]['index']
|
||||
last_index = results[results.ntuples-1]['index']
|
||||
buckets = last_index - first_index
|
||||
user_progress.each do |timings_array|
|
||||
if timings_array.present? && timings_array.length != buckets + 1
|
||||
timings_array[buckets] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{user_progress: user_progress, additional_user_data: additional_user_data}
|
||||
end
|
||||
|
||||
def get_quantiles(quantiles)
|
||||
quantiles_str = "[" + quantiles.join(",") + "]"
|
||||
result = self.class.connection.execute("""
|
||||
@@ -180,7 +319,7 @@ class Exercise < ApplicationRecord
|
||||
exercise_id,
|
||||
max_score,
|
||||
CASE
|
||||
WHEN working_time >= '0:05:00' THEN '0'
|
||||
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
|
||||
ELSE working_time
|
||||
END AS working_time_new
|
||||
FROM all_working_times_until_max ), result AS
|
||||
@@ -274,7 +413,7 @@ class Exercise < ApplicationRecord
|
||||
|
||||
FILTERED_TIMES_UNTIL_MAX AS
|
||||
(
|
||||
SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
|
||||
SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM ALL_WORKING_TIMES_UNTIL_MAX
|
||||
)
|
||||
SELECT e.external_id AS external_user_id, f.user_id, exercise_id, MAX(max_score) AS max_score, sum(working_time_new) AS working_time
|
||||
|
@@ -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)
|
||||
|
@@ -1,12 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StudyGroup < ApplicationRecord
|
||||
has_many :study_group_memberships
|
||||
has_many :study_group_memberships, dependent: :destroy
|
||||
# Use `ExternalUser` as `source_type` for now.
|
||||
# Using `User` will lead ActiveRecord to access the inexistent table `users`.
|
||||
# Issue created: https://github.com/rails/rails/issues/34531
|
||||
has_many :users, through: :study_group_memberships, source_type: 'ExternalUser'
|
||||
has_many :submissions
|
||||
has_many :submissions, dependent: :nullify
|
||||
belongs_to :consumer
|
||||
|
||||
def to_s
|
||||
|
@@ -1,6 +1,7 @@
|
||||
class Submission < ApplicationRecord
|
||||
include Context
|
||||
include Creation
|
||||
include ActionCableHelper
|
||||
|
||||
CAUSES = %w(assess download file render run save submit test autosave requestComments remoteAssess)
|
||||
FILENAME_URL_PLACEHOLDER = '{filename}'
|
||||
@@ -20,6 +21,8 @@ class Submission < ApplicationRecord
|
||||
validates :cause, inclusion: {in: CAUSES}
|
||||
validates :exercise_id, presence: true
|
||||
|
||||
after_save :trigger_working_times_action_cable
|
||||
|
||||
MAX_COMMENTS_ON_RECOMMENDED_RFC = 5
|
||||
|
||||
def build_files_hash(files, attribute)
|
||||
|
@@ -25,6 +25,22 @@ class ApplicationPolicy
|
||||
end
|
||||
private :no_one
|
||||
|
||||
def everyone_in_study_group
|
||||
study_group = @record.study_group
|
||||
return false if study_group.blank?
|
||||
|
||||
users_in_same_study_group = study_group.users
|
||||
return false if users_in_same_study_group.blank?
|
||||
|
||||
users_in_same_study_group.include? @user
|
||||
end
|
||||
private :everyone_in_study_group
|
||||
|
||||
def teacher_in_study_group
|
||||
teacher? && everyone_in_study_group
|
||||
end
|
||||
private :teacher_in_study_group
|
||||
|
||||
def initialize(user, record)
|
||||
@user = user
|
||||
@record = record
|
||||
|
@@ -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
|
||||
|
@@ -12,14 +12,8 @@ class SubmissionPolicy < ApplicationPolicy
|
||||
admin?
|
||||
end
|
||||
|
||||
def everyone_in_study_group
|
||||
users_in_same_study_group = @record.study_groups.users
|
||||
users_in_same_study_group.include? @user
|
||||
end
|
||||
private :everyone_in_study_group
|
||||
|
||||
def teacher_in_study_group
|
||||
teacher? && everyone_in_study_group
|
||||
def show_study_group?
|
||||
admin? || teacher_in_study_group
|
||||
end
|
||||
private :teacher_in_study_group
|
||||
end
|
||||
|
@@ -58,7 +58,7 @@ h1 = "#{@exercise} (external user #{@external_user})"
|
||||
td =
|
||||
td =
|
||||
td = @working_times_until[index] if index > 0
|
||||
p = t('.addendum')
|
||||
p = t('.addendum', delta: StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS / 60)
|
||||
.d-none#wtimes data-working_times=ActiveSupport::JSON.encode(@working_times_until);
|
||||
div#progress_chart.col-lg-12
|
||||
.graph-functions-2
|
||||
|
37
app/views/exercises/study_group_dashboard.html.slim
Normal file
37
app/views/exercises/study_group_dashboard.html.slim
Normal file
@@ -0,0 +1,37 @@
|
||||
- content_for :head do
|
||||
// Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326.
|
||||
Otherwise, code might not be highlighted correctly (race condition)
|
||||
meta name='turbolinks-visit-control' content='reload'
|
||||
= javascript_pack_tag('d3-tip', 'data-turbolinks-track': true)
|
||||
|
||||
h1
|
||||
= t('.live_dashboard')
|
||||
|
||||
div.teacher_dashboard data-exercise-id="#{@exercise.id}" data-study-group-id="#{@study_group_id}"
|
||||
|
||||
h4.mt-4
|
||||
= t('.time_spent_per_learner')
|
||||
|
||||
.d-none#initial_graph_data data-graph_data=ActiveSupport::JSON.encode(@graph_data);
|
||||
div.w-100#chart_stacked
|
||||
|
||||
.d-none.badge-info.container.py-2#no_chart_data
|
||||
i class="fa fa-info" aria-hidden="true"
|
||||
= t('.no_data_yet')
|
||||
|
||||
h4.mt-4
|
||||
= 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,10 @@ html lang='en'
|
||||
= javascript_include_tag('application', 'data-turbolinks-track': true)
|
||||
= yield(:head)
|
||||
= csrf_meta_tags
|
||||
= timeago_script_tag
|
||||
script type="text/javascript"
|
||||
| I18n.defaultLocale = "#{I18n.default_locale}";
|
||||
| I18n.locale = "#{I18n.locale}";
|
||||
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
|
@@ -9,6 +9,9 @@
|
||||
- testruns = Testrun.where(:submission_id => @request_for_comment.submission)
|
||||
= link_to_if(policy(user).show?, user.displayname, user)
|
||||
| | #{@request_for_comment.created_at.localtime}
|
||||
- if @request_for_comment.submission.study_group.present? && policy(@request_for_comment.submission).show_study_group?
|
||||
= ' | '
|
||||
= link_to_if(policy(@request_for_comment.submission.study_group).show?, @request_for_comment.submission.study_group, @request_for_comment.submission.study_group)
|
||||
.rfc
|
||||
.description
|
||||
h5
|
||||
|
@@ -9,6 +9,7 @@ h1 = @submission
|
||||
|
||||
= row(label: 'submission.exercise', value: link_to_if(policy(@submission.exercise).show?, @submission.exercise, @submission.exercise))
|
||||
= row(label: 'submission.user', value: link_to_if(policy(@submission.user).show?, @submission.user, @submission.user))
|
||||
= row(label: 'submission.study_group', value: link_to_if(policy(@submission.study_group).show?, @submission.study_group, @submission.study_group))
|
||||
= row(label: 'submission.cause', value: t("submissions.causes.#{@submission.cause}"))
|
||||
= row(label: 'submission.score', value: @submission.score)
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
== t('mailers.user_mailer.send_thank_you_note.body',
|
||||
receiver_displayname: @receiver_displayname,
|
||||
link_to_comment: link_to(@rfc_link, @rfc_link),
|
||||
author: @author.displayname,
|
||||
author: @author,
|
||||
thank_you_note: @thank_you_note )
|
||||
|
Reference in New Issue
Block a user