merge master
This commit is contained in:
@ -14,12 +14,9 @@
|
||||
//= require pagedown_bootstrap
|
||||
//= require rails-timeago
|
||||
//= require locales/jquery.timeago.de.js
|
||||
//= require i18n
|
||||
//= require i18n/translations
|
||||
//
|
||||
// lib/assets
|
||||
//= require flash
|
||||
//= require url
|
||||
//
|
||||
// vendor/assets
|
||||
//= require ace/ace
|
||||
|
@ -1,6 +1,6 @@
|
||||
$(document).on('turbolinks:load', function() {
|
||||
|
||||
var subMenusSelector = 'ul.dropdown-menu [data-toggle=dropdown]';
|
||||
var subMenusSelector = 'ul.dropdown-menu [data-bs-toggle=dropdown]';
|
||||
|
||||
function openSubMenu(event) {
|
||||
if (this.pathname === '/') {
|
||||
|
@ -1,5 +1,5 @@
|
||||
$(document).on('turbolinks:load', function() {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
$('[data-bs-toggle="tooltip"]').tooltip();
|
||||
if($.isController('codeharbor_links')) {
|
||||
if ($('.edit_codeharbor_link, .new_codeharbor_link').isPresent()) {
|
||||
|
||||
|
@ -12,7 +12,7 @@ $(document).on('turbolinks:load', function() {
|
||||
return _.map($('tbody tr[data-id]'), function(element) {
|
||||
return {
|
||||
content: $('td.name', element).text(),
|
||||
id: $(element).data('id'),
|
||||
id: `execution_environment_${$(element).data('id')}`,
|
||||
visible: false
|
||||
};
|
||||
});
|
||||
@ -67,7 +67,7 @@ $(document).on('turbolinks:load', function() {
|
||||
var setGroupVisibility = function(response) {
|
||||
_.each(response.docker, function(data) {
|
||||
groups.update({
|
||||
id: data.id,
|
||||
id: `execution_environment_${data.id}`,
|
||||
visible: data.prewarmingPoolSize > 0
|
||||
});
|
||||
});
|
||||
@ -76,7 +76,7 @@ $(document).on('turbolinks:load', function() {
|
||||
var updateChartData = function(response) {
|
||||
_.each(response.docker, function(data) {
|
||||
dataset.add({
|
||||
group: data.id,
|
||||
group: `execution_environment_${data.id}`,
|
||||
x: vis.moment(),
|
||||
y: data.usedRunners
|
||||
});
|
||||
|
@ -15,12 +15,9 @@ $(document).on('turbolinks:load', function(event) {
|
||||
);
|
||||
|
||||
if ($('#editor').isPresent() && CodeOceanEditor && event.originalEvent.data.url.includes("/implement")) {
|
||||
if (CodeOceanEditor.isBrowserSupported()) {
|
||||
$('#alert').hide();
|
||||
// This call will (amon other things) initializeEditors and load the content except for the last line
|
||||
// It must not be called during page navigation. Otherwise, content will be duplicated!
|
||||
// Search for insertLines and Turbolinks reload / cache control
|
||||
CodeOceanEditor.initializeEverything();
|
||||
}
|
||||
// This call will (amon other things) initializeEditors and load the content except for the last line
|
||||
// It must not be called during page navigation. Otherwise, content will be duplicated!
|
||||
// Search for insertLines and Turbolinks reload / cache control
|
||||
CodeOceanEditor.initializeEverything();
|
||||
}
|
||||
});
|
||||
|
@ -17,7 +17,7 @@ var CodeOceanEditor = {
|
||||
//Request-For-Comments-Configuration
|
||||
REQUEST_FOR_COMMENTS_DELAY: 0,
|
||||
REQUEST_TOOLTIP_TIME: 5000,
|
||||
REQUEST_TOOLTIP_DELAY: 10 * 60 * 1000,
|
||||
REQUEST_TOOLTIP_DELAY: 15 * 60 * 1000,
|
||||
|
||||
editors: [],
|
||||
editor_for_file: new Map(),
|
||||
@ -78,7 +78,7 @@ var CodeOceanEditor = {
|
||||
if ($('#output-' + index).isPresent()) {
|
||||
return $('#output-' + index);
|
||||
} else {
|
||||
var element = $('<pre class="mt-2">').attr('id', 'output-' + index);
|
||||
var element = $('<pre class="mb-2">').attr('id', 'output-' + index);
|
||||
$('#output').append(element);
|
||||
return element;
|
||||
}
|
||||
@ -216,8 +216,8 @@ var CodeOceanEditor = {
|
||||
},
|
||||
|
||||
hideSpinner: function () {
|
||||
$('button i.fa, button i.far, button i.fas').show();
|
||||
$('button i.fa-spin').hide();
|
||||
$('button i.fa-solid, button i.fa-regular').show();
|
||||
$('button i.fa-spin').removeClass('d-inline-block').addClass('d-none');
|
||||
},
|
||||
|
||||
|
||||
@ -235,10 +235,20 @@ var CodeOceanEditor = {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
},
|
||||
|
||||
resizeParentOfAceEditor: function (element) {
|
||||
resizeSidebars: function () {
|
||||
$('#content-left-sidebar').height(this.calculateEditorHeight('#content-left-sidebar', false));
|
||||
$('#content-right-sidebar').height(this.calculateEditorHeight('#content-right-sidebar', false));
|
||||
},
|
||||
|
||||
calculateEditorHeight: function (element, considerStatusbar) {
|
||||
let bottom = considerStatusbar ? ($('#statusbar').height() || 0) : 0;
|
||||
// calculate needed size: window height - position of top of ACE editor - height of autosave label below editor - 5 for bar margins
|
||||
var windowHeight = window.innerHeight - $(element).offset().top - ($('#statusbar').height() || 0) - 5;
|
||||
$(element).parent().height(windowHeight);
|
||||
return window.innerHeight - $(element).offset().top - bottom - 5;
|
||||
},
|
||||
|
||||
resizeParentOfAceEditor: function (element) {
|
||||
const editorHeight = this.calculateEditorHeight(element, true);
|
||||
$(element).parent().height(editorHeight);
|
||||
},
|
||||
|
||||
initializeEditors: function (own_solution = false) {
|
||||
@ -259,6 +269,7 @@ var CodeOceanEditor = {
|
||||
// Resize frame on window size change
|
||||
$(window).resize(function () {
|
||||
this.resizeParentOfAceEditor(element);
|
||||
this.resizeSidebars();
|
||||
}.bind(this));
|
||||
|
||||
var editor = ace.edit(element);
|
||||
@ -366,11 +377,9 @@ var CodeOceanEditor = {
|
||||
}
|
||||
filesInstance.jstree(filesInstance.data('entries'));
|
||||
filesInstance.on('click', 'li.jstree-leaf > a', function (event) {
|
||||
this.setActiveFile(
|
||||
$(event.target).parent().text(),
|
||||
parseInt($(event.target).parent().attr('id'))
|
||||
);
|
||||
var frame = $('[data-file-id="' + this.active_file.id + '"]').parent();
|
||||
const file_id = parseInt($(event.target).parent().attr('id'));
|
||||
const frame = $('[data-file-id="' + file_id + '"]').parent();
|
||||
this.setActiveFile(frame.data('filename'), file_id);
|
||||
this.showFrame(frame);
|
||||
this.toggleButtonStates();
|
||||
}.bind(this));
|
||||
@ -392,6 +401,7 @@ var CodeOceanEditor = {
|
||||
tipButton.on('click', this.handleSideBarToggle.bind(this));
|
||||
}
|
||||
$('#sidebar').on('transitionend', this.resizeAceEditors.bind(this));
|
||||
$('#sidebar').on('transitionend', this.resizeSidebars.bind(this));
|
||||
},
|
||||
|
||||
handleSideBarToggle: function () {
|
||||
@ -435,12 +445,12 @@ var CodeOceanEditor = {
|
||||
button.prop('disabled', true);
|
||||
button.on('click', function () {
|
||||
$('#rfc_intervention_text').hide()
|
||||
$('#comment-modal').modal('show');
|
||||
new bootstrap.Modal($('#comment-modal')).show();
|
||||
});
|
||||
|
||||
$('#askForCommentsButton').on('click', this.requestComments.bind(this));
|
||||
$('#closeAskForCommentsButton').on('click', function () {
|
||||
$('#comment-modal').modal('hide');
|
||||
bootstrap.Modal.getInstance($('#comment-modal')).hide();
|
||||
});
|
||||
|
||||
setTimeout(function () {
|
||||
@ -477,30 +487,24 @@ var CodeOceanEditor = {
|
||||
return this.isActiveFileExecutable() && ['teacher_defined_test', 'user_defined_test', 'teacher_defined_linter'].includes(this.active_frame.data('role'));
|
||||
},
|
||||
|
||||
isBrowserSupported: function () {
|
||||
// websockets are used for run, score and test
|
||||
// Also exclude IE and IE 11
|
||||
return Modernizr.websockets && window.navigator.userAgent.indexOf("MSIE") <= 0 && !navigator.userAgent.match(/Trident\/7\./);
|
||||
},
|
||||
|
||||
populateCard: function (card, result, index) {
|
||||
card.addClass(this.getCardClass(result));
|
||||
card.find('.card-title .filename').text(result.filename);
|
||||
card.find('.card-title .number').text(index + 1);
|
||||
card.find('.row .col-sm-9').eq(0).find('.number').eq(0).text(result.passed);
|
||||
card.find('.row .col-sm-9').eq(0).find('.number').eq(1).text(result.count);
|
||||
card.find('.row .col-md-9').eq(0).find('.number').eq(0).text(result.passed);
|
||||
card.find('.row .col-md-9').eq(0).find('.number').eq(1).text(result.count);
|
||||
if (result.weight !== 0) {
|
||||
card.find('.row .col-sm-9').eq(1).find('.number').eq(0).text(parseFloat((result.score * result.weight).toFixed(2)));
|
||||
card.find('.row .col-sm-9').eq(1).find('.number').eq(1).text(result.weight);
|
||||
card.find('.row .col-md-9').eq(1).find('.number').eq(0).text(parseFloat((result.score * result.weight).toFixed(2)));
|
||||
card.find('.row .col-md-9').eq(1).find('.number').eq(1).text(result.weight);
|
||||
} else {
|
||||
// Hide score row if no score could be achieved
|
||||
card.find('.attribute-row.row').eq(1).addClass('d-none');
|
||||
}
|
||||
card.find('.row .col-sm-9').eq(2).html(result.message);
|
||||
card.find('.row .col-md-9').eq(2).html(result.message);
|
||||
|
||||
// Add error message from code to card
|
||||
if (result.error_messages) {
|
||||
const targetNode = card.find('.row .col-sm-9').eq(3);
|
||||
const targetNode = card.find('.row .col-md-9').eq(3);
|
||||
|
||||
let errorMessagesToShow = [];
|
||||
result.error_messages.forEach(function (item) {
|
||||
@ -571,7 +575,7 @@ var CodeOceanEditor = {
|
||||
}
|
||||
targetNode.append(ul);
|
||||
}
|
||||
//card.find('.row .col-sm-9').eq(4).find('a').attr('href', '#output-' + index);
|
||||
//card.find('.row .col-md-9').eq(4).find('a').attr('href', '#output-' + index);
|
||||
},
|
||||
|
||||
createEventHandler: function (eventType, data) {
|
||||
@ -652,12 +656,17 @@ var CodeOceanEditor = {
|
||||
|
||||
let matches;
|
||||
|
||||
let augmented_text = text;
|
||||
// Switch both lines below to enable the output of images and render <IMG/> tags.
|
||||
// Also consider `printOutput` in evaluation.js
|
||||
|
||||
// let augmented_text = element.text();
|
||||
let augmented_text = element.html();
|
||||
while (matches = this.tracepositions_regex.exec(text)) {
|
||||
const frame = $('div.frame[data-filename="' + matches[1] + '"]')
|
||||
|
||||
if (frame.length > 0) {
|
||||
augmented_text = augmented_text.replace(new RegExp(matches[0], 'g'), "<a href='#' data-file='" + matches[1] + "' data-line='" + matches[2] + "'>" + matches[0] + "</a>");
|
||||
// augmented_text = augmented_text.replace(new RegExp(matches[0], 'g'), "<a href='#' data-file='" + matches[1] + "' data-line='" + matches[2] + "'>" + matches[0] + "</a>");
|
||||
augmented_text = augmented_text.replace(new RegExp(_.unescape(matches[0]), 'g'), "<a href='#' data-file='" + matches[1] + "' data-line='" + matches[2] + "'>" + matches[0] + "</a>");
|
||||
}
|
||||
}
|
||||
element.html(augmented_text);
|
||||
@ -694,8 +703,8 @@ var CodeOceanEditor = {
|
||||
},
|
||||
|
||||
showSpinner: function (initiator) {
|
||||
$(initiator).find('i.fa, i.far, i.fas').hide();
|
||||
$(initiator).find('i.fa-spin').show();
|
||||
$(initiator).find('i.fa-solid, i.fa-regular').hide();
|
||||
$(initiator).find('i.fa-spin').addClass('d-inline-block').removeClass('d-none');
|
||||
},
|
||||
|
||||
showStatus: function (output) {
|
||||
@ -703,9 +712,11 @@ var CodeOceanEditor = {
|
||||
this.showTimeoutMessage();
|
||||
} else if (output.status === 'container_depleted') {
|
||||
this.showContainerDepletedMessage();
|
||||
} else if (output.status === 'out_of_memory') {
|
||||
this.showOutOfMemoryMessage();
|
||||
} else if (output.stderr) {
|
||||
$.flash.danger({
|
||||
icon: ['fa', 'fa-bug'],
|
||||
icon: ['fa-solid', 'fa-bug'],
|
||||
text: $('#run').data('message-failure')
|
||||
});
|
||||
Sentry.captureException(JSON.stringify(output));
|
||||
@ -738,14 +749,21 @@ var CodeOceanEditor = {
|
||||
|
||||
showContainerDepletedMessage: function () {
|
||||
$.flash.danger({
|
||||
icon: ['fa', 'fa-clock-o'],
|
||||
icon: ['fa-regular', 'fa-clock'],
|
||||
text: $('#editor').data('message-depleted')
|
||||
});
|
||||
},
|
||||
|
||||
showOutOfMemoryMessage: function () {
|
||||
$.flash.info({
|
||||
icon: ['fa-regular', 'fa-clock'],
|
||||
text: $('#editor').data('message-out-of-memory')
|
||||
});
|
||||
},
|
||||
|
||||
showTimeoutMessage: function () {
|
||||
$.flash.info({
|
||||
icon: ['fa', 'fa-clock-o'],
|
||||
icon: ['fa-regular', 'fa-clock'],
|
||||
text: $('#editor').data('message-timeout')
|
||||
});
|
||||
},
|
||||
@ -766,7 +784,7 @@ var CodeOceanEditor = {
|
||||
event.preventDefault();
|
||||
this.createSubmission('#create-file', null, function (response) {
|
||||
$('#code_ocean_file_context_id').val(response.id);
|
||||
$('#modal-file').modal('show');
|
||||
new bootstrap.Modal($('#modal-file')).show();
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
@ -774,6 +792,7 @@ var CodeOceanEditor = {
|
||||
$('#toggle-sidebar-output').on('click', this.hideOutputBar.bind(this));
|
||||
$('#toggle-sidebar-output-collapsed').on('click', this.showOutputBar.bind(this));
|
||||
$('#output_sidebar').on('transitionend', this.resizeAceEditors.bind(this));
|
||||
$('#output_sidebar').on('transitionend', this.resizeSidebars.bind(this));
|
||||
},
|
||||
|
||||
showOutputBar: function () {
|
||||
@ -789,7 +808,7 @@ var CodeOceanEditor = {
|
||||
},
|
||||
|
||||
initializeSideBarTooltips: function () {
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
$('[data-bs-toggle="tooltip"]').tooltip()
|
||||
},
|
||||
|
||||
initializeDescriptionToggle: function () {
|
||||
@ -797,12 +816,13 @@ var CodeOceanEditor = {
|
||||
$('a#toggle').on('click', this.toggleDescriptionCard.bind(this));
|
||||
},
|
||||
|
||||
toggleDescriptionCard: function () {
|
||||
toggleDescriptionCard: function (event) {
|
||||
$('#description-card').toggleClass('description-card-collapsed').toggleClass('description-card');
|
||||
$('#description-symbol').toggleClass('fa-chevron-down').toggleClass('fa-chevron-right');
|
||||
var toggle = $('a#toggle');
|
||||
toggle.text(toggle.text() == toggle.data('hide') ? toggle.data('show') : toggle.data('hide'));
|
||||
this.resizeAceEditors();
|
||||
this.resizeSidebars();
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
@ -835,11 +855,7 @@ var CodeOceanEditor = {
|
||||
const percentile75 = data['working_time_75_percentile'];
|
||||
const accumulatedWorkTimeUser = data['working_time_accumulated'];
|
||||
|
||||
let minTimeIntervention = 10 * 60 * 1000;
|
||||
if ($('#editor').data('exercise-id') === 909) {
|
||||
// 30 minutes for our large Map exercise
|
||||
minTimeIntervention = 30 * 60 * 1000;
|
||||
}
|
||||
let minTimeIntervention = 20 * 60 * 1000;
|
||||
|
||||
let timeUntilIntervention;
|
||||
if ((accumulatedWorkTimeUser - percentile75) > 0) {
|
||||
@ -861,17 +877,21 @@ var CodeOceanEditor = {
|
||||
clearInterval(tid);
|
||||
// timeUntilIntervention passed
|
||||
if (editor.data('tips-interventions')) {
|
||||
$('#tips-intervention-modal').modal('show');
|
||||
const modal = $('#tips-intervention-modal');
|
||||
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
|
||||
new bootstrap.Modal(modal).show();
|
||||
$.ajax({
|
||||
data: {
|
||||
intervention_type: 'TipIntervention'
|
||||
intervention_type: 'TipsIntervention'
|
||||
},
|
||||
dataType: 'json',
|
||||
type: 'POST',
|
||||
url: interventionSaveUrl
|
||||
});
|
||||
} else if (editor.data('break-interventions')) {
|
||||
$('#break-intervention-modal').modal('show');
|
||||
const modal = $('#break-intervention-modal');
|
||||
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
|
||||
new bootstrap.Modal(modal).show();
|
||||
$.ajax({
|
||||
data: {
|
||||
intervention_type: 'BreakIntervention'
|
||||
@ -885,7 +905,12 @@ var CodeOceanEditor = {
|
||||
// only show intervention if user did not requested for a comment already
|
||||
if (!button.prop('disabled')) {
|
||||
$('#rfc_intervention_text').show();
|
||||
$('#comment-modal').modal('show');
|
||||
modal = $('#comment-modal');
|
||||
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
|
||||
modal.on('hidden.bs.modal', function () {
|
||||
modal.find('.modal-footer').text('');
|
||||
});
|
||||
new bootstrap.Modal(modal).show();
|
||||
$.ajax({
|
||||
data: {
|
||||
intervention_type: 'QuestionIntervention'
|
||||
@ -928,7 +953,6 @@ var CodeOceanEditor = {
|
||||
CodeOceanEditor.editors = [];
|
||||
this.initializeRegexes();
|
||||
this.initializeCodePilot();
|
||||
$('.score, #development-environment').show();
|
||||
this.configureEditors();
|
||||
this.initializeEditors();
|
||||
this.initializeEventHandlers();
|
||||
@ -944,6 +968,7 @@ var CodeOceanEditor = {
|
||||
this.renderScore();
|
||||
this.showFirstFile();
|
||||
this.resizeAceEditors();
|
||||
this.resizeSidebars();
|
||||
this.initializeDeadlines();
|
||||
CodeOceanEditorTips.initializeEventHandlers();
|
||||
|
||||
|
@ -1,5 +1,8 @@
|
||||
CodeOceanEditorEvaluation = {
|
||||
chunkBuffer: [{streamedResponse: true}],
|
||||
// A list of non-printable characters that are not allowed in the code output.
|
||||
// Taken from https://stackoverflow.com/a/69024306
|
||||
nonPrintableRegEx: /[\u0000-\u0008\u000B\u000C\u000F-\u001F\u007F-\u009F\u2000-\u200F\u2028-\u202F\u205F-\u206F\u3000\uFEFF]/g,
|
||||
|
||||
/**
|
||||
* Scoring-Functions
|
||||
@ -99,6 +102,11 @@ CodeOceanEditorEvaluation = {
|
||||
})) {
|
||||
this.showTimeoutMessage();
|
||||
}
|
||||
if (_.some(response, function (result) {
|
||||
return result.status === 'out_of_memory';
|
||||
})) {
|
||||
this.showOutOfMemoryMessage();
|
||||
}
|
||||
if (_.some(response, function (result) {
|
||||
return result.status === 'container_depleted';
|
||||
})) {
|
||||
@ -199,26 +207,39 @@ CodeOceanEditorEvaluation = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.stdout !== undefined && !output.stdout.startsWith("<img")) {
|
||||
output.stdout = _.escape(output.stdout);
|
||||
}
|
||||
|
||||
var element = this.findOrCreateOutputElement(index);
|
||||
// Switch all four lines below to enable the output of images and render <IMG/> tags
|
||||
// Switch all four lines below to enable the output of images and render <IMG/> tags.
|
||||
// Also consider `augmentStacktraceInOutput` in editor.js.erb
|
||||
if (!colorize) {
|
||||
if (output.stdout !== undefined && output.stdout !== '') {
|
||||
//element.append(output.stdout)
|
||||
element.text(element.text() + output.stdout)
|
||||
output.stdout = output.stdout.replace(this.nonPrintableRegEx, "")
|
||||
|
||||
element.append(output.stdout)
|
||||
//element.text(element.text() + output.stdout)
|
||||
}
|
||||
|
||||
if (output.stderr !== undefined && output.stderr !== '') {
|
||||
//element.append('StdErr: ' + output.stderr);
|
||||
element.text('StdErr: ' + element.text() + output.stderr);
|
||||
output.stderr = output.stderr.replace(this.nonPrintableRegEx, "")
|
||||
|
||||
element.append('StdErr: ' + output.stderr);
|
||||
//element.text('StdErr: ' + element.text() + output.stderr);
|
||||
}
|
||||
|
||||
} else if (output.stderr) {
|
||||
//element.addClass('text-warning').append(output.stderr);
|
||||
element.addClass('text-warning').text(element.text() + output.stderr);
|
||||
output.stderr = output.stderr.replace(this.nonPrintableRegEx, "")
|
||||
|
||||
element.addClass('text-warning').append(output.stderr);
|
||||
//element.addClass('text-warning').text(element.text() + output.stderr);
|
||||
this.QaApiOutputBuffer.stderr += output.stderr;
|
||||
} else if (output.stdout) {
|
||||
//element.addClass('text-success').append(output.stdout);
|
||||
element.addClass('text-success').text(element.text() + output.stdout);
|
||||
output.stdout = output.stdout.replace(this.nonPrintableRegEx, "")
|
||||
|
||||
element.addClass('text-success').append(output.stdout);
|
||||
//element.addClass('text-success').text(element.text() + output.stdout);
|
||||
this.QaApiOutputBuffer.stdout += output.stdout;
|
||||
} else {
|
||||
element.addClass('text-muted').text($('#output').data('message-no-output'));
|
||||
|
@ -46,7 +46,6 @@ CodeOceanEditorWebsocket = {
|
||||
this.websocket.on('turtlebatch', this.handleTurtlebatchCommand.bind(this));
|
||||
this.websocket.on('render', this.renderWebsocketOutput.bind(this));
|
||||
this.websocket.on('exit', this.handleExitCommand.bind(this));
|
||||
this.websocket.on('timeout', this.showTimeoutMessage.bind(this));
|
||||
this.websocket.on('status', this.showStatus.bind(this));
|
||||
this.websocket.on('hint', this.showHint.bind(this));
|
||||
},
|
||||
|
@ -4,9 +4,9 @@ CodeOceanEditorFlowr = {
|
||||
'<div class="card mb-2">' +
|
||||
'<div id="{{headingId}}" role="tab" class="card-header">' +
|
||||
'<div class="card-title mb-0">' +
|
||||
'<a class="collapsed" data-toggle="collapse" data-parent="#flowrHint" href="#{{collapseId}}" aria-expanded="false" aria-controls="{{collapseId}}">' +
|
||||
'<a class="collapsed" data-bs-toggle="collapse" data-bs-parent="#flowrHint" href="#{{collapseId}}" aria-expanded="false" aria-controls="{{collapseId}}">' +
|
||||
'<div class="clearfix" role="button">' +
|
||||
'<i class="fa" aria-hidden="true"></i>' +
|
||||
'<i class="fa-solid" aria-hidden="true"></i>' +
|
||||
'<span>' +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
@ -14,7 +14,7 @@ CodeOceanEditorFlowr = {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="{{collapseId}}" role="tabpanel" aria-labelledby="{{headingId}}" class="card card-collapse collapse">' +
|
||||
'<div class="card-body"></div>' +
|
||||
'<div class="card-body d-grid gap-2"></div>' +
|
||||
'</div>' +
|
||||
'</div>',
|
||||
|
||||
@ -93,7 +93,7 @@ CodeOceanEditorFlowr = {
|
||||
|
||||
var body = resultTile.find('.card-body');
|
||||
body.html(result.body);
|
||||
body.append('<a target="_blank" href="' + questionUrl + '" class="btn btn-primary btn-block">' +
|
||||
body.append('<a target="_blank" href="' + questionUrl + '" class="btn btn-primary">' +
|
||||
'<%= I18n.t('exercises.implement.flowr.go_to_question') %></a>');
|
||||
body.find('.btn').on('click', CodeOceanEditor.createEventHandler('editor_flowr_click_question', questionUrl));
|
||||
|
||||
@ -112,7 +112,7 @@ CodeOceanEditorCodePilot = {
|
||||
QaApiOutputBuffer: {'stdout': '', 'stderr': ''},
|
||||
|
||||
initializeCodePilot: function () {
|
||||
if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined') && QaApi.isBrowserSupported()) {
|
||||
if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined')) {
|
||||
$('#editor-column').addClass('col-md-10').removeClass('col-md-12');
|
||||
$('#questions-column').addClass('col-md-2');
|
||||
|
||||
@ -161,7 +161,7 @@ CodeOceanEditorRequestForComments = {
|
||||
|
||||
this.createSubmission($('#requestComments'), null, createRequestForComments.bind(this));
|
||||
|
||||
$('#comment-modal').modal('hide');
|
||||
bootstrap.Modal.getInstance($('#comment-modal')).hide();
|
||||
$('#question').val('');
|
||||
// we disabled the button to prevent that the user spams RFCs, but decided against this now.
|
||||
//var button = $('#requestComments');
|
||||
|
@ -37,15 +37,16 @@ CodeOceanEditorTurtle = {
|
||||
},
|
||||
|
||||
showCanvas: function () {
|
||||
if ($('#turtlediv').isPresent() && this.turtlecanvas.hasClass('d-none')) {
|
||||
this.turtlecanvas.removeClass('d-none');
|
||||
const turtlediv = $('#turtlediv');
|
||||
if (turtlediv.isPresent() && turtlediv.hasClass('d-none')) {
|
||||
turtlediv.removeClass('d-none');
|
||||
}
|
||||
},
|
||||
|
||||
hideCanvas: function () {
|
||||
const turtlecanvas = $('#turtlecanvas');
|
||||
if ($('#turtlediv').isPresent() && !turtlecanvas.hasClass('d-none')) {
|
||||
turtlecanvas.addClass('d-none');
|
||||
const turtlediv = $('#turtlediv');
|
||||
if (turtlediv.isPresent() && !turtlediv.hasClass('d-none')) {
|
||||
turtlediv.addClass('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -172,7 +172,7 @@ $(document).on('turbolinks:load', function() {
|
||||
if (collectionExercises.indexOf(exercise.id) === -1) {
|
||||
// only add exercises that are not already contained in the collection
|
||||
var template = '<tr data-id="' + exercise.id + '">' +
|
||||
'<td><span class="fa fa-bars"></span></td>' +
|
||||
'<td><span class="fa-solid fa-bars"></span></td>' +
|
||||
'<td>' + exercise.title + '</td>' +
|
||||
'<td><a href="/exercises/' + exercise.id + '"><%= I18n.t('shared.show') %></td>' +
|
||||
'<td><a class="remove-exercise" href="#"><%= I18n.t('shared.destroy') %></td></tr>';
|
||||
@ -187,7 +187,7 @@ $(document).on('turbolinks:load', function() {
|
||||
for (var i = 0; i < selectedExercises.length; i++) {
|
||||
addExercise(selectedExercises[i].value, selectedExercises[i].label);
|
||||
}
|
||||
$('#add-exercise-modal').modal('hide')
|
||||
bootstrap.Modal.getInstance($('#add-exercise-modal')).hide();
|
||||
updateExerciseList();
|
||||
addExercisesForm.find('select').val('').trigger("chosen:updated");
|
||||
});
|
||||
|
@ -123,7 +123,7 @@ $(document).on('turbolinks:load', function () {
|
||||
var buildCheckboxes = function () {
|
||||
$('tbody tr').each(function (index, element) {
|
||||
var td = $('td.public', element);
|
||||
var checkbox = $('<input>', {
|
||||
var checkbox = $('<input class="form-check-input">', {
|
||||
checked: td.data('value'),
|
||||
type: 'checkbox'
|
||||
});
|
||||
@ -225,9 +225,9 @@ $(document).on('turbolinks:load', function () {
|
||||
const tip = {id: id, title: title}
|
||||
const template =
|
||||
'<div class="list-group-item d-block" data-tip-id=' + tip.id + ' data-id="">' +
|
||||
'<span class="fa fa-bars mr-3"></span>' + tip.title +
|
||||
'<a class="fa fa-eye ml-2" href="/tips/' + tip.id + '" target="_blank"></a>' +
|
||||
'<a class="fa fa-times ml-2 remove-tip" href="#""></a>' +
|
||||
'<span class="fa-solid fa-bars me-3"></span>' + tip.title +
|
||||
'<a class="fa-regular fa-eye ms-2" href="/tips/' + tip.id + '" target="_blank"></a>' +
|
||||
'<a class="fa-solid fa-xmark ms-2 remove-tip" href="#""></a>' +
|
||||
'<div class="list-group nested-sortable-list"></div>' +
|
||||
'</div>';
|
||||
const tipList = $('#tip-list').append(template);
|
||||
@ -243,7 +243,7 @@ $(document).on('turbolinks:load', function () {
|
||||
for (let i = 0; i < selectedTips.length; i++) {
|
||||
addTip(selectedTips[i].value, selectedTips[i].label);
|
||||
}
|
||||
$('#add-tips-modal').modal('hide')
|
||||
bootstrap.Modal.getInstance($('#add-tips-modal')).hide();
|
||||
updateTipsJSON();
|
||||
chosenInputTips.val('').trigger("chosen:updated");
|
||||
});
|
||||
@ -257,7 +257,7 @@ $(document).on('turbolinks:load', function () {
|
||||
|
||||
var highlightCode = function () {
|
||||
$('pre code').each(function (index, element) {
|
||||
hljs.highlightBlock(element);
|
||||
hljs.highlightElement(element);
|
||||
});
|
||||
};
|
||||
|
||||
@ -328,10 +328,7 @@ $(document).on('turbolinks:load', function () {
|
||||
var observeExportButtons = function () {
|
||||
$('.export-start').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
$('#export-modal').modal({
|
||||
height: 250
|
||||
});
|
||||
$('#export-modal').modal('show');
|
||||
new bootstrap.Modal($('#export-modal')).show();
|
||||
exportExerciseStart($(this).data().exerciseId);
|
||||
});
|
||||
$('body').on('click', '.export-retry-button', function () {
|
||||
@ -382,7 +379,7 @@ $(document).on('turbolinks:load', function () {
|
||||
if (response.status == 'success') {
|
||||
$messageDiv.addClass('export-success');
|
||||
setTimeout((function () {
|
||||
$('#export-modal').modal('hide');
|
||||
bootstrap.Modal.getInstance($('#export-modal')).hide();
|
||||
$messageDiv.html('').removeClass('export-success');
|
||||
}), 3000);
|
||||
} else {
|
||||
@ -396,7 +393,7 @@ $(document).on('turbolinks:load', function () {
|
||||
};
|
||||
|
||||
var overrideTextareaTabBehavior = function () {
|
||||
$('.form-group textarea[name$="[content]"]').on('keydown', function (event) {
|
||||
$('.mb-3 textarea[name$="[content]"]').on('keydown', function (event) {
|
||||
if (event.which === TAB_KEY_CODE) {
|
||||
event.preventDefault();
|
||||
insertTabAtCursor($(this));
|
||||
|
@ -9,7 +9,7 @@ $(document).on('turbolinks:load', function() {
|
||||
event.preventDefault();
|
||||
|
||||
if (!$(this).hasClass('disabled')) {
|
||||
var parent = $(this).parents('.form-group');
|
||||
var parent = $(this).parents('.mb-3');
|
||||
var original_input = parent.find('.original-input');
|
||||
var alternative_input = parent.find('.alternative-input');
|
||||
|
||||
|
@ -1,3 +0,0 @@
|
||||
/*! modernizr 3.6.0 (Custom Build) | MIT *
|
||||
* https://modernizr.com/download/?-eventsource-urlparser-websockets-setclasses !*/
|
||||
!function(e,n,s){function o(e,n){return typeof e===n}function t(){var e,n,s,t,a,c,f;for(var l in r)if(r.hasOwnProperty(l)){if(e=[],n=r[l],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(s=0;s<n.options.aliases.length;s++)e.push(n.options.aliases[s].toLowerCase());for(t=o(n.fn,"function")?n.fn():n.fn,a=0;a<e.length;a++)c=e[a],f=c.split("."),1===f.length?Modernizr[f[0]]=t:(!Modernizr[f[0]]||Modernizr[f[0]]instanceof Boolean||(Modernizr[f[0]]=new Boolean(Modernizr[f[0]])),Modernizr[f[0]][f[1]]=t),i.push((t?"":"no-")+f.join("-"))}}function a(e){var n=u.className,s=Modernizr._config.classPrefix||"";if(d&&(n=n.baseVal),Modernizr._config.enableJSClass){var o=new RegExp("(^|\\s)"+s+"no-js(\\s|$)");n=n.replace(o,"$1"+s+"js$2")}Modernizr._config.enableClasses&&(n+=" "+s+e.join(" "+s),d?u.className.baseVal=n:u.className=n)}var i=[],r=[],c={_version:"3.6.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var s=this;setTimeout(function(){n(s[e])},0)},addTest:function(e,n,s){r.push({name:e,fn:n,options:s})},addAsyncTest:function(e){r.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=c,Modernizr=new Modernizr;var f=!1;try{f="WebSocket"in e&&2===e.WebSocket.CLOSING}catch(l){}Modernizr.addTest("websockets",f),Modernizr.addTest("urlparser",function(){var e;try{return e=new URL("http://modernizr.com/"),"http://modernizr.com/"===e.href}catch(n){return!1}});var u=n.documentElement,d="svg"===u.nodeName.toLowerCase();Modernizr.addTest("eventsource","EventSource"in e),t(),a(i),delete c.addTest,delete c.addAsyncTest;for(var p=0;p<Modernizr._q.length;p++)Modernizr._q[p]();e.Modernizr=Modernizr}(window,document);
|
@ -29,12 +29,12 @@
|
||||
// entering links.
|
||||
var linkDialogTitle = "<%= I18n.t('components.markdown_editor.insert_link.dialog_title', default: 'Insert link') %>";
|
||||
var linkInputLabel = "<%= I18n.t('components.markdown_editor.insert_link.input_label', default: 'Link URL') %>";
|
||||
var linkInputPlaceholder = "http://example.com/ \"optional title\"";
|
||||
var linkInputPlaceholder = "https://example.com/ \"optional title\"";
|
||||
var linkInputHelp = "<%= I18n.t('components.markdown_editor.insert_link.input_help', default: 'Enter URL to point link to and optional title to display when mouse is placed over the link') %>";
|
||||
|
||||
var imageDialogTitle = "<%= I18n.t('components.markdown_editor.insert_image.dialog_title', default: 'Insert image') %>";
|
||||
var imageInputLabel = "<%= I18n.t('components.markdown_editor.insert_image.input_label', default: 'Image URL') %>";
|
||||
var imageInputPlaceholder = "http://example.com/images/diagram.jpg \"optional title\"";
|
||||
var imageInputPlaceholder = "https://example.com/images/diagram.jpg \"optional title\"";
|
||||
var imageInputHelp = "<%= I18n.t('components.markdown_editor.insert_link.input_help', default: 'Enter URL where image is located and optional title to display when mouse is placed over the image') %>";
|
||||
|
||||
var defaultHelpHoverTitle = "Markdown Editing Help";
|
||||
@ -193,7 +193,7 @@
|
||||
var regexText;
|
||||
var replacementText;
|
||||
|
||||
// chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985
|
||||
// chrome bug ... documented at: https://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985
|
||||
if (navigator.userAgent.match(/Chrome/)) {
|
||||
"X".match(/()./);
|
||||
}
|
||||
@ -1018,7 +1018,7 @@
|
||||
text = 'http://' + text;
|
||||
}
|
||||
|
||||
$(dialog).modal('hide');
|
||||
bootstrap.Modal.getInstance($(dialog)).hide();
|
||||
|
||||
callback(text);
|
||||
return false;
|
||||
@ -1032,7 +1032,7 @@
|
||||
// <div class="modal-dialog">
|
||||
// <div class="modal-content">
|
||||
// <div class="modal-header">
|
||||
// <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
// <button type="button" class="close" data-bs-dismiss="modal" aria-hidden="true">×</button>
|
||||
// <h3 class="modal-title">Modal title</h3>
|
||||
// </div>
|
||||
// <div class="modal-body">
|
||||
@ -1062,7 +1062,7 @@
|
||||
// The header.
|
||||
var header = doc.createElement("div");
|
||||
header.className = "modal-header";
|
||||
header.innerHTML = '<h3 class="modal-title">'+title+'</h3> <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>';
|
||||
header.innerHTML = '<h3 class="modal-title">'+title+'</h3> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-hidden="true"></button>';
|
||||
dialogContent.appendChild(header);
|
||||
|
||||
// The body.
|
||||
@ -1082,7 +1082,7 @@
|
||||
|
||||
// The input text box
|
||||
var formGroup = doc.createElement("div");
|
||||
formGroup.className = "form-group";
|
||||
formGroup.className = "mb-3";
|
||||
form.appendChild(formGroup);
|
||||
|
||||
var label = doc.createElement("label");
|
||||
@ -1144,15 +1144,15 @@
|
||||
range.select();
|
||||
}
|
||||
|
||||
$(dialog).on('shown', function () {
|
||||
$(dialog).on('shown.bs.modal', function () {
|
||||
input.focus();
|
||||
});
|
||||
|
||||
$(dialog).on('hidden', function () {
|
||||
$(dialog).on('hidden.bs.modal', function () {
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
});
|
||||
|
||||
$(dialog).modal()
|
||||
new bootstrap.Modal($(dialog)).show();
|
||||
|
||||
}, 0);
|
||||
};
|
||||
@ -1360,8 +1360,8 @@
|
||||
button.appendChild(buttonImage);
|
||||
button.id = id + postfix;
|
||||
button.title = title;
|
||||
button.setAttribute("data-toggle", "tooltip");
|
||||
button.setAttribute("data-placement", "top");
|
||||
button.setAttribute("data-bs-toggle", "tooltip");
|
||||
button.setAttribute("data-bs-placement", "top");
|
||||
if (textOp)
|
||||
button.textOp = textOp;
|
||||
setupButton(button, true);
|
||||
@ -1381,51 +1381,51 @@
|
||||
};
|
||||
|
||||
var group1 = makeGroup(1);
|
||||
buttons.bold = makeButton("wmd-bold-button", "<%= I18n.t('components.markdown_editor.bold.button_title', default: 'Bold (Ctrl+B)') %>", "m-1 fa fa-bold", bindCommand("doBold"), group1);
|
||||
buttons.italic = makeButton("wmd-italic-button", "<%= I18n.t('components.markdown_editor.italic.button_title', default: 'Italic (Ctrl+I)') %>", "m-1 fa fa-italic", bindCommand("doItalic"), group1);
|
||||
buttons.bold = makeButton("wmd-bold-button", "<%= I18n.t('components.markdown_editor.bold.button_title', default: 'Bold (Ctrl+B)') %>", "m-1 fa-solid fa-bold", bindCommand("doBold"), group1);
|
||||
buttons.italic = makeButton("wmd-italic-button", "<%= I18n.t('components.markdown_editor.italic.button_title', default: 'Italic (Ctrl+I)') %>", "m-1 fa-solid fa-italic", bindCommand("doItalic"), group1);
|
||||
|
||||
var group2 = makeGroup(2);
|
||||
buttons.link = makeButton("wmd-link-button", "<%= I18n.t('components.markdown_editor.insert_link.button_title', default: 'Link (Ctrl+L)') %>", "m-1 fa fa-link", bindCommand(function (chunk, postProcessing) {
|
||||
buttons.link = makeButton("wmd-link-button", "<%= I18n.t('components.markdown_editor.insert_link.button_title', default: 'Link (Ctrl+L)') %>", "m-1 fa-solid fa-link", bindCommand(function (chunk, postProcessing) {
|
||||
return this.doLinkOrImage(chunk, postProcessing, false);
|
||||
}), group2);
|
||||
buttons.image = makeButton("wmd-image-button", "<%= I18n.t('components.markdown_editor.insert_image.button_title', default: 'Image (Ctrl+G)') %>", "m-1 fa fa-picture-o", bindCommand(function (chunk, postProcessing) {
|
||||
buttons.image = makeButton("wmd-image-button", "<%= I18n.t('components.markdown_editor.insert_image.button_title', default: 'Image (Ctrl+G)') %>", "m-1 fa-regular fa-image", bindCommand(function (chunk, postProcessing) {
|
||||
return this.doLinkOrImage(chunk, postProcessing, true);
|
||||
}), group2);
|
||||
buttons.quote = makeButton("wmd-quote-button", "<%= I18n.t('components.markdown_editor.blockquoute.button_title', default: 'Blockquote (Ctrl+Q)') %>", "m-1 fa fa-quote-left", bindCommand("doBlockquote"), group2);
|
||||
buttons.code = makeButton("wmd-code-button", "<%= I18n.t('components.markdown_editor.code_sample.button_title', default: 'Code Sample (Ctrl+K)') %>", "m-1 fa fa-code", bindCommand("doCode"), group2);
|
||||
buttons.quote = makeButton("wmd-quote-button", "<%= I18n.t('components.markdown_editor.blockquoute.button_title', default: 'Blockquote (Ctrl+Q)') %>", "m-1 fa-solid fa-quote-left", bindCommand("doBlockquote"), group2);
|
||||
buttons.code = makeButton("wmd-code-button", "<%= I18n.t('components.markdown_editor.code_sample.button_title', default: 'Code Sample (Ctrl+K)') %>", "m-1 fa-solid fa-code", bindCommand("doCode"), group2);
|
||||
|
||||
var group3 = makeGroup(3);
|
||||
buttons.ulist = makeButton("wmd-ulist-button", "<%= I18n.t('components.markdown_editor.bulleted_list.button_title', default: 'Bulleted List (Ctrl+U)') %>", "m-1 fa fa-list-ul", bindCommand(function (chunk, postProcessing) {
|
||||
buttons.ulist = makeButton("wmd-ulist-button", "<%= I18n.t('components.markdown_editor.bulleted_list.button_title', default: 'Bulleted List (Ctrl+U)') %>", "m-1 fa-solid fa-list-ul", bindCommand(function (chunk, postProcessing) {
|
||||
this.doList(chunk, postProcessing, false);
|
||||
}), group3);
|
||||
buttons.olist = makeButton("wmd-olist-button", "<%= I18n.t('components.markdown_editor.numbered_list.button_title', default: 'Numbered List (Ctrl+O)') %>", "m-1 fa fa-list-ol", bindCommand(function (chunk, postProcessing) {
|
||||
buttons.olist = makeButton("wmd-olist-button", "<%= I18n.t('components.markdown_editor.numbered_list.button_title', default: 'Numbered List (Ctrl+O)') %>", "m-1 fa-solid fa-list-ol", bindCommand(function (chunk, postProcessing) {
|
||||
this.doList(chunk, postProcessing, true);
|
||||
}), group3);
|
||||
buttons.heading = makeButton("wmd-heading-button", "<%= I18n.t('components.markdown_editor.heading.button_title', default: 'Heading (Ctrl+H)') %>", "m-1 fa fa-font", bindCommand("doHeading"), group3);
|
||||
buttons.heading = makeButton("wmd-heading-button", "<%= I18n.t('components.markdown_editor.heading.button_title', default: 'Heading (Ctrl+H)') %>", "m-1 fa-solid fa-font", bindCommand("doHeading"), group3);
|
||||
|
||||
var group4 = makeGroup(4);
|
||||
buttons.undo = makeButton("wmd-undo-button", "<%= I18n.t('components.markdown_editor.undo.button_title', default: 'Undo (Ctrl+Z)') %>", "m-1 fa fa-undo", null, group4);
|
||||
buttons.undo = makeButton("wmd-undo-button", "<%= I18n.t('components.markdown_editor.undo.button_title', default: 'Undo (Ctrl+Z)') %>", "m-1 fa-solid fa-arrow-rotate-left", null, group4);
|
||||
buttons.undo.execute = function (manager) { if (manager) manager.undo(); };
|
||||
|
||||
var redoTitle = /win/.test(nav.platform.toLowerCase()) ?
|
||||
"<%= I18n.t('components.markdown_editor.redo.button_title.win', default: 'Redo (Ctrl+Y)') %>" :
|
||||
"<%= I18n.t('components.markdown_editor.redo.button_title.other', default: 'Redo (Ctrl+Shift+Z)') %>"; // mac and other non-Windows platforms
|
||||
|
||||
buttons.redo = makeButton("wmd-redo-button", redoTitle, "m-1 fa fa-repeat", null, group4);
|
||||
buttons.redo = makeButton("wmd-redo-button", redoTitle, "m-1 fa-solid fa-arrow-rotate-right", null, group4);
|
||||
buttons.redo.execute = function (manager) { if (manager) manager.redo(); };
|
||||
|
||||
if (helpOptions) {
|
||||
var group5 = makeGroup(5);
|
||||
group5.className = group5.className + " ml-auto";
|
||||
group5.className = group5.className + " ms-auto";
|
||||
var helpButton = document.createElement("button");
|
||||
var helpButtonImage = document.createElement("i");
|
||||
helpButtonImage.className = "m-1 fa fa-info";
|
||||
helpButtonImage.className = "m-1 fa-solid fa-info";
|
||||
helpButton.appendChild(helpButtonImage);
|
||||
helpButton.className = "btn btn-info btn-sm";
|
||||
helpButton.id = "wmd-help-button" + postfix;
|
||||
helpButton.isHelp = true;
|
||||
helpButton.setAttribute("data-toggle", "tooltip");
|
||||
helpButton.setAttribute("data-placement", "top");
|
||||
helpButton.setAttribute("data-bs-toggle", "tooltip");
|
||||
helpButton.setAttribute("data-bs-placement", "top");
|
||||
helpButton.title = helpOptions.title || defaultHelpHoverTitle;
|
||||
helpButton.onclick = helpOptions.handler;
|
||||
|
||||
@ -1793,7 +1793,7 @@
|
||||
//
|
||||
// Since this is essentially a backwards-moving regex, it's susceptible to
|
||||
// catastrophic backtracking and can cause the browser to hang;
|
||||
// see e.g. http://meta.stackoverflow.com/questions/9807.
|
||||
// see e.g. https://meta.stackoverflow.com/questions/9807.
|
||||
//
|
||||
// Hence we replaced this by a simple state machine that just goes through the
|
||||
// lines and checks for a), b), and c).
|
||||
|
@ -24,7 +24,7 @@ createPagedownEditor = function( selector, context ) {
|
||||
Markdown.Extra.init(converter);
|
||||
const help = {
|
||||
handler() {
|
||||
window.open('http://daringfireball.net/projects/markdown/syntax');
|
||||
window.open('https://daringfireball.net/projects/markdown/syntax');
|
||||
return false;
|
||||
},
|
||||
title: "<%= I18n.t('components.markdown_editor.help', default: 'Markdown Editing Help') %>"
|
||||
@ -32,7 +32,7 @@ createPagedownEditor = function( selector, context ) {
|
||||
|
||||
const editor = new Markdown.Editor(converter, attr, help);
|
||||
editor.run();
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
$('[data-bs-toggle="tooltip"]').tooltip();
|
||||
return $(input).data('is_rendered', true);
|
||||
});
|
||||
};
|
||||
|
@ -29,10 +29,14 @@ $(document).on('turbolinks:load', function () {
|
||||
};
|
||||
|
||||
const handleResponse = function (response) {
|
||||
// Always print stdout and stderr
|
||||
printOutput(response);
|
||||
|
||||
// If an error occurred, print it too
|
||||
if (response.status === 'timeout') {
|
||||
printTimeout(response);
|
||||
} else {
|
||||
printOutput(response);
|
||||
} else if (response.status === 'out_of_memory') {
|
||||
printOutOfMemory(response);
|
||||
}
|
||||
};
|
||||
|
||||
@ -71,12 +75,19 @@ $(document).on('turbolinks:load', function () {
|
||||
};
|
||||
|
||||
const printTimeout = function (output) {
|
||||
const element = $.append('<p>');
|
||||
const element = $('<p>');
|
||||
element.addClass('text-danger');
|
||||
element.text($('#shell').data('message-timeout'));
|
||||
$('#output').append(element);
|
||||
};
|
||||
|
||||
const printOutOfMemory = function (output) {
|
||||
const element = $('<p>');
|
||||
element.addClass('text-danger');
|
||||
element.text($('#shell').data('message-out-of-memory'));
|
||||
$('#output').append(element);
|
||||
};
|
||||
|
||||
if ($('#shell').isPresent()) {
|
||||
const command = $('#command')
|
||||
command.focus();
|
||||
|
@ -1,498 +0,0 @@
|
||||
$(document).on('turbolinks:load', function(){
|
||||
(function vendorTableSorter(){
|
||||
/*
|
||||
SortTable
|
||||
version 2
|
||||
7th April 2007
|
||||
Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/
|
||||
|
||||
Instructions:
|
||||
Download this file
|
||||
Add <script src="sorttable.js"></script> to your HTML
|
||||
Add class="sortable" to any table you'd like to make sortable
|
||||
Click on the headers to sort
|
||||
|
||||
Thanks to many, many people for contributions and suggestions.
|
||||
Licenced as X11: http://www.kryogenix.org/code/browser/licence.html
|
||||
This basically means: do what you want with it.
|
||||
*/
|
||||
|
||||
|
||||
var stIsIE = /*@cc_on!@*/false;
|
||||
|
||||
sorttable = {
|
||||
init: function() {
|
||||
// quit if this function has already been called
|
||||
if (arguments.callee.done) return;
|
||||
// flag this function so we don't do the same thing twice
|
||||
arguments.callee.done = true;
|
||||
// kill the timer
|
||||
if (_timer) clearInterval(_timer);
|
||||
|
||||
if (!document.createElement || !document.getElementsByTagName) return;
|
||||
|
||||
sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/;
|
||||
|
||||
forEach(document.getElementsByTagName('table'), function(table) {
|
||||
if (table.className.search(/\bsortable\b/) != -1) {
|
||||
sorttable.makeSortable(table);
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
makeSortable: function(table) {
|
||||
if (table.getElementsByTagName('thead').length == 0) {
|
||||
// table doesn't have a tHead. Since it should have, create one and
|
||||
// put the first table row in it.
|
||||
the = document.createElement('thead');
|
||||
the.appendChild(table.rows[0]);
|
||||
table.insertBefore(the,table.firstChild);
|
||||
}
|
||||
// Safari doesn't support table.tHead, sigh
|
||||
if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0];
|
||||
|
||||
if (table.tHead.rows.length != 1) return; // can't cope with two header rows
|
||||
|
||||
// Sorttable v1 put rows with a class of "sortbottom" at the bottom (as
|
||||
// "total" rows, for example). This is B&R, since what you're supposed
|
||||
// to do is put them in a tfoot. So, if there are sortbottom rows,
|
||||
// for backwards compatibility, move them to tfoot (creating it if needed).
|
||||
sortbottomrows = [];
|
||||
for (var i=0; i<table.rows.length; i++) {
|
||||
if (table.rows[i].className.search(/\bsortbottom\b/) != -1) {
|
||||
sortbottomrows[sortbottomrows.length] = table.rows[i];
|
||||
}
|
||||
}
|
||||
if (sortbottomrows) {
|
||||
if (table.tFoot == null) {
|
||||
// table doesn't have a tfoot. Create one.
|
||||
tfo = document.createElement('tfoot');
|
||||
table.appendChild(tfo);
|
||||
}
|
||||
for (var i=0; i<sortbottomrows.length; i++) {
|
||||
tfo.appendChild(sortbottomrows[i]);
|
||||
}
|
||||
delete sortbottomrows;
|
||||
}
|
||||
|
||||
// work through each column and calculate its type
|
||||
headrow = table.tHead.rows[0].cells;
|
||||
for (var i=0; i<headrow.length; i++) {
|
||||
// manually override the type with a sorttable_type attribute
|
||||
if (!headrow[i].className.match(/\bsorttable_nosort\b/)) { // skip this col
|
||||
mtch = headrow[i].className.match(/\bsorttable_([a-z0-9]+)\b/);
|
||||
if (mtch) { override = mtch[1]; }
|
||||
if (mtch && typeof sorttable["sort_"+override] == 'function') {
|
||||
headrow[i].sorttable_sortfunction = sorttable["sort_"+override];
|
||||
} else {
|
||||
headrow[i].sorttable_sortfunction = sorttable.guessType(table,i);
|
||||
}
|
||||
// make it clickable to sort
|
||||
headrow[i].sorttable_columnindex = i;
|
||||
headrow[i].sorttable_tbody = table.tBodies[0];
|
||||
dean_addEvent(headrow[i],"click", sorttable.innerSortFunction = function(e) {
|
||||
|
||||
if (this.className.search(/\bsorttable_sorted\b/) != -1) {
|
||||
// if we're already sorted by this column, just
|
||||
// reverse the table, which is quicker
|
||||
sorttable.reverse(this.sorttable_tbody);
|
||||
this.className = this.className.replace('sorttable_sorted',
|
||||
'sorttable_sorted_reverse');
|
||||
this.removeChild(document.getElementById('sorttable_sortfwdind'));
|
||||
sortrevind = document.createElement('span');
|
||||
sortrevind.id = "sorttable_sortrevind";
|
||||
sortrevind.innerHTML = stIsIE ? ' <font face="webdings">5</font>' : ' ▴';
|
||||
this.appendChild(sortrevind);
|
||||
return;
|
||||
}
|
||||
if (this.className.search(/\bsorttable_sorted_reverse\b/) != -1) {
|
||||
// if we're already sorted by this column in reverse, just
|
||||
// re-reverse the table, which is quicker
|
||||
sorttable.reverse(this.sorttable_tbody);
|
||||
this.className = this.className.replace('sorttable_sorted_reverse',
|
||||
'sorttable_sorted');
|
||||
this.removeChild(document.getElementById('sorttable_sortrevind'));
|
||||
sortfwdind = document.createElement('span');
|
||||
sortfwdind.id = "sorttable_sortfwdind";
|
||||
sortfwdind.innerHTML = stIsIE ? ' <font face="webdings">6</font>' : ' ▾';
|
||||
this.appendChild(sortfwdind);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove sorttable_sorted classes
|
||||
theadrow = this.parentNode;
|
||||
forEach(theadrow.childNodes, function(cell) {
|
||||
if (cell.nodeType == 1) { // an element
|
||||
cell.className = cell.className.replace('sorttable_sorted_reverse','');
|
||||
cell.className = cell.className.replace('sorttable_sorted','');
|
||||
}
|
||||
});
|
||||
sortfwdind = document.getElementById('sorttable_sortfwdind');
|
||||
if (sortfwdind) { sortfwdind.parentNode.removeChild(sortfwdind); }
|
||||
sortrevind = document.getElementById('sorttable_sortrevind');
|
||||
if (sortrevind) { sortrevind.parentNode.removeChild(sortrevind); }
|
||||
|
||||
this.className += ' sorttable_sorted';
|
||||
sortfwdind = document.createElement('span');
|
||||
sortfwdind.id = "sorttable_sortfwdind";
|
||||
sortfwdind.innerHTML = stIsIE ? ' <font face="webdings">6</font>' : ' ▾';
|
||||
this.appendChild(sortfwdind);
|
||||
|
||||
// build an array to sort. This is a Schwartzian transform thing,
|
||||
// i.e., we "decorate" each row with the actual sort key,
|
||||
// sort based on the sort keys, and then put the rows back in order
|
||||
// which is a lot faster because you only do getInnerText once per row
|
||||
row_array = [];
|
||||
col = this.sorttable_columnindex;
|
||||
rows = this.sorttable_tbody.rows;
|
||||
for (var j=0; j<rows.length; j++) {
|
||||
row_array[row_array.length] = [sorttable.getInnerText(rows[j].cells[col]), rows[j]];
|
||||
}
|
||||
/* If you want a stable sort, uncomment the following line */
|
||||
//sorttable.shaker_sort(row_array, this.sorttable_sortfunction);
|
||||
/* and comment out this one */
|
||||
row_array.sort(this.sorttable_sortfunction);
|
||||
|
||||
tb = this.sorttable_tbody;
|
||||
for (var j=0; j<row_array.length; j++) {
|
||||
tb.appendChild(row_array[j][1]);
|
||||
}
|
||||
|
||||
delete row_array;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
guessType: function(table, column) {
|
||||
// guess the type of a column based on its first non-blank row
|
||||
sortfn = sorttable.sort_alpha;
|
||||
for (var i=0; i<table.tBodies[0].rows.length; i++) {
|
||||
text = sorttable.getInnerText(table.tBodies[0].rows[i].cells[column]);
|
||||
if (text != '') {
|
||||
if (text.match(/^-?[£$¤]?[\d,.]+%?$/)) {
|
||||
return sorttable.sort_numeric;
|
||||
}
|
||||
// check for a date: dd/mm/yyyy or dd/mm/yy
|
||||
// can have / or . or - as separator
|
||||
// can be mm/dd as well
|
||||
possdate = text.match(sorttable.DATE_RE)
|
||||
if (possdate) {
|
||||
// looks like a date
|
||||
first = parseInt(possdate[1]);
|
||||
second = parseInt(possdate[2]);
|
||||
if (first > 12) {
|
||||
// definitely dd/mm
|
||||
return sorttable.sort_ddmm;
|
||||
} else if (second > 12) {
|
||||
return sorttable.sort_mmdd;
|
||||
} else {
|
||||
// looks like a date, but we can't tell which, so assume
|
||||
// that it's dd/mm (English imperialism!) and keep looking
|
||||
sortfn = sorttable.sort_ddmm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortfn;
|
||||
},
|
||||
|
||||
getInnerText: function(node) {
|
||||
// gets the text we want to use for sorting for a cell.
|
||||
// strips leading and trailing whitespace.
|
||||
// this is *not* a generic getInnerText function; it's special to sorttable.
|
||||
// for example, you can override the cell text with a customkey attribute.
|
||||
// it also gets .value for <input> fields.
|
||||
|
||||
if (!node) return "";
|
||||
|
||||
hasInputs = (typeof node.getElementsByTagName == 'function') &&
|
||||
node.getElementsByTagName('input').length;
|
||||
|
||||
if (node.getAttribute("sorttable_customkey") != null) {
|
||||
return node.getAttribute("sorttable_customkey");
|
||||
}
|
||||
else if (typeof node.textContent != 'undefined' && !hasInputs) {
|
||||
return node.textContent.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else if (typeof node.innerText != 'undefined' && !hasInputs) {
|
||||
return node.innerText.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else if (typeof node.text != 'undefined' && !hasInputs) {
|
||||
return node.text.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else {
|
||||
switch (node.nodeType) {
|
||||
case 3:
|
||||
if (node.nodeName.toLowerCase() == 'input') {
|
||||
return node.value.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
case 4:
|
||||
return node.nodeValue.replace(/^\s+|\s+$/g, '');
|
||||
break;
|
||||
case 1:
|
||||
case 11:
|
||||
var innerText = '';
|
||||
for (var i = 0; i < node.childNodes.length; i++) {
|
||||
innerText += sorttable.getInnerText(node.childNodes[i]);
|
||||
}
|
||||
return innerText.replace(/^\s+|\s+$/g, '');
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
reverse: function(tbody) {
|
||||
// reverse the rows in a tbody
|
||||
newrows = [];
|
||||
for (var i=0; i<tbody.rows.length; i++) {
|
||||
newrows[newrows.length] = tbody.rows[i];
|
||||
}
|
||||
for (var i=newrows.length-1; i>=0; i--) {
|
||||
tbody.appendChild(newrows[i]);
|
||||
}
|
||||
delete newrows;
|
||||
},
|
||||
|
||||
/* sort functions
|
||||
each sort function takes two parameters, a and b
|
||||
you are comparing a[0] and b[0] */
|
||||
sort_numeric: function(a,b) {
|
||||
aa = parseFloat(a[0].replace(/[^0-9.-]/g,''));
|
||||
if (isNaN(aa)) aa = 0;
|
||||
bb = parseFloat(b[0].replace(/[^0-9.-]/g,''));
|
||||
if (isNaN(bb)) bb = 0;
|
||||
return aa-bb;
|
||||
},
|
||||
sort_alpha: function(a,b) {
|
||||
if (a[0]==b[0]) return 0;
|
||||
if (a[0]<b[0]) return -1;
|
||||
return 1;
|
||||
},
|
||||
sort_ddmm: function(a,b) {
|
||||
mtch = a[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; m = mtch[2]; d = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt1 = y+m+d;
|
||||
mtch = b[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; m = mtch[2]; d = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt2 = y+m+d;
|
||||
if (dt1==dt2) return 0;
|
||||
if (dt1<dt2) return -1;
|
||||
return 1;
|
||||
},
|
||||
sort_mmdd: function(a,b) {
|
||||
mtch = a[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; d = mtch[2]; m = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt1 = y+m+d;
|
||||
mtch = b[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; d = mtch[2]; m = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt2 = y+m+d;
|
||||
if (dt1==dt2) return 0;
|
||||
if (dt1<dt2) return -1;
|
||||
return 1;
|
||||
},
|
||||
|
||||
shaker_sort: function(list, comp_func) {
|
||||
// A stable sort function to allow multi-level sorting of data
|
||||
// see: http://en.wikipedia.org/wiki/Cocktail_sort
|
||||
// thanks to Joseph Nahmias
|
||||
var b = 0;
|
||||
var t = list.length - 1;
|
||||
var swap = true;
|
||||
|
||||
while(swap) {
|
||||
swap = false;
|
||||
for(var i = b; i < t; ++i) {
|
||||
if ( comp_func(list[i], list[i+1]) > 0 ) {
|
||||
var q = list[i]; list[i] = list[i+1]; list[i+1] = q;
|
||||
swap = true;
|
||||
}
|
||||
} // for
|
||||
t--;
|
||||
|
||||
if (!swap) break;
|
||||
|
||||
for(var i = t; i > b; --i) {
|
||||
if ( comp_func(list[i], list[i-1]) < 0 ) {
|
||||
var q = list[i]; list[i] = list[i-1]; list[i-1] = q;
|
||||
swap = true;
|
||||
}
|
||||
} // for
|
||||
b++;
|
||||
|
||||
} // while(swap)
|
||||
}
|
||||
}
|
||||
|
||||
/* ******************************************************************
|
||||
Supporting functions: bundled here to avoid depending on a library
|
||||
****************************************************************** */
|
||||
|
||||
// Dean Edwards/Matthias Miller/John Resig
|
||||
|
||||
/* for Mozilla/Opera9 */
|
||||
if (document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", sorttable.init, false);
|
||||
}
|
||||
|
||||
/* for Internet Explorer */
|
||||
/*@cc_on @*/
|
||||
/*@if (@_win32)
|
||||
document.write("<script id=__ie_onload defer src=javascript:void(0)><\/script>");
|
||||
var script = document.getElementById("__ie_onload");
|
||||
script.onreadystatechange = function() {
|
||||
if (this.readyState == "complete") {
|
||||
sorttable.init(); // call the onload handler
|
||||
}
|
||||
};
|
||||
/*@end @*/
|
||||
|
||||
/* for Safari */
|
||||
if (/WebKit/i.test(navigator.userAgent)) { // sniff
|
||||
var _timer = setInterval(function() {
|
||||
if (/loaded|complete/.test(document.readyState)) {
|
||||
sorttable.init(); // call the onload handler
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/* for other browsers */
|
||||
window.onload = sorttable.init;
|
||||
|
||||
// written by Dean Edwards, 2005
|
||||
// with input from Tino Zijdel, Matthias Miller, Diego Perini
|
||||
|
||||
// http://dean.edwards.name/weblog/2005/10/add-event/
|
||||
|
||||
function dean_addEvent(element, type, handler) {
|
||||
if (element.addEventListener) {
|
||||
element.addEventListener(type, handler, false);
|
||||
} else {
|
||||
// assign each event handler a unique ID
|
||||
if (!handler.$$guid) handler.$$guid = dean_addEvent.guid++;
|
||||
// create a hash table of event types for the element
|
||||
if (!element.events) element.events = {};
|
||||
// create a hash table of event handlers for each element/event pair
|
||||
var handlers = element.events[type];
|
||||
if (!handlers) {
|
||||
handlers = element.events[type] = {};
|
||||
// store the existing event handler (if there is one)
|
||||
if (element["on" + type]) {
|
||||
handlers[0] = element["on" + type];
|
||||
}
|
||||
}
|
||||
// store the event handler in the hash table
|
||||
handlers[handler.$$guid] = handler;
|
||||
// assign a global event handler to do all the work
|
||||
element["on" + type] = handleEvent;
|
||||
}
|
||||
};
|
||||
// a counter used to create unique IDs
|
||||
dean_addEvent.guid = 1;
|
||||
|
||||
function removeEvent(element, type, handler) {
|
||||
if (element.removeEventListener) {
|
||||
element.removeEventListener(type, handler, false);
|
||||
} else {
|
||||
// delete the event handler from the hash table
|
||||
if (element.events && element.events[type]) {
|
||||
delete element.events[type][handler.$$guid];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleEvent(event) {
|
||||
var returnValue = true;
|
||||
// grab the event object (IE uses a global event object)
|
||||
event = event || fixEvent(((this.ownerDocument || this.document || this).parentWindow || window).event);
|
||||
// get a reference to the hash table of event handlers
|
||||
var handlers = this.events[event.type];
|
||||
// execute each event handler
|
||||
for (var i in handlers) {
|
||||
this.$$handleEvent = handlers[i];
|
||||
if (this.$$handleEvent(event) === false) {
|
||||
returnValue = false;
|
||||
}
|
||||
}
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
function fixEvent(event) {
|
||||
// add W3C standard event methods
|
||||
event.preventDefault = fixEvent.preventDefault;
|
||||
event.stopPropagation = fixEvent.stopPropagation;
|
||||
return event;
|
||||
};
|
||||
fixEvent.preventDefault = function() {
|
||||
this.returnValue = false;
|
||||
};
|
||||
fixEvent.stopPropagation = function() {
|
||||
this.cancelBubble = true;
|
||||
}
|
||||
|
||||
// Dean's forEach: http://dean.edwards.name/base/forEach.js
|
||||
/*
|
||||
forEach, version 1.0
|
||||
Copyright 2006, Dean Edwards
|
||||
License: http://www.opensource.org/licenses/mit-license.php
|
||||
*/
|
||||
|
||||
// array-like enumeration
|
||||
if (!Array.forEach) { // mozilla already supports this
|
||||
Array.forEach = function(array, block, context) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
block.call(context, array[i], i, array);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// generic enumeration
|
||||
Function.prototype.forEach = function(object, block, context) {
|
||||
for (var key in object) {
|
||||
if (typeof this.prototype[key] == "undefined") {
|
||||
block.call(context, object[key], key, object);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// character enumeration
|
||||
String.forEach = function(string, block, context) {
|
||||
Array.forEach(string.split(""), function(chr, index) {
|
||||
block.call(context, chr, index, string);
|
||||
});
|
||||
};
|
||||
|
||||
// globally resolve forEach enumeration
|
||||
var forEach = function(object, block, context) {
|
||||
if (object) {
|
||||
var resolve = Object; // default
|
||||
if (object instanceof Function) {
|
||||
// functions have a "length" property
|
||||
resolve = Function;
|
||||
} else if (object.forEach instanceof Function) {
|
||||
// the object implements a custom forEach method so use that
|
||||
object.forEach(block, context);
|
||||
return;
|
||||
} else if (typeof object == "string") {
|
||||
// the object is a string
|
||||
resolve = String;
|
||||
} else if (typeof object.length == "number") {
|
||||
// the object is array-like
|
||||
resolve = Array;
|
||||
}
|
||||
resolve.forEach(object, block, context);
|
||||
}
|
||||
};
|
||||
}());
|
||||
});
|
@ -10,6 +10,8 @@ $(document).on('turbolinks:load', function() {
|
||||
var fileTypeById = {};
|
||||
|
||||
var showActiveFile = function() {
|
||||
$('tr.active').removeClass('active');
|
||||
$('tr#submission-' + currentSubmission).addClass('active');
|
||||
var session = editor.getSession();
|
||||
var fileType = fileTypeById[active_file.file_type_id];
|
||||
session.setMode(fileType.editor_mode);
|
||||
@ -81,6 +83,7 @@ $(document).on('turbolinks:load', function() {
|
||||
|
||||
$('tr[data-id]>.clickable').each(function(index, element) {
|
||||
element = $(element);
|
||||
element.parent().attr('id', 'submission-' + index);
|
||||
element.click(function() {
|
||||
slider.val(index);
|
||||
slider.change()
|
||||
@ -105,7 +108,7 @@ $(document).on('turbolinks:load', function() {
|
||||
stopReplay = function() {
|
||||
clearInterval(playInterval);
|
||||
playInterval = undefined;
|
||||
playButton.find('span.fa').removeClass('fa-pause').addClass('fa-play')
|
||||
playButton.find('span.fa-solid').removeClass('fa-pause').addClass('fa-play')
|
||||
};
|
||||
|
||||
playButton.on('click', function(event) {
|
||||
@ -124,7 +127,7 @@ $(document).on('turbolinks:load', function() {
|
||||
stopReplay();
|
||||
}
|
||||
}, 1000);
|
||||
playButton.find('span.fa').removeClass('fa-play').addClass('fa-pause')
|
||||
playButton.find('span.fa-solid').removeClass('fa-play').addClass('fa-pause')
|
||||
} else {
|
||||
stopReplay();
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ $(document).on('turbolinks:load', function() {
|
||||
|
||||
if ($.isController('exercises') && $('.working-time-graphs').isPresent()) {
|
||||
var working_times = $('#data').data('working-time');
|
||||
|
||||
|
||||
function get_minutes (timestamp){
|
||||
try{
|
||||
hours = timestamp.split(":")[0];
|
||||
@ -160,7 +160,6 @@ $(document).on('turbolinks:load', function() {
|
||||
groupRanges += groupWidth;
|
||||
}
|
||||
while (groupRanges < maximum_minutes);
|
||||
console.log(maximum_minutes);
|
||||
|
||||
var clusterCount = 0,
|
||||
sum = 0,
|
||||
|
@ -12,7 +12,16 @@ h1, h2, h3, h4, h5, h6 {
|
||||
color: rgba(70, 70, 70, 1);
|
||||
}
|
||||
|
||||
i.fa, i.far, i.fas {
|
||||
a:not(.dropdown-item, .dropdown-toggle, .dropdown-link, .btn, .page-link), .btn-link {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
i.fa-solid, i.fa-regular, i.fa-solid {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
@ -35,6 +44,10 @@ span.caret {
|
||||
}
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
--bs-btn-disabled-border-color: transparent;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 0;
|
||||
border: 1px solid #CCCCCC;
|
||||
@ -51,13 +64,17 @@ span.caret {
|
||||
.navbar {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
.dropdown-item {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-row + .attribute-row {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.badge-pill {
|
||||
.rounded-pill {
|
||||
font-size: 100%;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
@ -29,11 +29,11 @@
|
||||
border-left-color: #ffffff;
|
||||
}
|
||||
|
||||
.dropdown-submenu.float-left {
|
||||
.dropdown-submenu.float-start {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.dropdown-submenu.float-left > .dropdown-menu {
|
||||
.dropdown-submenu.float-start > .dropdown-menu {
|
||||
left: -100%;
|
||||
margin-left: 10px;
|
||||
-webkit-border-radius: 6px 0 6px 6px;
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Place all the styles related to the Comments controller here.
|
||||
// They will automatically be included in application.css.
|
||||
// You can use Sass (SCSS) here: http://sass-lang.com/
|
||||
// You can use Sass (SCSS) here: https://sass-lang.com/
|
||||
|
||||
.ace_gutter-cell.code-ocean_comment {
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3wIUDTMn077U+gAAANhJREFUOMvV0jFOQkEURuEPfCFKaChwASQWhsIOaxYgW7Ci0c4VuBBM6GmsWIC2JsbCykJiRSD0GMVHMy+ZTPSBlfHvZu7NyZ1zh79O5Zu7Bi7RRz30TDHC7TbACB1UkSe1Kt5xg2FxuRc1jHFcMm0e+k/xgceCCmc4wtcOz17jCs0YMMDnL9ytcBgDVj8ILZO/jgHXiY9t2cdbLHGBFk4KcklqYcXLdAt3wXQ3kVlBhgM84AKvRTFL6O0gM8MTZuH8ggnmZaP18Ix7nO8qI0t+Wi/4yP2bbAA4rSby6Nz4AwAAAABJRU5ErkJggg==");
|
||||
|
@ -1,7 +1,3 @@
|
||||
button i.fa-spin {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@ -48,10 +44,6 @@ button i.fa-spin {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
#development-environment {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#dummy {
|
||||
display: none;
|
||||
}
|
||||
@ -203,8 +195,8 @@ button i.fa-spin {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.enforce-big-top-margin {
|
||||
margin-top: 15px !important;
|
||||
.enforce-big-bottom-margin {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
|
||||
.enforce-bottom-margin {
|
||||
|
@ -40,11 +40,11 @@ input[type='file'] {
|
||||
}
|
||||
}
|
||||
|
||||
[data-toggle="collapse"] .fa:before {
|
||||
[data-bs-toggle="collapse"] .fa-solid:before {
|
||||
content: "\f139";
|
||||
}
|
||||
|
||||
[data-toggle="collapse"].collapsed .fa:before {
|
||||
[data-bs-toggle="collapse"].collapsed .fa-solid:before {
|
||||
content: "\f13a";
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,3 @@
|
||||
// Place all the styles related to the FileTemplates controller here.
|
||||
// They will automatically be included in application.css.
|
||||
// You can use Sass (SCSS) here: http://sass-lang.com/
|
||||
// You can use Sass (SCSS) here: https://sass-lang.com/
|
||||
|
@ -23,12 +23,6 @@
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
&:not(:last-child) {
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
input, select {
|
||||
min-width: 200px !important;
|
||||
}
|
||||
|
@ -58,6 +58,15 @@ div.negative-result {
|
||||
box-shadow: 0px 0px 11px 1px rgba(222,0,0,1);
|
||||
}
|
||||
|
||||
tr.active {
|
||||
filter: brightness(85%);
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
tr:not(.before_deadline,.within_grace_period,.after_late_deadline) {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
tr.highlight {
|
||||
border-top: 2px solid rgba(222,0,0,1);
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ class LaExercisesChannel < ApplicationCable::Channel
|
||||
private
|
||||
|
||||
def specific_channel
|
||||
reject unless StudyGroupPolicy.new(current_user, StudyGroup.find_by(id: params[:study_group_id])).stream_la?
|
||||
reject unless StudyGroupPolicy.new(current_user, StudyGroup.find(params[:study_group_id])).stream_la?
|
||||
"la_exercises_#{params[:exercise_id]}_channel_study_group_#{params[:study_group_id]}"
|
||||
end
|
||||
end
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
class ApplicationController < ActionController::Base
|
||||
include ApplicationHelper
|
||||
include Pundit
|
||||
include Pundit::Authorization
|
||||
|
||||
MEMBER_ACTIONS = %i[destroy edit show update].freeze
|
||||
|
||||
@ -15,9 +15,11 @@ class ApplicationController < ActionController::Base
|
||||
rescue_from ActionController::InvalidAuthenticityToken, with: :render_csrf_error
|
||||
|
||||
def current_user
|
||||
::NewRelic::Agent.add_custom_attributes(external_user_id: session[:external_user_id],
|
||||
session_user_id: session[:user_id])
|
||||
@current_user ||= ExternalUser.find_by(id: session[:external_user_id]) || login_from_session || login_from_other_sources || nil
|
||||
@current_user ||= ExternalUser.find_by(id: session[:external_user_id]) ||
|
||||
login_from_session ||
|
||||
login_from_other_sources ||
|
||||
login_from_authentication_token ||
|
||||
nil
|
||||
end
|
||||
|
||||
def require_user!
|
||||
@ -34,6 +36,13 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
end
|
||||
|
||||
def login_from_authentication_token
|
||||
token = AuthenticationToken.find_by(shared_secret: params[:token])
|
||||
return unless token
|
||||
|
||||
auto_login(token.user) if token.expire_at.future?
|
||||
end
|
||||
|
||||
def set_sentry_context
|
||||
return if current_user.blank?
|
||||
|
||||
@ -73,7 +82,7 @@ class ApplicationController < ActionController::Base
|
||||
private :render_error
|
||||
|
||||
def switch_locale(&action)
|
||||
session[:locale] = params[:custom_locale] || params[:locale] || session[:locale]
|
||||
session[:locale] = sanitize_locale(params[:custom_locale] || params[:locale] || session[:locale])
|
||||
locale = session[:locale] || I18n.default_locale
|
||||
Sentry.set_extras(locale: locale)
|
||||
I18n.with_locale(locale, &action)
|
||||
@ -98,4 +107,18 @@ class ApplicationController < ActionController::Base
|
||||
@embed_options
|
||||
end
|
||||
private :load_embed_options
|
||||
|
||||
# Sanitize given locale.
|
||||
#
|
||||
# Return `nil` if the locale is blank or not available.
|
||||
#
|
||||
def sanitize_locale(locale)
|
||||
return if locale.blank?
|
||||
|
||||
locale = locale.downcase.to_sym
|
||||
return unless I18n.available_locales.include?(locale)
|
||||
|
||||
locale
|
||||
end
|
||||
private :sanitize_locale
|
||||
end
|
||||
|
@ -28,7 +28,7 @@ module CodeOcean
|
||||
yield if block_given?
|
||||
path = options[:path].try(:call) || @object
|
||||
respond_with_valid_object(format, notice: t('shared.object_created', model: @object.class.model_name.human),
|
||||
path: path, status: :created)
|
||||
path: path, status: :created)
|
||||
else
|
||||
filename = "#{@object.path || ''}/#{@object.name || ''}#{@object.file_type.try(:file_extension) || ''}"
|
||||
format.html do
|
||||
|
@ -44,7 +44,6 @@ class CodeharborLinksController < ApplicationController
|
||||
|
||||
def set_codeharbor_link
|
||||
@codeharbor_link = CodeharborLink.find(params[:id])
|
||||
@codeharbor_link.user = current_user
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -3,9 +3,6 @@
|
||||
class CommentsController < ApplicationController
|
||||
before_action :set_comment, only: %i[show update destroy]
|
||||
|
||||
# to disable authorization check: comment the line below back in
|
||||
# skip_after_action :verify_authorized
|
||||
|
||||
def authorize!
|
||||
authorize(@comment || @comments)
|
||||
end
|
||||
@ -55,7 +52,7 @@ class CommentsController < ApplicationController
|
||||
|
||||
# PATCH/PUT /comments/1.json
|
||||
def update
|
||||
if @comment.update(comment_params_without_request_id)
|
||||
if @comment.update(comment_params_for_update)
|
||||
render :show, status: :ok, location: @comment
|
||||
else
|
||||
render json: @comment.errors, status: :unprocessable_entity
|
||||
@ -77,6 +74,10 @@ class CommentsController < ApplicationController
|
||||
@comment = Comment.find(params[:id])
|
||||
end
|
||||
|
||||
def comment_params_for_update
|
||||
params.require(:comment).permit(:text)
|
||||
end
|
||||
|
||||
def comment_params_without_request_id
|
||||
comment_params.except :request_id
|
||||
end
|
||||
|
@ -11,7 +11,7 @@ class CommunitySolutionsController < ApplicationController
|
||||
|
||||
# GET /community_solutions
|
||||
def index
|
||||
@community_solutions = CommunitySolution.all
|
||||
@community_solutions = CommunitySolution.all.paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
@ -85,7 +85,7 @@ class CommunitySolutionsController < ApplicationController
|
||||
private
|
||||
|
||||
def authorize!
|
||||
authorize(@community_solution)
|
||||
authorize(@community_solution || @community_solutions)
|
||||
end
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
|
@ -21,7 +21,7 @@ module Lti
|
||||
# exercise_id.nil? ==> the user has logged out. All session data is to be destroyed
|
||||
# exercise_id.exists? ==> the user has submitted the results of an exercise to the consumer.
|
||||
# Only the lti_parameters are deleted.
|
||||
def clear_lti_session_data(exercise_id = nil, user_id = nil)
|
||||
def clear_lti_session_data(exercise_id = nil, _user_id = nil)
|
||||
if exercise_id.nil?
|
||||
session.delete(:external_user_id)
|
||||
session.delete(:study_group_id)
|
||||
@ -29,8 +29,10 @@ module Lti
|
||||
session.delete(:lti_exercise_id)
|
||||
session.delete(:lti_parameters_id)
|
||||
end
|
||||
LtiParameter.where(external_users_id: user_id,
|
||||
exercises_id: exercise_id).destroy_all
|
||||
|
||||
# March 2022: We temporarily allow reusing the LTI credentials and don't remove them on purpose.
|
||||
# This allows users to jump between remote and web evaluation with the same behavior.
|
||||
# LtiParameter.where(external_users_id: user_id, exercises_id: exercise_id).destroy_all
|
||||
end
|
||||
|
||||
private :clear_lti_session_data
|
||||
@ -136,7 +138,6 @@ module Lti
|
||||
private :return_to_consumer
|
||||
|
||||
def send_score(submission)
|
||||
::NewRelic::Agent.add_custom_attributes({score: submission.normalized_score, session: session})
|
||||
unless (0..MAXIMUM_SCORE).cover?(submission.normalized_score)
|
||||
raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!")
|
||||
end
|
||||
|
@ -129,12 +129,7 @@ module RedirectBehavior
|
||||
lti_parameters_id: session[:lti_parameters_id]
|
||||
)
|
||||
|
||||
lti_parameter = LtiParameter.where(external_users_id: @submission.user_id,
|
||||
exercises_id: @submission.exercise_id).last
|
||||
|
||||
path = lti_return_path(submission_id: @submission.id,
|
||||
url: consumer_return_url(build_tool_provider(consumer: @submission.user.consumer,
|
||||
parameters: lti_parameter&.lti_parameters)))
|
||||
path = lti_return_path(submission_id: @submission.id)
|
||||
clear_lti_session_data(@submission.exercise_id, @submission.user_id)
|
||||
respond_to do |format|
|
||||
format.html { redirect_to(path) }
|
||||
|
@ -28,7 +28,7 @@ class ConsumersController < ApplicationController
|
||||
private :consumer_params
|
||||
|
||||
def index
|
||||
@consumers = Consumer.paginate(page: params[:page])
|
||||
@consumers = Consumer.paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -12,7 +12,7 @@ class ErrorTemplateAttributesController < ApplicationController
|
||||
# GET /error_template_attributes.json
|
||||
def index
|
||||
@error_template_attributes = ErrorTemplateAttribute.all.order('important DESC', :key,
|
||||
:id).paginate(page: params[:page])
|
||||
:id).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
@ -42,7 +42,7 @@ class ErrorTemplateAttributesController < ApplicationController
|
||||
respond_to do |format|
|
||||
if @error_template_attribute.save
|
||||
format.html do
|
||||
redirect_to @error_template_attribute, notice: 'Error template attribute was successfully created.'
|
||||
redirect_to @error_template_attribute, notice: t('shared.object_created', model: @error_template_attribute.class.model_name.human)
|
||||
end
|
||||
format.json { render :show, status: :created, location: @error_template_attribute }
|
||||
else
|
||||
@ -59,7 +59,7 @@ class ErrorTemplateAttributesController < ApplicationController
|
||||
respond_to do |format|
|
||||
if @error_template_attribute.update(error_template_attribute_params)
|
||||
format.html do
|
||||
redirect_to @error_template_attribute, notice: 'Error template attribute was successfully updated.'
|
||||
redirect_to @error_template_attribute, notice: t('shared.object_updated', model: @error_template_attribute.class.model_name.human)
|
||||
end
|
||||
format.json { render :show, status: :ok, location: @error_template_attribute }
|
||||
else
|
||||
@ -76,7 +76,7 @@ class ErrorTemplateAttributesController < ApplicationController
|
||||
@error_template_attribute.destroy
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_to error_template_attributes_url, notice: 'Error template attribute was successfully destroyed.'
|
||||
redirect_to error_template_attributes_url, notice: t('shared.object_destroyed', model: @error_template_attribute.class.model_name.human)
|
||||
end
|
||||
format.json { head :no_content }
|
||||
end
|
||||
|
@ -11,7 +11,7 @@ class ErrorTemplatesController < ApplicationController
|
||||
# GET /error_templates
|
||||
# GET /error_templates.json
|
||||
def index
|
||||
@error_templates = ErrorTemplate.all.order(:execution_environment_id, :name).paginate(page: params[:page])
|
||||
@error_templates = ErrorTemplate.all.order(:execution_environment_id, :name).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
@ -40,7 +40,7 @@ class ErrorTemplatesController < ApplicationController
|
||||
|
||||
respond_to do |format|
|
||||
if @error_template.save
|
||||
format.html { redirect_to @error_template, notice: 'Error template was successfully created.' }
|
||||
format.html { redirect_to @error_template, notice: t('shared.object_created', model: @error_template.class.model_name.human) }
|
||||
format.json { render :show, status: :created, location: @error_template }
|
||||
else
|
||||
format.html { render :new }
|
||||
@ -55,7 +55,7 @@ class ErrorTemplatesController < ApplicationController
|
||||
authorize!
|
||||
respond_to do |format|
|
||||
if @error_template.update(error_template_params)
|
||||
format.html { redirect_to @error_template, notice: 'Error template was successfully updated.' }
|
||||
format.html { redirect_to @error_template, notice: t('shared.object_updated', model: @error_template.class.model_name.human) }
|
||||
format.json { render :show, status: :ok, location: @error_template }
|
||||
else
|
||||
format.html { render :edit }
|
||||
@ -70,14 +70,14 @@ class ErrorTemplatesController < ApplicationController
|
||||
authorize!
|
||||
@error_template.destroy
|
||||
respond_to do |format|
|
||||
format.html { redirect_to error_templates_url, notice: 'Error template was successfully destroyed.' }
|
||||
format.html { redirect_to error_templates_url, notice: t('shared.object_destroyed', model: @error_template.class.model_name.human) }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
def add_attribute
|
||||
authorize!
|
||||
@error_template.error_template_attributes << ErrorTemplateAttribute.find(params['error_template_attribute_id'])
|
||||
@error_template.error_template_attributes << ErrorTemplateAttribute.find(params[:error_template_attribute_id])
|
||||
respond_to do |format|
|
||||
format.html { redirect_to @error_template }
|
||||
format.json { head :no_content }
|
||||
@ -86,7 +86,7 @@ class ErrorTemplatesController < ApplicationController
|
||||
|
||||
def remove_attribute
|
||||
authorize!
|
||||
@error_template.error_template_attributes.delete(ErrorTemplateAttribute.find(params['error_template_attribute_id']))
|
||||
@error_template.error_template_attributes.delete(ErrorTemplateAttribute.find(params[:error_template_attribute_id]))
|
||||
respond_to do |format|
|
||||
format.html { redirect_to @error_template }
|
||||
format.json { head :no_content }
|
||||
|
@ -30,7 +30,7 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
def execute_command
|
||||
runner = Runner.for(current_user, @execution_environment)
|
||||
output = runner.execute_command(params[:command], raise_exception: false)
|
||||
render json: output
|
||||
render json: output.except(:messages)
|
||||
end
|
||||
|
||||
def working_time_query
|
||||
@ -44,7 +44,7 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
|
||||
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
@ -121,7 +121,7 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
private :execution_environment_params
|
||||
|
||||
def index
|
||||
@execution_environments = ExecutionEnvironment.all.includes(:user).order(:name).paginate(page: params[:page])
|
||||
@execution_environments = ExecutionEnvironment.all.includes(:user).order(:name).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
@ -158,7 +158,7 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
|
||||
def show
|
||||
if @execution_environment.testing_framework?
|
||||
@testing_framework_adapter = Kernel.const_get(@execution_environment.testing_framework)
|
||||
@testing_framework_adapter = TestingFrameworkAdapter.descendants.find {|klass| klass.name == @execution_environment.testing_framework }
|
||||
end
|
||||
end
|
||||
|
||||
@ -172,8 +172,7 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
begin
|
||||
Runner.strategy_class.sync_environment(@execution_environment)
|
||||
rescue Runner::Error => e
|
||||
Rails.logger.debug { "Runner error while synchronizing execution environment with id #{@execution_environment.id}: #{e.message}" }
|
||||
Sentry.capture_exception(e)
|
||||
Rails.logger.warn { "Runner error while synchronizing execution environment with id #{@execution_environment.id}: #{e.message}" }
|
||||
redirect_to @execution_environment, alert: t('execution_environments.index.synchronize.failure', error: e.message)
|
||||
else
|
||||
redirect_to @execution_environment, notice: t('execution_environments.index.synchronize.success')
|
||||
|
@ -6,7 +6,7 @@ class ExerciseCollectionsController < ApplicationController
|
||||
before_action :set_exercise_collection, only: %i[show edit update destroy statistics]
|
||||
|
||||
def index
|
||||
@exercise_collections = ExerciseCollection.all.paginate(page: params[:page])
|
||||
@exercise_collections = ExerciseCollection.all.paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -11,21 +11,21 @@ class ExercisesController < ApplicationController
|
||||
before_action :set_execution_environments, only: %i[index create edit new update]
|
||||
before_action :set_exercise_and_authorize,
|
||||
only: MEMBER_ACTIONS + %i[clone implement working_times intervention search run statistics submit reload feedback
|
||||
requests_for_comments study_group_dashboard export_external_check export_external_confirm]
|
||||
before_action :set_external_user_and_authorize, only: [:statistics]
|
||||
requests_for_comments study_group_dashboard export_external_check export_external_confirm
|
||||
external_user_statistics]
|
||||
before_action :set_external_user_and_authorize, only: [:external_user_statistics]
|
||||
before_action :set_file_types, only: %i[create edit new update]
|
||||
before_action :set_course_token, only: [:implement]
|
||||
before_action :set_available_tips, only: %i[implement show new edit]
|
||||
|
||||
skip_before_action :verify_authenticity_token,
|
||||
only: %i[import_exercise import_uuid_check export_external_confirm export_external_check]
|
||||
skip_after_action :verify_authorized, only: %i[import_exercise import_uuid_check export_external_confirm]
|
||||
skip_after_action :verify_policy_scoped, only: %i[import_exercise import_uuid_check export_external_confirm],
|
||||
raise: false
|
||||
skip_before_action :verify_authenticity_token, only: %i[import_exercise import_uuid_check]
|
||||
skip_after_action :verify_authorized, only: %i[import_exercise import_uuid_check]
|
||||
skip_after_action :verify_policy_scoped, only: %i[import_exercise import_uuid_check], raise: false
|
||||
|
||||
def authorize!
|
||||
authorize(@exercise || @exercises)
|
||||
end
|
||||
|
||||
private :authorize!
|
||||
|
||||
def max_intervention_count_per_day
|
||||
@ -51,7 +51,7 @@ raise: false
|
||||
exercise = @exercise.duplicate(public: false, token: nil, user: current_user)
|
||||
exercise.send(:generate_token)
|
||||
if exercise.save
|
||||
redirect_to(exercise, notice: t('shared.object_cloned', model: Exercise.model_name.human))
|
||||
redirect_to(exercise_path(exercise), notice: t('shared.object_cloned', model: Exercise.model_name.human))
|
||||
else
|
||||
flash[:danger] = t('shared.message_failure')
|
||||
redirect_to(@exercise)
|
||||
@ -67,6 +67,7 @@ raise: false
|
||||
end
|
||||
subpaths.flatten.uniq
|
||||
end
|
||||
|
||||
private :collect_paths
|
||||
|
||||
def create
|
||||
@ -103,7 +104,7 @@ raise: false
|
||||
|
||||
def feedback
|
||||
authorize!
|
||||
@feedbacks = @exercise.user_exercise_feedbacks.paginate(page: params[:page])
|
||||
@feedbacks = @exercise.user_exercise_feedbacks.paginate(page: params[:page], per_page: per_page_param)
|
||||
@submissions = @feedbacks.map do |feedback|
|
||||
feedback.exercise.final_submission(feedback.user)
|
||||
end
|
||||
@ -128,6 +129,7 @@ raise: false
|
||||
end
|
||||
|
||||
def export_external_confirm
|
||||
authorize!
|
||||
@exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil?
|
||||
|
||||
error = ExerciseService::PushExternal.call(
|
||||
@ -176,7 +178,7 @@ raise: false
|
||||
ActiveRecord::Base.transaction do
|
||||
exercise = ::ProformaService::Import.call(zip: tempfile, user: user)
|
||||
exercise.save!
|
||||
return render json: {}, status: :created
|
||||
render json: {}, status: :created
|
||||
end
|
||||
rescue Proforma::ExerciseNotOwned
|
||||
render json: {}, status: :unauthorized
|
||||
@ -192,12 +194,14 @@ raise: false
|
||||
api_key = authorization_header&.split(' ')&.second
|
||||
user_by_codeharbor_token(api_key)
|
||||
end
|
||||
|
||||
private :user_from_api_key
|
||||
|
||||
def user_by_codeharbor_token(api_key)
|
||||
link = CodeharborLink.find_by(api_key: api_key)
|
||||
link&.user
|
||||
end
|
||||
|
||||
private :user_by_codeharbor_token
|
||||
|
||||
def exercise_params
|
||||
@ -225,6 +229,7 @@ raise: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private :exercise_params
|
||||
|
||||
def handle_file_uploads
|
||||
@ -241,6 +246,7 @@ raise: false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private :handle_file_uploads
|
||||
|
||||
def handle_exercise_tips
|
||||
@ -258,6 +264,7 @@ raise: false
|
||||
redirect_to(edit_exercise_path(@exercise))
|
||||
end
|
||||
end
|
||||
|
||||
private :handle_exercise_tips
|
||||
|
||||
def update_exercise_tips(exercise_tips, parent_exercise_tip_id, rank)
|
||||
@ -283,6 +290,7 @@ raise: false
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
private :update_exercise_tips
|
||||
|
||||
def implement
|
||||
@ -348,6 +356,7 @@ raise: false
|
||||
@course_token = '702cbd2a-c84c-4b37-923a-692d7d1532d0'
|
||||
end
|
||||
end
|
||||
|
||||
private :set_course_token
|
||||
|
||||
def set_available_tips
|
||||
@ -374,13 +383,14 @@ raise: false
|
||||
# Return an array with top-level tips
|
||||
@tips = nested_tips.values.select {|tip| tip.parent_exercise_tip_id.nil? }
|
||||
end
|
||||
|
||||
private :set_available_tips
|
||||
|
||||
def working_times
|
||||
working_time_accumulated = @exercise.accumulated_working_time_for_only(current_user)
|
||||
working_time_75_percentile = @exercise.get_quantiles([0.75]).first
|
||||
render(json: {working_time_75_percentile: working_time_75_percentile,
|
||||
working_time_accumulated: working_time_accumulated})
|
||||
working_time_accumulated: working_time_accumulated})
|
||||
end
|
||||
|
||||
def intervention
|
||||
@ -401,8 +411,9 @@ working_time_accumulated: working_time_accumulated})
|
||||
search_text = params[:search_text]
|
||||
search = Search.new(user: current_user, exercise: @exercise, search: search_text)
|
||||
|
||||
begin search.save
|
||||
render(json: {success: 'true'})
|
||||
begin
|
||||
search.save
|
||||
render(json: {success: 'true'})
|
||||
rescue StandardError
|
||||
render(json: {success: 'false', error: "could not save search: #{$ERROR_INFO}"})
|
||||
end
|
||||
@ -410,7 +421,7 @@ working_time_accumulated: working_time_accumulated})
|
||||
|
||||
def index
|
||||
@search = policy_scope(Exercise).ransack(params[:q])
|
||||
@exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page])
|
||||
@exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
@ -424,12 +435,14 @@ working_time_accumulated: working_time_accumulated})
|
||||
def set_execution_environments
|
||||
@execution_environments = ExecutionEnvironment.all.order(:name)
|
||||
end
|
||||
|
||||
private :set_execution_environments
|
||||
|
||||
def set_exercise_and_authorize
|
||||
@exercise = Exercise.find(params[:id])
|
||||
authorize!
|
||||
end
|
||||
|
||||
private :set_exercise_and_authorize
|
||||
|
||||
def set_external_user_and_authorize
|
||||
@ -438,23 +451,25 @@ working_time_accumulated: working_time_accumulated})
|
||||
authorize!
|
||||
end
|
||||
end
|
||||
|
||||
private :set_external_user_and_authorize
|
||||
|
||||
def set_file_types
|
||||
@file_types = FileType.all.order(:name)
|
||||
end
|
||||
|
||||
private :set_file_types
|
||||
|
||||
def collect_set_and_unset_exercise_tags
|
||||
@search = policy_scope(Tag).ransack(params[:q])
|
||||
@tags = @search.result.order(:name)
|
||||
@tags = policy_scope(Tag)
|
||||
checked_exercise_tags = @exercise.exercise_tags
|
||||
checked_tags = checked_exercise_tags.collect(&:tag).to_set
|
||||
unchecked_tags = Tag.all.to_set.subtract checked_tags
|
||||
@exercise_tags = checked_exercise_tags + unchecked_tags.collect do |tag|
|
||||
ExerciseTag.new(exercise: @exercise, tag: tag)
|
||||
end
|
||||
ExerciseTag.new(exercise: @exercise, tag: tag)
|
||||
end
|
||||
end
|
||||
|
||||
private :collect_set_and_unset_exercise_tags
|
||||
|
||||
def show
|
||||
@ -466,55 +481,63 @@ working_time_accumulated: working_time_accumulated})
|
||||
end
|
||||
|
||||
def statistics
|
||||
if @external_user
|
||||
# Render statistics page for one specific external user
|
||||
authorize(@external_user, :statistics?)
|
||||
if policy(@exercise).detailed_statistics?
|
||||
@submissions = Submission.where(user: @external_user,
|
||||
exercise_id: @exercise.id).in_study_group_of(current_user).order('created_at')
|
||||
interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id,
|
||||
@exercise.id)
|
||||
@all_events = (@submissions + interventions).sort_by(&:created_at)
|
||||
@deltas = @all_events.map.with_index do |item, index|
|
||||
delta = item.created_at - @all_events[index - 1].created_at if index.positive?
|
||||
delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta
|
||||
end
|
||||
@working_times_until = []
|
||||
@all_events.each_with_index do |_, index|
|
||||
@working_times_until.push((format_time_difference(@deltas[0..index].sum) if index.positive?))
|
||||
end
|
||||
else
|
||||
final_submissions = Submission.where(user: @external_user,
|
||||
exercise_id: @exercise.id).in_study_group_of(current_user).final
|
||||
@submissions = []
|
||||
%i[before_deadline within_grace_period after_late_deadline].each do |filter|
|
||||
relevant_submission = final_submissions.send(filter).latest
|
||||
@submissions.push relevant_submission if relevant_submission.present?
|
||||
end
|
||||
@all_events = @submissions
|
||||
end
|
||||
render 'exercises/external_users/statistics'
|
||||
else
|
||||
# Show general statistic page for specific exercise
|
||||
user_statistics = {}
|
||||
additional_filter = if policy(@exercise).detailed_statistics?
|
||||
''
|
||||
elsif !policy(@exercise).detailed_statistics? && current_user.study_groups.count.positive?
|
||||
"AND study_group_id IN (#{current_user.study_groups.pluck(:id).join(', ')}) AND cause = 'submit'"
|
||||
else
|
||||
# e.g. internal user without any study groups, show no submissions
|
||||
'AND FALSE'
|
||||
end
|
||||
query = "SELECT user_id, MAX(score) AS maximum_score, COUNT(id) AS runs
|
||||
FROM submissions WHERE exercise_id = #{@exercise.id} #{additional_filter} GROUP BY
|
||||
user_id;"
|
||||
ApplicationRecord.connection.execute(query).each do |tuple|
|
||||
user_statistics[tuple['user_id'].to_i] = tuple
|
||||
end
|
||||
render locals: {
|
||||
user_statistics: user_statistics,
|
||||
}
|
||||
# Show general statistic page for specific exercise
|
||||
user_statistics = {'InternalUser' => {}, 'ExternalUser' => {}}
|
||||
|
||||
query = Submission.select('user_id, user_type, MAX(score) AS maximum_score, COUNT(id) AS runs')
|
||||
.where(exercise_id: @exercise.id)
|
||||
.group('user_id, user_type')
|
||||
|
||||
query = if policy(@exercise).detailed_statistics?
|
||||
query
|
||||
elsif !policy(@exercise).detailed_statistics? && current_user.study_groups.count.positive?
|
||||
query.where(study_groups: current_user.study_groups.pluck(:id), cause: 'submit')
|
||||
else
|
||||
# e.g. internal user without any study groups, show no submissions
|
||||
query.where('false')
|
||||
end
|
||||
|
||||
query.each do |tuple|
|
||||
user_statistics[tuple['user_type']][tuple['user_id'].to_i] = tuple
|
||||
end
|
||||
|
||||
render locals: {
|
||||
user_statistics: user_statistics,
|
||||
}
|
||||
end
|
||||
|
||||
def external_user_statistics
|
||||
# Render statistics page for one specific external user
|
||||
|
||||
if policy(@exercise).detailed_statistics?
|
||||
submissions = Submission.where(user: @external_user, exercise: @exercise)
|
||||
.in_study_group_of(current_user)
|
||||
.order('created_at')
|
||||
@show_autosaves = params[:show_autosaves] == 'true' || submissions.none? {|s| s.cause != 'autosave' }
|
||||
submissions = submissions.where.not(cause: 'autosave') unless @show_autosaves
|
||||
interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id,
|
||||
@exercise.id)
|
||||
@all_events = (submissions + interventions).sort_by(&:created_at)
|
||||
@deltas = @all_events.map.with_index do |item, index|
|
||||
delta = item.created_at - @all_events[index - 1].created_at if index.positive?
|
||||
delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta
|
||||
end
|
||||
@working_times_until = []
|
||||
@all_events.each_with_index do |_, index|
|
||||
@working_times_until.push((format_time_difference(@deltas[0..index].sum) if index.positive?))
|
||||
end
|
||||
else
|
||||
final_submissions = Submission.where(user: @external_user,
|
||||
exercise_id: @exercise.id).in_study_group_of(current_user).final
|
||||
submissions = []
|
||||
%i[before_deadline within_grace_period after_late_deadline].each do |filter|
|
||||
relevant_submission = final_submissions.send(filter).latest
|
||||
submissions.push relevant_submission if relevant_submission.present?
|
||||
end
|
||||
@all_events = submissions
|
||||
end
|
||||
|
||||
render 'exercises/external_users/statistics'
|
||||
end
|
||||
|
||||
def submit
|
||||
@ -534,8 +557,6 @@ working_time_accumulated: working_time_accumulated})
|
||||
end
|
||||
|
||||
def transmit_lti_score
|
||||
::NewRelic::Agent.add_custom_attributes({submission: @submission.id,
|
||||
normalized_score: @submission.normalized_score})
|
||||
response = send_score(@submission)
|
||||
|
||||
if response[:status] == 'success'
|
||||
@ -552,6 +573,7 @@ working_time_accumulated: working_time_accumulated})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private :transmit_lti_score
|
||||
|
||||
def update
|
||||
|
@ -10,7 +10,7 @@ class ExternalUsersController < ApplicationController
|
||||
|
||||
def index
|
||||
@search = ExternalUser.ransack(params[:q])
|
||||
@users = @search.result.in_study_group_of(current_user).includes(:consumer).paginate(page: params[:page])
|
||||
@users = @search.result.in_study_group_of(current_user).includes(:consumer).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
@ -32,7 +32,7 @@ class ExternalUsersController < ApplicationController
|
||||
score,
|
||||
id,
|
||||
CASE
|
||||
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
|
||||
WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0'
|
||||
ELSE working_time
|
||||
END AS working_time_new
|
||||
FROM
|
||||
|
@ -19,7 +19,7 @@ class FileTemplatesController < ApplicationController
|
||||
# GET /file_templates
|
||||
# GET /file_templates.json
|
||||
def index
|
||||
@file_templates = FileTemplate.all.order(:file_type_id).paginate(page: params[:page])
|
||||
@file_templates = FileTemplate.all.order(:file_type_id).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
@ -48,7 +48,7 @@ class FileTemplatesController < ApplicationController
|
||||
|
||||
respond_to do |format|
|
||||
if @file_template.save
|
||||
format.html { redirect_to @file_template, notice: 'File template was successfully created.' }
|
||||
format.html { redirect_to @file_template, notice: t('shared.object_created', model: @file_template.class.model_name.human) }
|
||||
format.json { render :show, status: :created, location: @file_template }
|
||||
else
|
||||
format.html { render :new }
|
||||
@ -63,7 +63,7 @@ class FileTemplatesController < ApplicationController
|
||||
authorize!
|
||||
respond_to do |format|
|
||||
if @file_template.update(file_template_params)
|
||||
format.html { redirect_to @file_template, notice: 'File template was successfully updated.' }
|
||||
format.html { redirect_to @file_template, notice: t('shared.object_updated', model: @file_template.class.model_name.human) }
|
||||
format.json { render :show, status: :ok, location: @file_template }
|
||||
else
|
||||
format.html { render :edit }
|
||||
@ -78,7 +78,7 @@ class FileTemplatesController < ApplicationController
|
||||
authorize!
|
||||
@file_template.destroy
|
||||
respond_to do |format|
|
||||
format.html { redirect_to file_templates_url, notice: 'File template was successfully destroyed.' }
|
||||
format.html { redirect_to file_templates_url, notice: t('shared.object_destroyed', model: @file_template.class.model_name.human) }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
@ -33,7 +33,7 @@ class FileTypesController < ApplicationController
|
||||
private :file_type_params
|
||||
|
||||
def index
|
||||
@file_types = FileType.all.includes(:user).order(:name).paginate(page: params[:page])
|
||||
@file_types = FileType.all.includes(:user).order(:name).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -6,7 +6,7 @@ class FlowrController < ApplicationController
|
||||
# get the latest submission for this user that also has a test run (i.e. structured_errors if applicable)
|
||||
submission = Submission.joins(:testruns)
|
||||
.where(submissions: {user_id: current_user.id, user_type: current_user.class.name})
|
||||
.order('testruns.created_at DESC').first
|
||||
.merge(Testrun.order(created_at: :desc)).first
|
||||
|
||||
# Return if no submission was found
|
||||
if submission.blank? || @embed_options[:disable_hints] || @embed_options[:hide_test_results]
|
||||
|
@ -6,7 +6,6 @@ class InternalUsersController < ApplicationController
|
||||
before_action :require_activation_token, only: :activate
|
||||
before_action :require_reset_password_token, only: :reset_password
|
||||
before_action :set_user, only: MEMBER_ACTIONS
|
||||
skip_before_action :verify_authenticity_token, only: :activate
|
||||
after_action :verify_authorized, except: %i[activate forgot_password reset_password]
|
||||
|
||||
def activate
|
||||
@ -33,9 +32,15 @@ class InternalUsersController < ApplicationController
|
||||
|
||||
def create
|
||||
@user = InternalUser.new(internal_user_params)
|
||||
@user.role = role_param if current_user.admin?
|
||||
authorize!
|
||||
@user.send(:setup_activation)
|
||||
create_and_respond(object: @user) { @user.send(:send_activation_needed_email!) }
|
||||
create_and_respond(object: @user) do
|
||||
@user.send(:send_activation_needed_email!)
|
||||
# The return value is used as a flash message. If this block does not
|
||||
# have any specific return value, a default success message is shown.
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_reset_password_instructions
|
||||
@ -63,15 +68,20 @@ class InternalUsersController < ApplicationController
|
||||
|
||||
def index
|
||||
@search = InternalUser.ransack(params[:q])
|
||||
@users = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
|
||||
@users = @search.result.includes(:consumer).order(:name).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
def internal_user_params
|
||||
params[:internal_user].permit(:consumer_id, :email, :name, :role) if params[:internal_user].present?
|
||||
params.require(:internal_user).permit(:consumer_id, :email, :name)
|
||||
end
|
||||
private :internal_user_params
|
||||
|
||||
def role_param
|
||||
params.require(:internal_user).permit(:role)[:role]
|
||||
end
|
||||
private :role_param
|
||||
|
||||
def new
|
||||
@user = InternalUser.new
|
||||
authorize!
|
||||
@ -129,6 +139,7 @@ class InternalUsersController < ApplicationController
|
||||
# the form by another user. Otherwise, the update might fail if an
|
||||
# activation_token or password_reset_token is present
|
||||
@user.validate_password = current_user == @user
|
||||
@user.role = role_param if current_user.admin?
|
||||
|
||||
update_and_respond(object: @user, params: internal_user_params)
|
||||
end
|
||||
|
@ -15,7 +15,7 @@ class ProxyExercisesController < ApplicationController
|
||||
user: current_user)
|
||||
proxy_exercise.send(:generate_token)
|
||||
if proxy_exercise.save
|
||||
redirect_to(proxy_exercise, notice: t('shared.object_cloned', model: ProxyExercise.model_name.human))
|
||||
redirect_to(proxy_exercise_path(proxy_exercise), notice: t('shared.object_cloned', model: ProxyExercise.model_name.human))
|
||||
else
|
||||
flash[:danger] = t('shared.message_failure')
|
||||
redirect_to(@proxy_exercise)
|
||||
@ -51,7 +51,7 @@ class ProxyExercisesController < ApplicationController
|
||||
|
||||
def index
|
||||
@search = policy_scope(ProxyExercise).ransack(params[:q])
|
||||
@proxy_exercises = @search.result.order(:title).paginate(page: params[:page])
|
||||
@proxy_exercises = @search.result.order(:title).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -39,8 +39,8 @@ class RemoteEvaluationController < ApplicationController
|
||||
else
|
||||
{
|
||||
message: "Your submission was successfully scored with #{@submission.normalized_score}%. " \
|
||||
'However, your score could not be sent to the e-Learning platform. Please reopen ' \
|
||||
'the exercise through the e-Learning platform and try again.',
|
||||
'However, your score could not be sent to the e-Learning platform. Please check ' \
|
||||
'the submission deadline, reopen the exercise through the e-Learning platform and try again.',
|
||||
status: 410,
|
||||
}
|
||||
end
|
||||
|
@ -1,8 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RequestForCommentsController < ApplicationController
|
||||
include CommonBehavior
|
||||
before_action :require_user!
|
||||
before_action :set_request_for_comment, only: %i[show mark_as_solved set_thank_you_note]
|
||||
before_action :set_request_for_comment, only: %i[show mark_as_solved set_thank_you_note clear_question]
|
||||
before_action :set_study_group_grouping,
|
||||
only: %i[index my_comment_requests rfcs_with_my_comments rfcs_for_exercise]
|
||||
|
||||
@ -23,7 +24,7 @@ class RequestForCommentsController < ApplicationController
|
||||
.where(exercises: {unpublished: false})
|
||||
.includes(submission: [:study_group])
|
||||
.order('created_at DESC')
|
||||
.paginate(page: params[:page], total_entries: @search.result.length)
|
||||
.paginate(page: params[:page], per_page: per_page_param, total_entries: @search.result.length)
|
||||
|
||||
authorize!
|
||||
end
|
||||
@ -36,7 +37,7 @@ class RequestForCommentsController < ApplicationController
|
||||
.ransack(params[:q])
|
||||
@request_for_comments = @search.result
|
||||
.order('created_at DESC')
|
||||
.paginate(page: params[:page])
|
||||
.paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
render 'index'
|
||||
end
|
||||
@ -50,7 +51,7 @@ class RequestForCommentsController < ApplicationController
|
||||
.ransack(params[:q])
|
||||
@request_for_comments = @search.result
|
||||
.order('last_comment DESC')
|
||||
.paginate(page: params[:page])
|
||||
.paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
render 'index'
|
||||
end
|
||||
@ -65,7 +66,7 @@ class RequestForCommentsController < ApplicationController
|
||||
@request_for_comments = @search.result
|
||||
.joins(:exercise)
|
||||
.order('last_comment DESC')
|
||||
.paginate(page: params[:page])
|
||||
.paginate(page: params[:page], per_page: per_page_param)
|
||||
# let the exercise decide, whether its rfcs should be visible
|
||||
authorize(exercise)
|
||||
render 'index'
|
||||
@ -101,6 +102,12 @@ class RequestForCommentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# POST /request_for_comments/1/clear_question
|
||||
def clear_question
|
||||
authorize!
|
||||
update_and_respond(object: @request_for_comment, params: {question: nil})
|
||||
end
|
||||
|
||||
# GET /request_for_comments/1
|
||||
# GET /request_for_comments/1.json
|
||||
def show
|
||||
|
@ -24,7 +24,7 @@ class SessionsController < ApplicationController
|
||||
store_lti_session_data(consumer: @consumer, parameters: params)
|
||||
store_nonce(params[:oauth_nonce])
|
||||
if params[:custom_redirect_target]
|
||||
redirect_to(params[:custom_redirect_target])
|
||||
redirect_to(URI.parse(params[:custom_redirect_target].to_s).path)
|
||||
else
|
||||
redirect_to(implement_exercise_path(@exercise),
|
||||
notice: t("sessions.create_through_lti.session_#{lti_outcome_service?(@exercise.id, @current_user.id) ? 'with' : 'without'}_outcome",
|
||||
@ -43,6 +43,10 @@ class SessionsController < ApplicationController
|
||||
|
||||
def destroy_through_lti
|
||||
@submission = Submission.find(params[:submission_id])
|
||||
authorize(@submission, :show?)
|
||||
lti_parameter = LtiParameter.where(external_users_id: @submission.user_id, exercises_id: @submission.exercise_id).last
|
||||
@url = consumer_return_url(build_tool_provider(consumer: @submission.user.consumer, parameters: lti_parameter&.lti_parameters))
|
||||
|
||||
clear_lti_session_data(@submission.exercise_id, @submission.user_id)
|
||||
end
|
||||
|
||||
|
@ -7,7 +7,7 @@ class StudyGroupsController < ApplicationController
|
||||
|
||||
def index
|
||||
@search = policy_scope(StudyGroup).ransack(params[:q])
|
||||
@study_groups = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
|
||||
@study_groups = @search.result.includes(:consumer).order(:name).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -9,10 +9,10 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_submission, only: %i[download download_file render_file run score show statistics test]
|
||||
before_action :set_testrun, only: %i[run score test]
|
||||
before_action :set_files, only: %i[download show]
|
||||
before_action :set_files_and_specific_file, only: %i[download_file render_file run test]
|
||||
before_action :set_mime_type, only: %i[download_file render_file]
|
||||
skip_before_action :verify_authenticity_token, only: %i[download_file render_file]
|
||||
|
||||
def create
|
||||
@submission = Submission.new(submission_params)
|
||||
@ -27,8 +27,8 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
stringio = Zip::OutputStream.write_buffer do |zio|
|
||||
@files.each do |file|
|
||||
zio.put_next_entry(file.filepath)
|
||||
zio.write(file.content.presence || file.native_file.read)
|
||||
zio.put_next_entry(file.filepath.delete_prefix('/'))
|
||||
zio.write(file.read)
|
||||
end
|
||||
|
||||
# zip exercise description
|
||||
@ -39,7 +39,7 @@ class SubmissionsController < ApplicationController
|
||||
# zip .co file
|
||||
zio.put_next_entry('.co')
|
||||
zio.write(File.read(id_file))
|
||||
File.delete(id_file) if File.exist?(id_file)
|
||||
FileUtils.rm_rf(id_file)
|
||||
|
||||
# zip client scripts
|
||||
scripts_path = 'app/assets/remote_scripts'
|
||||
@ -56,22 +56,18 @@ class SubmissionsController < ApplicationController
|
||||
def download_file
|
||||
raise Pundit::NotAuthorizedError if @embed_options[:disable_download]
|
||||
|
||||
if @file.native_file?
|
||||
send_file(@file.native_file.path)
|
||||
else
|
||||
send_data(@file.content, filename: @file.name_with_extension)
|
||||
end
|
||||
send_data(@file.read, filename: @file.name_with_extension)
|
||||
end
|
||||
|
||||
def index
|
||||
@search = Submission.ransack(params[:q])
|
||||
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page])
|
||||
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
def render_file
|
||||
if @file.native_file?
|
||||
send_file(@file.native_file.path, disposition: 'inline')
|
||||
send_data(@file.read, filename: @file.name_with_extension, disposition: 'inline')
|
||||
else
|
||||
render(plain: @file.content)
|
||||
end
|
||||
@ -85,10 +81,14 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
hijack do |tubesock|
|
||||
client_socket = tubesock
|
||||
return kill_client_socket(client_socket) if @embed_options[:disable_run]
|
||||
|
||||
client_socket.onopen do |_event|
|
||||
kill_client_socket(client_socket) if @embed_options[:disable_run]
|
||||
end
|
||||
|
||||
client_socket.onclose do |_event|
|
||||
runner_socket&.close(:terminated_by_client)
|
||||
@testrun[:status] ||= :terminated_by_client
|
||||
end
|
||||
|
||||
client_socket.onmessage do |raw_event|
|
||||
@ -97,9 +97,17 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
# Otherwise, we expect to receive a JSON: Parsing.
|
||||
event = JSON.parse(raw_event).deep_symbolize_keys
|
||||
event[:cmd] = event[:cmd].to_sym
|
||||
event[:stream] = event[:stream].to_sym if event.key? :stream
|
||||
|
||||
case event[:cmd].to_sym
|
||||
# We could store the received event. However, it is also echoed by the container
|
||||
# and correctly identified as the original input. Therefore, we don't store
|
||||
# it here to prevent duplicated events.
|
||||
# @testrun[:messages].push(event)
|
||||
|
||||
case event[:cmd]
|
||||
when :client_kill
|
||||
@testrun[:status] = :terminated_by_client
|
||||
close_client_connection(client_socket)
|
||||
Rails.logger.debug('Client exited container.')
|
||||
when :result, :canvasevent, :exception
|
||||
@ -125,68 +133,88 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
@output = +''
|
||||
durations = @submission.run(@file) do |socket|
|
||||
@testrun[:output] = +''
|
||||
durations = @submission.run(@file) do |socket, starting_time|
|
||||
runner_socket = socket
|
||||
@testrun[:starting_time] = starting_time
|
||||
client_socket.send_data JSON.dump({cmd: :status, status: :container_running})
|
||||
|
||||
runner_socket.on :stdout do |data|
|
||||
json_data = prepare data, :stdout
|
||||
@output << json_data[0, max_output_buffer_size - @output.size]
|
||||
client_socket.send_data(json_data)
|
||||
message = retrieve_message_from_output data, :stdout
|
||||
@testrun[:output] << message[:data][0, max_output_buffer_size - @testrun[:output].size] if message[:data]
|
||||
send_and_store client_socket, message
|
||||
end
|
||||
|
||||
runner_socket.on :stderr do |data|
|
||||
json_data = prepare data, :stderr
|
||||
@output << json_data[0, max_output_buffer_size - @output.size]
|
||||
client_socket.send_data(json_data)
|
||||
message = retrieve_message_from_output data, :stderr
|
||||
@testrun[:output] << message[:data][0, max_output_buffer_size - @testrun[:output].size] if message[:data]
|
||||
send_and_store client_socket, message
|
||||
end
|
||||
|
||||
runner_socket.on :exit do |exit_code|
|
||||
@testrun[:exit_code] = exit_code
|
||||
exit_statement =
|
||||
if @output.empty? && exit_code.zero?
|
||||
if @testrun[:output].empty? && exit_code.zero?
|
||||
@testrun[:status] = :ok
|
||||
t('exercises.implement.no_output_exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)
|
||||
elsif @output.empty?
|
||||
elsif @testrun[:output].empty?
|
||||
@testrun[:status] = :failed
|
||||
t('exercises.implement.no_output_exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)
|
||||
elsif exit_code.zero?
|
||||
@testrun[:status] = :ok
|
||||
"\n#{t('exercises.implement.exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
|
||||
else
|
||||
@testrun[:status] = :failed
|
||||
"\n#{t('exercises.implement.exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
|
||||
end
|
||||
client_socket.send_data JSON.dump({cmd: :write, stream: :stdout, data: "#{exit_statement}\n"})
|
||||
send_and_store client_socket, {cmd: :write, stream: :stdout, data: "#{exit_statement}\n"}
|
||||
if exit_code == 137
|
||||
send_and_store client_socket, {cmd: :status, status: :out_of_memory}
|
||||
@testrun[:status] = :out_of_memory
|
||||
end
|
||||
|
||||
close_client_connection(client_socket)
|
||||
end
|
||||
end
|
||||
@container_execution_time = durations[:execution_duration]
|
||||
@waiting_for_container_time = durations[:waiting_duration]
|
||||
@testrun[:container_execution_time] = durations[:execution_duration]
|
||||
@testrun[:waiting_for_container_time] = durations[:waiting_duration]
|
||||
rescue Runner::Error::ExecutionTimeout => e
|
||||
client_socket.send_data JSON.dump({cmd: :status, status: :timeout})
|
||||
send_and_store client_socket, {cmd: :status, status: :timeout}
|
||||
close_client_connection(client_socket)
|
||||
Rails.logger.debug { "Running a submission timed out: #{e.message}" }
|
||||
@output = "timeout: #{@output}"
|
||||
@testrun[:status] ||= :timeout
|
||||
@testrun[:output] = "timeout: #{@testrun[:output]}"
|
||||
extract_durations(e)
|
||||
rescue Runner::Error => e
|
||||
client_socket.send_data JSON.dump({cmd: :status, status: :container_depleted})
|
||||
send_and_store client_socket, {cmd: :status, status: :container_depleted}
|
||||
close_client_connection(client_socket)
|
||||
@testrun[:status] ||= :container_depleted
|
||||
Rails.logger.debug { "Runner error while running a submission: #{e.message}" }
|
||||
extract_durations(e)
|
||||
ensure
|
||||
save_run_output
|
||||
save_testrun_output 'run'
|
||||
end
|
||||
|
||||
def score
|
||||
hijack do |tubesock|
|
||||
return if @embed_options[:disable_score]
|
||||
tubesock.onopen do |_event|
|
||||
switch_locale do
|
||||
kill_client_socket(tubesock) if @embed_options[:disable_score]
|
||||
|
||||
tubesock.send_data(JSON.dump(@submission.calculate_score))
|
||||
# To enable hints when scoring a submission, uncomment the next line:
|
||||
# send_hints(tubesock, StructuredError.where(submission: @submission))
|
||||
rescue Runner::Error => e
|
||||
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted})
|
||||
Rails.logger.debug { "Runner error while scoring submission #{@submission.id}: #{e.message}" }
|
||||
ensure
|
||||
kill_client_socket(tubesock)
|
||||
# The score is stored separately, we can forward it to the client immediately
|
||||
tubesock.send_data(JSON.dump(@submission.calculate_score))
|
||||
# To enable hints when scoring a submission, uncomment the next line:
|
||||
# send_hints(tubesock, StructuredError.where(submission: @submission))
|
||||
kill_client_socket(tubesock)
|
||||
rescue Runner::Error => e
|
||||
extract_durations(e)
|
||||
send_and_store tubesock, {cmd: :status, status: :container_depleted}
|
||||
kill_client_socket(tubesock)
|
||||
Rails.logger.debug { "Runner error while scoring submission #{@submission.id}: #{e.message}" }
|
||||
@testrun[:passed] = false
|
||||
save_testrun_output 'assess'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -196,14 +224,22 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
def test
|
||||
hijack do |tubesock|
|
||||
return kill_client_socket(tubesock) if @embed_options[:disable_run]
|
||||
tubesock.onopen do |_event|
|
||||
switch_locale do
|
||||
kill_client_socket(tubesock) if @embed_options[:disable_run]
|
||||
|
||||
tubesock.send_data(JSON.dump(@submission.test(@file)))
|
||||
rescue Runner::Error => e
|
||||
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted})
|
||||
Rails.logger.debug { "Runner error while testing submission #{@submission.id}: #{e.message}" }
|
||||
ensure
|
||||
kill_client_socket(tubesock)
|
||||
# The score is stored separately, we can forward it to the client immediately
|
||||
tubesock.send_data(JSON.dump(@submission.test(@file)))
|
||||
kill_client_socket(tubesock)
|
||||
rescue Runner::Error => e
|
||||
extract_durations(e)
|
||||
send_and_store tubesock, {cmd: :status, status: :container_depleted}
|
||||
kill_client_socket(tubesock)
|
||||
Rails.logger.debug { "Runner error while testing submission #{@submission.id}: #{e.message}" }
|
||||
@testrun[:passed] = false
|
||||
save_testrun_output 'assess'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -221,6 +257,7 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
def kill_client_socket(client_socket)
|
||||
# We don't want to store this (arbitrary) exit command and redirect it ourselves
|
||||
client_socket.send_data JSON.dump({cmd: :exit})
|
||||
client_socket.close
|
||||
end
|
||||
@ -240,7 +277,7 @@ class SubmissionsController < ApplicationController
|
||||
# parse validation token
|
||||
content = "#{remote_evaluation_mapping.validation_token}\n"
|
||||
# parse remote request url
|
||||
content += "#{request.base_url}/evaluate\n"
|
||||
content += "#{evaluate_url}\n"
|
||||
@submission.files.each do |file|
|
||||
content += "#{file.filepath}=#{file.file_id}\n"
|
||||
end
|
||||
@ -249,21 +286,33 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
def extract_durations(error)
|
||||
@container_execution_time = error.execution_duration
|
||||
@waiting_for_container_time = error.waiting_duration
|
||||
@testrun[:starting_time] = error.starting_time
|
||||
@testrun[:container_execution_time] = error.execution_duration
|
||||
@testrun[:waiting_for_container_time] = error.waiting_duration
|
||||
end
|
||||
|
||||
def extract_errors
|
||||
results = []
|
||||
if @output.present?
|
||||
if @testrun[:output].present?
|
||||
@submission.exercise.execution_environment.error_templates.each do |template|
|
||||
pattern = Regexp.new(template.signature).freeze
|
||||
results << StructuredError.create_from_template(template, @output, @submission) if pattern.match(@output)
|
||||
results << StructuredError.create_from_template(template, @testrun[:output], @submission) if pattern.match(@testrun[:output])
|
||||
end
|
||||
end
|
||||
results
|
||||
end
|
||||
|
||||
def send_and_store(client_socket, message)
|
||||
message[:timestamp] = if @testrun[:starting_time]
|
||||
ActiveSupport::Duration.build(Time.zone.now - @testrun[:starting_time])
|
||||
else
|
||||
0.seconds
|
||||
end
|
||||
@testrun[:messages].push message
|
||||
@testrun[:status] = message[:status] if message[:status]
|
||||
client_socket.send_data JSON.dump(message)
|
||||
end
|
||||
|
||||
def max_output_buffer_size
|
||||
if @submission.cause == 'requestComments'
|
||||
5000
|
||||
@ -272,28 +321,25 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def prepare(data, stream)
|
||||
if valid_command? data
|
||||
data
|
||||
else
|
||||
JSON.dump({cmd: :write, stream: stream, data: data})
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_filename
|
||||
params[:filename].gsub(/\.json$/, '')
|
||||
end
|
||||
|
||||
# save the output of this "run" as a "testrun" (scoring runs are saved in submission.rb)
|
||||
def save_run_output
|
||||
Testrun.create(
|
||||
def save_testrun_output(cause)
|
||||
testrun = Testrun.create!(
|
||||
file: @file,
|
||||
cause: 'run',
|
||||
passed: @testrun[:passed],
|
||||
cause: cause,
|
||||
submission: @submission,
|
||||
output: @output,
|
||||
container_execution_time: @container_execution_time,
|
||||
waiting_for_container_time: @waiting_for_container_time
|
||||
exit_code: @testrun[:exit_code], # might be nil, e.g., when the run did not finish
|
||||
status: @testrun[:status],
|
||||
output: @testrun[:output].presence, # TODO: Remove duplicated saving of the output after creating TestrunMessages
|
||||
container_execution_time: @testrun[:container_execution_time],
|
||||
waiting_for_container_time: @testrun[:waiting_for_container_time]
|
||||
)
|
||||
TestrunMessage.create_for(testrun, @testrun[:messages])
|
||||
TestrunExecutionEnvironment.create(testrun: testrun, execution_environment: @submission.used_execution_environment)
|
||||
end
|
||||
|
||||
def send_hints(tubesock, errors)
|
||||
@ -301,7 +347,7 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
errors = errors.to_a.uniq(&:hint)
|
||||
errors.each do |error|
|
||||
tubesock.send_data JSON.dump({cmd: 'hint', hint: error.hint, description: error.error_template.description})
|
||||
send_and_store tubesock, {cmd: :hint, hint: error.hint, description: error.error_template.description}
|
||||
end
|
||||
end
|
||||
|
||||
@ -327,10 +373,26 @@ class SubmissionsController < ApplicationController
|
||||
authorize!
|
||||
end
|
||||
|
||||
def valid_command?(data)
|
||||
def set_testrun
|
||||
@testrun = {
|
||||
messages: [],
|
||||
exit_code: nil,
|
||||
status: nil,
|
||||
}
|
||||
end
|
||||
|
||||
def retrieve_message_from_output(data, stream)
|
||||
parsed = JSON.parse(data)
|
||||
parsed.instance_of?(Hash) && parsed.key?('cmd')
|
||||
if parsed.instance_of?(Hash) && parsed.key?('cmd')
|
||||
parsed.symbolize_keys!
|
||||
# Symbolize two values if present
|
||||
parsed[:cmd] = parsed[:cmd].to_sym
|
||||
parsed[:stream] = parsed[:stream].to_sym if parsed.key? :stream
|
||||
parsed
|
||||
else
|
||||
{cmd: :write, stream: stream, data: data}
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
false
|
||||
{cmd: :write, stream: stream, data: data}
|
||||
end
|
||||
end
|
||||
|
@ -28,7 +28,7 @@ class TagsController < ApplicationController
|
||||
private :tag_params
|
||||
|
||||
def index
|
||||
@tags = Tag.all.paginate(page: params[:page])
|
||||
@tags = Tag.all.paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -34,7 +34,7 @@ class TipsController < ApplicationController
|
||||
private :tip_params
|
||||
|
||||
def index
|
||||
@tips = Tip.all.paginate(page: params[:page])
|
||||
@tips = Tip.all.paginate(page: params[:page], per_page: per_page_param)
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
class Runner
|
||||
class Error < ApplicationError
|
||||
attr_accessor :waiting_duration, :execution_duration
|
||||
attr_accessor :waiting_duration, :execution_duration, :starting_time
|
||||
|
||||
class BadRequest < Error; end
|
||||
|
||||
|
@ -18,11 +18,11 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
def empty
|
||||
tag.i(nil, class: 'empty fa fa-minus')
|
||||
tag.i(nil, class: 'empty fa-solid fa-minus')
|
||||
end
|
||||
|
||||
def label_column(label)
|
||||
tag.div(class: 'col-sm-3') do
|
||||
tag.div(class: 'col-md-3') do
|
||||
tag.strong do
|
||||
I18n.translation_present?("activerecord.attributes.#{label}") ? t("activerecord.attributes.#{label}") : t(label)
|
||||
end
|
||||
@ -31,13 +31,21 @@ module ApplicationHelper
|
||||
private :label_column
|
||||
|
||||
def no
|
||||
tag.i(nil, class: 'fa fa-times')
|
||||
tag.i(nil, class: 'fa-solid fa-xmark')
|
||||
end
|
||||
|
||||
def per_page_param
|
||||
if params[:per_page]
|
||||
[params[:per_page].to_i, 100].min
|
||||
else
|
||||
WillPaginate.per_page
|
||||
end
|
||||
end
|
||||
|
||||
def progress_bar(value)
|
||||
tag.div(class: value ? 'progress' : 'disabled progress') do
|
||||
tag.div(value ? "#{value}%" : '', 'aria-valuemax': 100, 'aria-valuemin': 0,
|
||||
'aria-valuenow': value, class: 'progress-bar progress-bar-striped', role: 'progressbar', style: "width: #{[value || 0, 100].min}%;")
|
||||
'aria-valuenow': value, class: 'progress-bar progress-bar-striped', role: 'progressbar', style: "width: #{[value || 0, 100].min}%;")
|
||||
end
|
||||
end
|
||||
|
||||
@ -64,13 +72,13 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
def value_column(value)
|
||||
tag.div(class: 'col-sm-9') do
|
||||
tag.div(class: 'col-md-9') do
|
||||
block_given? ? yield : symbol_for(value)
|
||||
end
|
||||
end
|
||||
private :value_column
|
||||
|
||||
def yes
|
||||
tag.i(nil, class: 'fa fa-check')
|
||||
tag.i(nil, class: 'fa-solid fa-check')
|
||||
end
|
||||
end
|
||||
|
@ -28,7 +28,7 @@ class PagedownFormBuilder < ActionView::Helpers::FormBuilder
|
||||
|
||||
def wmd_preview
|
||||
@template.tag.div(nil, class: 'wmd-preview',
|
||||
id: "wmd-preview-#{base_id}")
|
||||
id: "wmd-preview-#{base_id}")
|
||||
end
|
||||
|
||||
def show_wmd_preview?
|
||||
|
@ -2,7 +2,9 @@
|
||||
|
||||
module StatisticsHelper
|
||||
WORKING_TIME_DELTA_IN_SECONDS = 5.minutes
|
||||
WORKING_TIME_DELTA_IN_SQL_INTERVAL = "'0:05:00'" # yes, a string with quotes
|
||||
def self.working_time_larger_delta
|
||||
@working_time_larger_delta ||= ActiveRecord::Base.sanitize_sql(['working_time >= ?', '0:05:00'])
|
||||
end
|
||||
|
||||
def statistics_data
|
||||
[
|
||||
@ -79,7 +81,7 @@ module StatisticsHelper
|
||||
{
|
||||
key: 'container_requests_per_minute',
|
||||
name: t('statistics.entries.exercises.container_requests_per_minute'),
|
||||
data: (Testrun.where('created_at >= ?', DateTime.now - 1.hour).count.to_f / 60).round(2),
|
||||
data: (Testrun.where(created_at: DateTime.now - 1.hour..).count.to_f / 60).round(2),
|
||||
unit: '/min',
|
||||
},
|
||||
{
|
||||
@ -179,7 +181,7 @@ module StatisticsHelper
|
||||
key: 'rfcs',
|
||||
name: t('activerecord.models.request_for_comment.other'),
|
||||
data: RequestForComment.in_range(from, to)
|
||||
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
|
||||
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
|
||||
.group('key').order('key'),
|
||||
},
|
||||
{
|
||||
@ -187,7 +189,7 @@ module StatisticsHelper
|
||||
name: t('statistics.entries.request_for_comments.percent_solved'),
|
||||
data: RequestForComment.in_range(from, to)
|
||||
.where(solved: true)
|
||||
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
|
||||
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
|
||||
.group('key').order('key'),
|
||||
},
|
||||
{
|
||||
@ -195,14 +197,14 @@ module StatisticsHelper
|
||||
name: t('statistics.entries.request_for_comments.percent_soft_solved'),
|
||||
data: RequestForComment.in_range(from, to).unsolved
|
||||
.where(full_score_reached: true)
|
||||
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
|
||||
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
|
||||
.group('key').order('key'),
|
||||
},
|
||||
{
|
||||
key: 'rfcs_unsolved',
|
||||
name: t('statistics.entries.request_for_comments.percent_unsolved'),
|
||||
data: RequestForComment.in_range(from, to).unsolved
|
||||
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
|
||||
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
|
||||
.group('key').order('key'),
|
||||
},
|
||||
]
|
||||
@ -215,14 +217,14 @@ module StatisticsHelper
|
||||
name: t('statistics.entries.users.active'),
|
||||
data: ExternalUser.joins(:submissions)
|
||||
.where(submissions: {created_at: from..to})
|
||||
.select("date_trunc('#{interval}', submissions.created_at) AS \"key\", count(distinct external_users.id) AS \"value\"")
|
||||
.select(ExternalUser.sanitize_sql(['date_trunc(?, submissions.created_at) AS "key", count(distinct external_users.id) AS "value"', interval]))
|
||||
.group('key').order('key'),
|
||||
},
|
||||
{
|
||||
key: 'submissions',
|
||||
name: t('statistics.entries.exercises.submissions'),
|
||||
data: Submission.where(created_at: from..to)
|
||||
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
|
||||
.select(Submission.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
|
||||
.group('key').order('key'),
|
||||
axis: 'right',
|
||||
},
|
||||
|
@ -10,12 +10,14 @@
|
||||
// JS
|
||||
import 'jquery';
|
||||
import 'jquery-ujs'
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min';
|
||||
import * as bootstrap from 'bootstrap/dist/js/bootstrap.bundle';
|
||||
import 'chosen-js/chosen.jquery';
|
||||
import 'jstree';
|
||||
import 'underscore';
|
||||
import 'd3';
|
||||
import '@sentry/browser';
|
||||
import 'sorttable';
|
||||
window.bootstrap = bootstrap; // Publish bootstrap in global namespace
|
||||
window._ = _; // Publish underscore's `_` in global namespace
|
||||
window.d3 = d3; // Publish d3 in global namespace
|
||||
window.Sentry = Sentry; // Publish sentry in global namespace
|
||||
@ -40,6 +42,23 @@ import 'jquery-ui/themes/base/resizable.css'
|
||||
import 'jquery-ui/themes/base/selectable.css'
|
||||
import 'jquery-ui/themes/base/sortable.css'
|
||||
|
||||
|
||||
// I18n locales
|
||||
import { I18n } from "i18n-js";
|
||||
import locales from "../../tmp/locales.json";
|
||||
|
||||
// Fetch user locale from html#lang.
|
||||
// This value is being set on `app/views/layouts/application.html.erb` and
|
||||
// is inferred from `ACCEPT-LANGUAGE` header.
|
||||
const userLocale = document.documentElement.lang;
|
||||
|
||||
export const i18n = new I18n();
|
||||
i18n.store(locales);
|
||||
i18n.defaultLocale = "en";
|
||||
i18n.enableFallback = true;
|
||||
i18n.locale = userLocale;
|
||||
window.I18n = i18n;
|
||||
|
||||
// Routes
|
||||
import * as Routes from 'routes.js.erb';
|
||||
window.Routes = Routes;
|
5
app/javascript/d3-tip.js
vendored
Normal file
5
app/javascript/d3-tip.js
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/* eslint no-console:0 */
|
||||
|
||||
// JS
|
||||
import * as d3Tip from 'd3-tip/dist'
|
||||
window.d3.tip = d3Tip;
|
8
app/javascript/highlight.js
Normal file
8
app/javascript/highlight.js
Normal file
@ -0,0 +1,8 @@
|
||||
/* eslint no-console:0 */
|
||||
|
||||
// JS
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
window.hljs = hljs;
|
||||
|
||||
// CSS
|
||||
import 'highlight.js/styles/base16/tomorrow.css'
|
12
app/javascript/packs/d3-tip.js
vendored
12
app/javascript/packs/d3-tip.js
vendored
@ -1,12 +0,0 @@
|
||||
/* eslint no-console:0 */
|
||||
// This file is automatically compiled by Webpack, along with any other files
|
||||
// present in this directory. You're encouraged to place your actual application logic in
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so it'll be compiled.
|
||||
//
|
||||
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
|
||||
// layout file, like app/views/layouts/application.html.slim
|
||||
|
||||
// JS
|
||||
import d3Tip from 'd3-tip'
|
||||
window.d3.tip = d3Tip;
|
@ -1,15 +0,0 @@
|
||||
/* eslint no-console:0 */
|
||||
// This file is automatically compiled by Webpack, along with any other files
|
||||
// present in this directory. You're encouraged to place your actual application logic in
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so it'll be compiled.
|
||||
//
|
||||
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
|
||||
// layout file, like app/views/layouts/application.html.slim
|
||||
|
||||
// JS
|
||||
import hljs from 'highlight.js'
|
||||
window.hljs = hljs;
|
||||
|
||||
// CSS
|
||||
import 'highlight.js/styles/base16/tomorrow.css'
|
@ -1,12 +0,0 @@
|
||||
/* eslint no-console:0 */
|
||||
// This file is automatically compiled by Webpack, along with any other files
|
||||
// present in this directory. You're encouraged to place your actual application logic in
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so it'll be compiled.
|
||||
//
|
||||
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
|
||||
// layout file, like app/views/layouts/application.html.slim
|
||||
|
||||
// JS
|
||||
import Sortable from 'sortablejs'
|
||||
window.Sortable = Sortable;
|
@ -1,15 +0,0 @@
|
||||
/* eslint no-console:0 */
|
||||
// This file is automatically compiled by Webpack, along with any other files
|
||||
// present in this directory. You're encouraged to place your actual application logic in
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so it'll be compiled.
|
||||
//
|
||||
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
|
||||
// layout file, like app/views/layouts/application.html.slim
|
||||
|
||||
// JS
|
||||
import 'vis'
|
||||
window.vis = vis;
|
||||
|
||||
// CSS
|
||||
import 'vis/dist/vis.min.css'
|
5
app/javascript/sortable.js
Normal file
5
app/javascript/sortable.js
Normal file
@ -0,0 +1,5 @@
|
||||
/* eslint no-console:0 */
|
||||
|
||||
// JS
|
||||
import Sortable from 'sortablejs'
|
||||
window.Sortable = Sortable;
|
@ -7,14 +7,14 @@
|
||||
// To reference this file, add <%= stylesheet_pack_tag 'stylesheets' %> to the appropriate
|
||||
// layout file, like app/views/layouts/application.html.slim
|
||||
|
||||
$web-font-path: '';
|
||||
@import '~bootswatch/dist/yeti/variables';
|
||||
@import '~bootstrap/scss/bootstrap';
|
||||
@import '~bootswatch/dist/yeti/bootswatch';
|
||||
$web-font-path: '//';
|
||||
@import '../../node_modules/bootswatch/dist/yeti/variables';
|
||||
@import '../../node_modules/bootstrap/scss/bootstrap';
|
||||
@import '../../node_modules/bootswatch/dist/yeti/bootswatch';
|
||||
$fa-font-path: '~@fortawesome/fontawesome-free/webfonts/';
|
||||
@import '~@fortawesome/fontawesome-free/scss/fontawesome';
|
||||
@import '~@fortawesome/fontawesome-free/scss/solid';
|
||||
@import '~@fortawesome/fontawesome-free/scss/regular';
|
||||
@import '~@fortawesome/fontawesome-free/scss/v4-shims';
|
||||
@import '../../node_modules/@fortawesome/fontawesome-free/scss/solid';
|
||||
@import '../../node_modules/@fortawesome/fontawesome-free/scss/regular';
|
||||
@import '../../node_modules/@fortawesome/fontawesome-free/scss/v4-shims';
|
||||
$opensans-path: '~opensans-webkit/fonts/';
|
||||
@import '~opensans-webkit/src/sass/open-sans';
|
||||
@import '../../node_modules/opensans-webkit/src/sass/open-sans';
|
8
app/javascript/vis.js
Normal file
8
app/javascript/vis.js
Normal file
@ -0,0 +1,8 @@
|
||||
/* eslint no-console:0 */
|
||||
|
||||
// JS
|
||||
import 'vis'
|
||||
window.vis = vis;
|
||||
|
||||
// CSS
|
||||
import 'vis-timeline/dist/vis-timeline-graph2d.css';
|
@ -20,10 +20,11 @@ class UserMailer < ApplicationMailer
|
||||
|
||||
def got_new_comment(comment, request_for_comment, commenting_user)
|
||||
# TODO: check whether we can take the last known locale of the receiver?
|
||||
token = AuthenticationToken.generate!(request_for_comment.user)
|
||||
@receiver_displayname = request_for_comment.user.displayname
|
||||
@commenting_user_displayname = commenting_user.displayname
|
||||
@comment_text = comment.text
|
||||
@rfc_link = request_for_comment_url(request_for_comment)
|
||||
@rfc_link = request_for_comment_url(request_for_comment, token: token.shared_secret)
|
||||
mail(
|
||||
subject: t('mailers.user_mailer.got_new_comment.subject',
|
||||
commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email
|
||||
@ -31,10 +32,11 @@ class UserMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def got_new_comment_for_subscription(comment, subscription, from_user)
|
||||
token = AuthenticationToken.generate!(subscription.user)
|
||||
@receiver_displayname = subscription.user.displayname
|
||||
@author_displayname = from_user.displayname
|
||||
@comment_text = comment.text
|
||||
@rfc_link = request_for_comment_url(subscription.request_for_comment)
|
||||
@rfc_link = request_for_comment_url(subscription.request_for_comment, token: token.shared_secret)
|
||||
@unsubscribe_link = unsubscribe_subscription_url(subscription)
|
||||
mail(
|
||||
subject: t('mailers.user_mailer.got_new_comment_for_subscription.subject',
|
||||
@ -42,11 +44,12 @@ class UserMailer < ApplicationMailer
|
||||
)
|
||||
end
|
||||
|
||||
def send_thank_you_note(request_for_comments, receiver)
|
||||
def send_thank_you_note(request_for_comment, receiver)
|
||||
token = AuthenticationToken.generate!(request_for_comment.user)
|
||||
@receiver_displayname = receiver.displayname
|
||||
@author = request_for_comments.user.displayname
|
||||
@thank_you_note = request_for_comments.thank_you_note
|
||||
@rfc_link = request_for_comment_url(request_for_comments)
|
||||
@author = request_for_comment.user.displayname
|
||||
@thank_you_note = request_for_comment.thank_you_note
|
||||
@rfc_link = request_for_comment_url(request_for_comment, token: token.shared_secret)
|
||||
mail(subject: t('mailers.user_mailer.send_thank_you_note.subject', author: @author), to: receiver.email)
|
||||
end
|
||||
|
||||
|
@ -8,10 +8,19 @@ class ApplicationRecord < ActiveRecord::Base
|
||||
def strip_strings
|
||||
# trim whitespace from beginning and end of string attributes
|
||||
# except for the `content` of CodeOcean::Files
|
||||
attribute_names.without('content').each do |name|
|
||||
# and except the `log` of TestrunMessages or the `output` of Testruns
|
||||
attribute_names.without('content', 'log', 'output').each do |name|
|
||||
if send(name.to_sym).respond_to?(:strip)
|
||||
send("#{name}=".to_sym, send(name).strip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.ransackable_associations(_auth_object = nil)
|
||||
[]
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
15
app/models/authentication_token.rb
Normal file
15
app/models/authentication_token.rb
Normal file
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'securerandom'
|
||||
|
||||
class AuthenticationToken < ApplicationRecord
|
||||
include Creation
|
||||
|
||||
def self.generate!(user)
|
||||
create!(
|
||||
shared_secret: SecureRandom.hex(32),
|
||||
user: user,
|
||||
expire_at: 7.days.from_now
|
||||
)
|
||||
end
|
||||
end
|
@ -56,6 +56,17 @@ module CodeOcean
|
||||
define_method("#{role}?") { self.role == role }
|
||||
end
|
||||
|
||||
def read
|
||||
if native_file?
|
||||
valid = Pathname(native_file.current_path).fnmatch? ::File.join(native_file.root, '**')
|
||||
return nil unless valid
|
||||
|
||||
native_file.read
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def ancestor_id
|
||||
file_id || id
|
||||
end
|
||||
@ -83,12 +94,7 @@ module CodeOcean
|
||||
end
|
||||
|
||||
def hash_content
|
||||
self.hashed_content = Digest::MD5.new.hexdigest(if file_type.try(:binary?)
|
||||
::File.new(native_file.file.path,
|
||||
'r').read
|
||||
else
|
||||
content
|
||||
end)
|
||||
self.hashed_content = Digest::MD5.new.hexdigest(read || '')
|
||||
end
|
||||
private :hash_content
|
||||
|
||||
|
@ -1,12 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DefaultValues
|
||||
# rubocop:disable Naming/AccessorMethodName
|
||||
def set_default_values_if_present(options = {})
|
||||
options.each do |attribute, value|
|
||||
send(:"#{attribute}=", send(:"#{attribute}") || value) if has_attribute?(attribute)
|
||||
end
|
||||
end
|
||||
private :set_default_values_if_present
|
||||
# rubocop:enable Naming/AccessorMethodName
|
||||
end
|
||||
|
@ -14,4 +14,8 @@ class Consumer < ApplicationRecord
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[id]
|
||||
end
|
||||
end
|
||||
|
@ -16,6 +16,7 @@ class ExecutionEnvironment < ApplicationRecord
|
||||
has_many :exercises
|
||||
belongs_to :file_type
|
||||
has_many :error_templates
|
||||
belongs_to :testrun_execution_environment, optional: true, dependent: :destroy
|
||||
|
||||
scope :with_exercises, -> { where('id IN (SELECT execution_environment_id FROM exercises)') }
|
||||
|
||||
@ -60,6 +61,10 @@ class ExecutionEnvironment < ApplicationRecord
|
||||
exposed_ports.join(', ')
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[id]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_values
|
||||
|
@ -94,7 +94,7 @@ class Exercise < ApplicationRecord
|
||||
(SELECT user_id,
|
||||
user_type,
|
||||
score,
|
||||
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
|
||||
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
user_type,
|
||||
@ -103,7 +103,7 @@ class Exercise < ApplicationRecord
|
||||
(created_at - lag(created_at) over (PARTITION BY user_id, exercise_id
|
||||
ORDER BY created_at)) AS working_time
|
||||
FROM submissions
|
||||
WHERE exercise_id=#{id}) AS foo) AS bar
|
||||
WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])}) AS foo) AS bar
|
||||
GROUP BY user_id, user_type
|
||||
"
|
||||
end
|
||||
@ -118,7 +118,7 @@ class Exercise < ApplicationRecord
|
||||
(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}),
|
||||
WHERE #{self.class.sanitize_sql(['exercise_id = ? and study_group_id = ?', exercise_id, study_group_id])} #{self.class.sanitize_sql(additional_filter)}),
|
||||
working_time_with_deltas_ignored AS (
|
||||
SELECT user_id,
|
||||
user_type,
|
||||
@ -126,7 +126,7 @@ class Exercise < ApplicationRecord
|
||||
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
|
||||
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_filtered
|
||||
FROM working_time_between_submissions
|
||||
),
|
||||
working_times_with_score_expanded AS (
|
||||
@ -251,7 +251,7 @@ class Exercise < ApplicationRecord
|
||||
end
|
||||
|
||||
def get_quantiles(quantiles)
|
||||
quantiles_str = "[#{quantiles.join(',')}]"
|
||||
quantiles_str = self.class.sanitize_sql("[#{quantiles.join(',')}]")
|
||||
result = ActiveRecord::Base.transaction do
|
||||
self.class.connection.execute("
|
||||
SET LOCAL intervalstyle = 'iso_8601';
|
||||
@ -263,7 +263,7 @@ class Exercise < ApplicationRecord
|
||||
Max(score) AS max_score,
|
||||
(created_at - Lag(created_at) OVER (partition BY user_id, exercise_id ORDER BY created_at)) AS working_time
|
||||
FROM submissions
|
||||
WHERE exercise_id = #{id}
|
||||
WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])}
|
||||
AND user_type = 'ExternalUser'
|
||||
GROUP BY user_id,
|
||||
id,
|
||||
@ -273,7 +273,7 @@ class Exercise < ApplicationRecord
|
||||
Sum(weight) AS max_points
|
||||
FROM files
|
||||
WHERE context_type = 'Exercise'
|
||||
AND context_id = #{id}
|
||||
AND #{self.class.sanitize_sql(['context_id = ?', id])}
|
||||
AND role IN ('teacher_defined_test', 'teacher_defined_linter')
|
||||
GROUP BY context_id),
|
||||
-- filter for rows containing max points
|
||||
@ -342,7 +342,7 @@ class Exercise < ApplicationRecord
|
||||
exercise_id,
|
||||
max_score,
|
||||
CASE
|
||||
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
|
||||
WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0'
|
||||
ELSE working_time
|
||||
END AS working_time_new
|
||||
FROM all_working_times_until_max ), result AS
|
||||
@ -372,11 +372,11 @@ class Exercise < ApplicationRecord
|
||||
end
|
||||
|
||||
def retrieve_working_time_statistics
|
||||
@working_time_statistics = {}
|
||||
@working_time_statistics = {'InternalUser' => {}, 'ExternalUser' => {}}
|
||||
ActiveRecord::Base.transaction do
|
||||
self.class.connection.execute("SET LOCAL intervalstyle = 'postgres'")
|
||||
self.class.connection.execute(user_working_time_query).each do |tuple|
|
||||
@working_time_statistics[tuple['user_id'].to_i] = tuple
|
||||
@working_time_statistics[tuple['user_type']][tuple['user_id'].to_i] = tuple
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -387,14 +387,14 @@ class Exercise < ApplicationRecord
|
||||
self.class.connection.execute("
|
||||
SELECT avg(working_time) as average_time
|
||||
FROM
|
||||
(#{user_working_time_query}) AS baz;
|
||||
(#{self.class.sanitize_sql(user_working_time_query)}) AS baz;
|
||||
").first['average_time']
|
||||
end
|
||||
end
|
||||
|
||||
def average_working_time_for(user_id)
|
||||
def average_working_time_for(user)
|
||||
retrieve_working_time_statistics if @working_time_statistics.nil?
|
||||
@working_time_statistics[user_id]['working_time']
|
||||
@working_time_statistics[user.class.name][user.id]['working_time']
|
||||
end
|
||||
|
||||
def accumulated_working_time_for_only(user)
|
||||
@ -445,7 +445,7 @@ class Exercise < ApplicationRecord
|
||||
|
||||
FILTERED_TIMES_UNTIL_MAX AS
|
||||
(
|
||||
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
|
||||
SELECT user_id,exercise_id, max_score, CASE WHEN #{StatisticsHelper.working_time_larger_delta} 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
|
||||
@ -597,4 +597,12 @@ cause: %w[submit assess remoteSubmit remoteAssess]}).distinct
|
||||
WHERE exercise_id = #{id}
|
||||
) AS t ON t.fv = submissions.id").distinct
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[title]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(_auth_object = nil)
|
||||
%w[execution_environment]
|
||||
end
|
||||
end
|
||||
|
@ -31,4 +31,8 @@ working_time: time_to_f(item.exercise.average_working_time)}
|
||||
def to_s
|
||||
"#{I18n.t('activerecord.models.exercise_collection.one')}: #{name} (#{id})"
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[id]
|
||||
end
|
||||
end
|
||||
|
@ -246,4 +246,8 @@ class ProxyExercise < ApplicationRecord
|
||||
def select_easiest_exercise(exercises)
|
||||
exercises.order(:expected_difficulty).first
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[title]
|
||||
end
|
||||
end
|
||||
|
@ -22,27 +22,6 @@ class RequestForComment < ApplicationRecord
|
||||
|
||||
# after_save :trigger_rfc_action_cable
|
||||
|
||||
# not used right now, finds the last submission for the respective user and exercise.
|
||||
# might be helpful to check whether the exercise has been solved in the meantime.
|
||||
def last_submission
|
||||
Submission.find_by_sql(" select * from submissions
|
||||
where exercise_id = #{exercise_id} AND
|
||||
user_id = #{user_id}
|
||||
order by created_at desc
|
||||
limit 1").first
|
||||
end
|
||||
|
||||
# not used any longer, since we directly saved the submission_id now.
|
||||
# Was used before that to determine the submission belonging to the request_for_comment.
|
||||
def last_submission_before_creation
|
||||
Submission.find_by_sql(" select * from submissions
|
||||
where exercise_id = #{exercise_id} AND
|
||||
user_id = #{user_id} AND
|
||||
'#{created_at.localtime}' > created_at
|
||||
order by created_at desc
|
||||
limit 1").first
|
||||
end
|
||||
|
||||
def comments_count
|
||||
submission.files.sum {|file| file.comments.size }
|
||||
end
|
||||
@ -89,7 +68,7 @@ class RequestForComment < ApplicationRecord
|
||||
end
|
||||
|
||||
def last_per_user(count = 5)
|
||||
from("(#{row_number_user_sql}) as request_for_comments")
|
||||
from(row_number_user_sql, :request_for_comments)
|
||||
.where('row_number <= ?', count)
|
||||
.group('request_for_comments.id, request_for_comments.user_id, request_for_comments.user_type, ' \
|
||||
'request_for_comments.exercise_id, request_for_comments.file_id, request_for_comments.question, ' \
|
||||
@ -98,10 +77,18 @@ class RequestForComment < ApplicationRecord
|
||||
# ugly, but necessary
|
||||
end
|
||||
|
||||
def ransackable_associations(_auth_object = nil)
|
||||
%w[exercise submission]
|
||||
end
|
||||
|
||||
def ransackable_attributes(_auth_object = nil)
|
||||
%w[solved]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def row_number_user_sql
|
||||
select('id, user_id, user_type, exercise_id, file_id, question, created_at, updated_at, solved, full_score_reached, submission_id, row_number() OVER (PARTITION BY user_id, user_type ORDER BY created_at DESC) as row_number').to_sql
|
||||
select('id, user_id, user_type, exercise_id, file_id, question, created_at, updated_at, solved, full_score_reached, submission_id, row_number() OVER (PARTITION BY user_id, user_type ORDER BY created_at DESC) as row_number')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -62,10 +62,11 @@ class Runner < ApplicationRecord
|
||||
# initializing its Runner::Connection with the given event loop. The Runner::Connection class ensures that
|
||||
# this event loop is stopped after the socket was closed.
|
||||
event_loop = Runner::EventLoop.new
|
||||
socket = @strategy.attach_to_execution(command, event_loop, &block)
|
||||
socket = @strategy.attach_to_execution(command, event_loop, starting_time, &block)
|
||||
event_loop.wait
|
||||
raise socket.error if socket.error.present?
|
||||
rescue Runner::Error => e
|
||||
e.starting_time = starting_time
|
||||
e.execution_duration = Time.zone.now - starting_time
|
||||
raise
|
||||
end
|
||||
@ -74,29 +75,34 @@ class Runner < ApplicationRecord
|
||||
end
|
||||
|
||||
def execute_command(command, raise_exception: true)
|
||||
output = {}
|
||||
stdout = +''
|
||||
stderr = +''
|
||||
output = {
|
||||
stdout: +'',
|
||||
stderr: +'',
|
||||
messages: [],
|
||||
exit_code: 1, # default to error
|
||||
}
|
||||
try = 0
|
||||
|
||||
begin
|
||||
if try.nonzero?
|
||||
request_new_id
|
||||
save
|
||||
end
|
||||
|
||||
exit_code = 1 # default to error
|
||||
execution_time = attach_to_execution(command) do |socket|
|
||||
execution_time = attach_to_execution(command) do |socket, starting_time|
|
||||
socket.on :stderr do |data|
|
||||
stderr << data
|
||||
output[:stderr] << data
|
||||
output[:messages].push({cmd: :write, stream: :stderr, log: data, timestamp: Time.zone.now - starting_time})
|
||||
end
|
||||
socket.on :stdout do |data|
|
||||
stdout << data
|
||||
output[:stdout] << data
|
||||
output[:messages].push({cmd: :write, stream: :stdout, log: data, timestamp: Time.zone.now - starting_time})
|
||||
end
|
||||
socket.on :exit do |received_exit_code|
|
||||
exit_code = received_exit_code
|
||||
output[:exit_code] = received_exit_code
|
||||
end
|
||||
end
|
||||
output.merge!(container_execution_time: execution_time, status: exit_code.zero? ? :ok : :failed)
|
||||
output.merge!(container_execution_time: execution_time, status: output[:exit_code].zero? ? :ok : :failed)
|
||||
rescue Runner::Error::ExecutionTimeout => e
|
||||
Rails.logger.debug { "Running command `#{command}` timed out: #{e.message}" }
|
||||
output.merge!(status: :timeout, container_execution_time: e.execution_duration)
|
||||
@ -115,12 +121,13 @@ class Runner < ApplicationRecord
|
||||
output.merge!(status: :failed, container_execution_time: e.execution_duration)
|
||||
rescue Runner::Error => e
|
||||
Rails.logger.debug { "Running command `#{command}` failed: #{e.message}" }
|
||||
output.merge!(status: :failed, container_execution_time: e.execution_duration)
|
||||
output.merge!(status: :container_depleted, container_execution_time: e.execution_duration)
|
||||
ensure
|
||||
# We forward the exception if requested
|
||||
raise e if raise_exception && defined?(e) && e.present?
|
||||
|
||||
output.merge!(stdout: stdout, stderr: stderr)
|
||||
# If the process was killed with SIGKILL, it is most likely that the OOM killer was triggered.
|
||||
output[:status] = :out_of_memory if output[:exit_code] == 137
|
||||
end
|
||||
end
|
||||
|
||||
@ -147,13 +154,13 @@ class Runner < ApplicationRecord
|
||||
rescue Runner::Error
|
||||
# An additional error was raised during synchronization
|
||||
raise Runner::Error::EnvironmentNotFound.new(
|
||||
"The execution environment with id #{execution_environment.id} was not found by the runner management. "\
|
||||
"The execution environment with id #{execution_environment.id} was not found by the runner management. " \
|
||||
'In addition, it could not be synced so that this probably indicates a permanent error.'
|
||||
)
|
||||
else
|
||||
# No error was raised during synchronization
|
||||
raise Runner::Error::EnvironmentNotFound.new(
|
||||
"The execution environment with id #{execution_environment.id} was not found yet by the runner management. "\
|
||||
"The execution environment with id #{execution_environment.id} was not found yet by the runner management. " \
|
||||
'It has been successfully synced now so that the next request should be successful.'
|
||||
)
|
||||
end
|
||||
|
@ -19,4 +19,12 @@ class StudyGroup < ApplicationRecord
|
||||
def to_s
|
||||
name.presence || "StudyGroup #{id}"
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[name]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(_auth_object = nil)
|
||||
%w[consumer]
|
||||
end
|
||||
end
|
||||
|
@ -9,7 +9,7 @@ class Submission < ApplicationRecord
|
||||
remoteSubmit].freeze
|
||||
FILENAME_URL_PLACEHOLDER = '{filename}'
|
||||
MAX_COMMENTS_ON_RECOMMENDED_RFC = 5
|
||||
OLDEST_RFC_TO_SHOW = 6.months
|
||||
OLDEST_RFC_TO_SHOW = 1.month
|
||||
|
||||
belongs_to :exercise
|
||||
belongs_to :study_group, optional: true
|
||||
@ -46,6 +46,8 @@ class Submission < ApplicationRecord
|
||||
|
||||
validates :cause, inclusion: {in: CAUSES}
|
||||
|
||||
attr_reader :used_execution_environment
|
||||
|
||||
# after_save :trigger_working_times_action_cable
|
||||
|
||||
def build_files_hash(files, attribute)
|
||||
@ -74,7 +76,6 @@ class Submission < ApplicationRecord
|
||||
end
|
||||
|
||||
def normalized_score
|
||||
::NewRelic::Agent.add_custom_attributes({unnormalized_score: score})
|
||||
if !score.nil? && !exercise.maximum_score.nil? && exercise.maximum_score.positive?
|
||||
score / exercise.maximum_score
|
||||
else
|
||||
@ -190,12 +191,17 @@ class Submission < ApplicationRecord
|
||||
result.merge(output)
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[study_group_id]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepared_runner
|
||||
request_time = Time.zone.now
|
||||
begin
|
||||
runner = Runner.for(user, exercise.execution_environment)
|
||||
@used_execution_environment = AwsStudy.get_execution_environment(user, exercise)
|
||||
runner = Runner.for(user, @used_execution_environment)
|
||||
files = collect_files
|
||||
files.reject!(&:teacher_defined_assessment?) if cause == 'run'
|
||||
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Copying files to Runner #{runner.id} for #{user_type} #{user_id} and Submission #{id}." }
|
||||
@ -249,10 +255,14 @@ class Submission < ApplicationRecord
|
||||
cause: 'assess', # Required to differ run and assess for RfC show
|
||||
file: file, # Test file that was executed
|
||||
passed: passed,
|
||||
output: testrun_output,
|
||||
exit_code: output[:exit_code],
|
||||
status: output[:status],
|
||||
output: testrun_output.presence,
|
||||
container_execution_time: output[:container_execution_time],
|
||||
waiting_for_container_time: output[:waiting_for_container_time]
|
||||
)
|
||||
TestrunMessage.create_for(testrun, output[:messages])
|
||||
TestrunExecutionEnvironment.create(testrun: testrun, execution_environment: @used_execution_environment)
|
||||
|
||||
filename = file.filepath
|
||||
|
||||
@ -266,6 +276,7 @@ class Submission < ApplicationRecord
|
||||
|
||||
output.merge!(assessment)
|
||||
output.merge!(filename: filename, message: feedback_message(file, output), weight: file.weight)
|
||||
output.except!(:messages)
|
||||
end
|
||||
|
||||
def feedback_message(file, output)
|
||||
|
@ -3,4 +3,22 @@
|
||||
class Testrun < ApplicationRecord
|
||||
belongs_to :file, class_name: 'CodeOcean::File', optional: true
|
||||
belongs_to :submission
|
||||
belongs_to :testrun_execution_environment, optional: true, dependent: :destroy
|
||||
has_many :testrun_messages, dependent: :destroy
|
||||
|
||||
enum status: {
|
||||
ok: 0,
|
||||
failed: 1,
|
||||
container_depleted: 2,
|
||||
timeout: 3,
|
||||
out_of_memory: 4,
|
||||
terminated_by_client: 5,
|
||||
}, _default: :ok, _prefix: true
|
||||
|
||||
validates :exit_code, numericality: {only_integer: true, min: 0, max: 255}, allow_nil: true
|
||||
validates :status, presence: true
|
||||
|
||||
def log
|
||||
testrun_messages.output.select(:log).map(&:log).join.presence
|
||||
end
|
||||
end
|
||||
|
6
app/models/testrun_execution_environment.rb
Normal file
6
app/models/testrun_execution_environment.rb
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TestrunExecutionEnvironment < ApplicationRecord
|
||||
belongs_to :testrun
|
||||
belongs_to :execution_environment
|
||||
end
|
94
app/models/testrun_message.rb
Normal file
94
app/models/testrun_message.rb
Normal file
@ -0,0 +1,94 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TestrunMessage < ApplicationRecord
|
||||
belongs_to :testrun
|
||||
|
||||
enum cmd: {
|
||||
input: 0,
|
||||
write: 1,
|
||||
clear: 2,
|
||||
turtle: 3,
|
||||
turtlebatch: 4,
|
||||
render: 5,
|
||||
exit: 6,
|
||||
status: 7,
|
||||
hint: 8,
|
||||
client_kill: 9,
|
||||
exception: 10,
|
||||
result: 11,
|
||||
canvasevent: 12,
|
||||
timeout: 13, # TODO: Shouldn't be in the data, this is a status and can be removed after the migration finished
|
||||
out_of_memory: 14, # TODO: Shouldn't be in the data, this is a status and can be removed after the migration finished
|
||||
}, _default: :write, _prefix: true
|
||||
|
||||
enum stream: {
|
||||
stdin: 0,
|
||||
stdout: 1,
|
||||
stderr: 2,
|
||||
}, _prefix: true
|
||||
|
||||
validates :cmd, presence: true
|
||||
validates :timestamp, presence: true
|
||||
validates :stream, length: {minimum: 0, allow_nil: false}, if: -> { cmd_write? }
|
||||
validates :log, length: {minimum: 0, allow_nil: false}, if: -> { cmd_write? }
|
||||
validate :either_data_or_log
|
||||
|
||||
default_scope { order(timestamp: :asc) }
|
||||
scope :output, -> { where(cmd: 1, stream: %i[stdout stderr]) }
|
||||
|
||||
def self.create_for(testrun, messages)
|
||||
# We don't want to store anything if the testrun passed
|
||||
return if testrun.passed?
|
||||
|
||||
messages.map! do |message|
|
||||
# We create a new hash and move all known keys
|
||||
result = {}
|
||||
result[:testrun] = testrun
|
||||
result[:log] = (message.delete(:log) || message.delete(:data)) if message[:cmd] == :write || message.key?(:log)
|
||||
result[:timestamp] = message.delete :timestamp
|
||||
result[:stream] = message.delete :stream if message.key?(:stream)
|
||||
result[:cmd] = message.delete :cmd
|
||||
# The remaining keys will be stored in the `data` column
|
||||
result[:data] = message.presence if message.present?
|
||||
result
|
||||
end
|
||||
|
||||
# Before storing all messages, we truncate some to save storage
|
||||
filtered_messages = filter_messages_by_size testrun, messages
|
||||
|
||||
# An array with hashes is passed, all are stored
|
||||
TestrunMessage.create!(filtered_messages)
|
||||
end
|
||||
|
||||
def self.filter_messages_by_size(testrun, messages)
|
||||
limits = if testrun.submission.cause == 'requestComments'
|
||||
{data: {limit: 25, size: 0}, log: {limit: 5000, size: 0}}
|
||||
else
|
||||
{data: {limit: 10, size: 0}, log: {limit: 500, size: 0}}
|
||||
end
|
||||
|
||||
filtered_messages = messages.map do |message|
|
||||
if message.key?(:log) && limits[:log][:size] < limits[:log][:limit]
|
||||
message[:log] = message[:log][0, limits[:log][:limit] - limits[:log][:size]]
|
||||
limits[:log][:size] += message[:log].size
|
||||
elsif message[:data] && limits[:data][:size] < limits[:data][:limit]
|
||||
limits[:data][:size] += 1
|
||||
elsif !message.key?(:log) && limits[:data][:size] < limits[:data][:limit]
|
||||
# Accept short TestrunMessages (e.g. just transporting a status information)
|
||||
# without increasing the `limits[:data][:limit]` before the limit is reached
|
||||
else
|
||||
# Clear all remaining messages
|
||||
message = nil
|
||||
end
|
||||
message
|
||||
end
|
||||
filtered_messages.select(&:present?)
|
||||
end
|
||||
|
||||
def either_data_or_log
|
||||
if [data, log].count(&:present?) > 1
|
||||
errors.add(log, "can't be present if data is also present")
|
||||
end
|
||||
end
|
||||
private :either_data_or_log
|
||||
end
|
@ -6,6 +6,7 @@ class User < ApplicationRecord
|
||||
ROLES = %w[admin teacher learner].freeze
|
||||
|
||||
belongs_to :consumer
|
||||
has_many :authentication_token, dependent: :destroy
|
||||
has_many :study_group_memberships, as: :user
|
||||
has_many :study_groups, through: :study_group_memberships, as: :user
|
||||
has_many :exercises, as: :user
|
||||
@ -40,4 +41,8 @@ class User < ApplicationRecord
|
||||
def to_s
|
||||
displayname
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(_auth_object = nil)
|
||||
%w[name email external_id consumer_id role]
|
||||
end
|
||||
end
|
||||
|
@ -5,7 +5,7 @@ class ExercisePolicy < AdminOrAuthorPolicy
|
||||
admin?
|
||||
end
|
||||
|
||||
%i[show? feedback? statistics? rfcs_for_exercise?].each do |action|
|
||||
%i[show? feedback? statistics? external_user_statistics? rfcs_for_exercise?].each do |action|
|
||||
define_method(action) { admin? || teacher_in_study_group? || (teacher? && @record.public?) || author? }
|
||||
end
|
||||
|
||||
@ -38,7 +38,13 @@ class ExercisePolicy < AdminOrAuthorPolicy
|
||||
if @user.admin?
|
||||
@scope.all
|
||||
elsif @user.teacher?
|
||||
@scope.where('user_id = ? OR public = TRUE', @user.id)
|
||||
@scope.where(
|
||||
'user_id IN (SELECT user_id FROM study_group_memberships WHERE study_group_id IN (?))
|
||||
OR (user_id = ? AND user_type = ?)
|
||||
OR public = TRUE',
|
||||
@user.study_groups.pluck(:id),
|
||||
@user.id, @user.class.name
|
||||
)
|
||||
else
|
||||
@scope.none
|
||||
end
|
||||
|
@ -25,6 +25,10 @@ class RequestForCommentPolicy < ApplicationPolicy
|
||||
admin? || author?
|
||||
end
|
||||
|
||||
def clear_question?
|
||||
admin? || teacher_in_study_group?
|
||||
end
|
||||
|
||||
def edit?
|
||||
admin?
|
||||
end
|
||||
|
@ -126,11 +126,11 @@ module ProformaService
|
||||
|
||||
def add_content_to_task_file(file, task_file)
|
||||
if file.native_file.present?
|
||||
file = ::File.new(file.native_file.file.path, 'r')
|
||||
task_file.content = file.read
|
||||
file_content = file.read
|
||||
task_file.content = file_content
|
||||
task_file.used_by_grader = false
|
||||
task_file.binary = true
|
||||
task_file.mimetype = MimeMagic.by_magic(file).type
|
||||
task_file.mimetype = MimeMagic.by_magic(file_content).type
|
||||
else
|
||||
task_file.content = file.content
|
||||
task_file.used_by_grader = true
|
||||
|
@ -6,7 +6,7 @@ module CodeOcean
|
||||
existing_files = File.where(name: record.name, path: record.path, file_type_id: record.file_type_id,
|
||||
context_id: record.context_id, context_type: record.context_type).to_a
|
||||
if !existing_files.empty? && (!record.context.is_a?(Exercise) || record.context.new_record?)
|
||||
record.errors[:base] << 'Duplicate'
|
||||
record.errors.add(:base, 'Duplicate')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -2,8 +2,8 @@
|
||||
// Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326.
|
||||
Otherwise, the global variable `vis` might be uninitialized in the assets (race condition)
|
||||
meta name='turbolinks-visit-control' content='reload'
|
||||
= javascript_pack_tag('vis', 'data-turbolinks-track': true)
|
||||
= stylesheet_pack_tag('vis', media: 'all', 'data-turbolinks-track': true)
|
||||
- append_javascript_pack_tag('vis')
|
||||
- append_stylesheet_pack_tag('vis')
|
||||
|
||||
h1 = t('breadcrumbs.dashboard.show')
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
- if model = Kernel.const_get(controller_path.classify) rescue nil
|
||||
- model = controller_path.classify.constantize rescue nil
|
||||
- if model
|
||||
- object = model.find_by(id: params[:id])
|
||||
- if model.try(:nested_resource?)
|
||||
- root_element = model.model_name.human(count: 2)
|
||||
@ -18,14 +19,17 @@
|
||||
|
||||
- title = "#{active_action} - #{application_name}"
|
||||
- content_for :breadcrumbs do
|
||||
.container
|
||||
ul.breadcrumb
|
||||
.container.mb-4
|
||||
ul.breadcrumb.bg-light.px-3.py-2
|
||||
- if root_element.present?
|
||||
li.breadcrumb-item = root_element
|
||||
li.breadcrumb-item.small
|
||||
= root_element
|
||||
- if current_element.present?
|
||||
li.breadcrumb-item = current_element
|
||||
li.breadcrumb-item.small
|
||||
= current_element
|
||||
- title = "#{object} - #{title}"
|
||||
- else
|
||||
- title = "#{model.model_name.human(count: 2)} - #{title}"
|
||||
li.breadcrumb-item.active = active_action
|
||||
li.breadcrumb-item.active.small
|
||||
= active_action
|
||||
- content_for :title, title
|
||||
|
@ -4,5 +4,4 @@
|
||||
- flash_mapping = {'alert' => 'warning', 'notice' => 'success'}
|
||||
div.alert.flash class="alert-#{flash_mapping.fetch(severity, severity)} alert-dismissible fade show"
|
||||
p.mb-0 id="flash-#{severity}" == flash[severity]
|
||||
button type="button" class="close" data-dismiss="alert" aria-label="Close"
|
||||
span.text-white aria-hidden="true" ×
|
||||
button.btn-close type="button" data-bs-dismiss="alert" aria-label="Close"
|
||||
|
@ -1,7 +1,7 @@
|
||||
li.nav-item.dropdown
|
||||
a.nav-link.dropdown-toggle.mx-3 data-toggle='dropdown' href='#'
|
||||
a.nav-link.dropdown-toggle.mx-3 data-bs-toggle='dropdown' href='#'
|
||||
= t("locales.#{I18n.locale}")
|
||||
span.caret
|
||||
ul.dropdown-menu.p-0.mt-1 role='menu'
|
||||
- I18n.available_locales.sort_by { |locale| t("locales.#{locale}") }.each do |locale|
|
||||
li = link_to(t("locales.#{locale}"), url_for(params.permit!.merge(locale: locale)), 'data-turbolinks' => "false", class: 'dropdown-item')
|
||||
li = link_to(t("locales.#{locale}"), url_for(request.query_parameters.merge(locale: locale)), 'data-turbolinks' => "false", class: 'dropdown-item')
|
||||
|
@ -1,7 +1,7 @@
|
||||
- if current_user.try(:admin?) or current_user.try(:teacher?)
|
||||
ul.nav.navbar-nav
|
||||
li.nav-item.dropdown
|
||||
a.nav-link.dropdown-toggle.mx-3 data-toggle='dropdown' href='#'
|
||||
a.nav-link.dropdown-toggle.mx-3 data-bs-toggle='dropdown' href='#'
|
||||
= t('shared.administration')
|
||||
span.caret
|
||||
ul.dropdown-menu.p-0.mt-1 role='menu'
|
||||
|
@ -1,7 +1,7 @@
|
||||
- if models.any? { |model| policy(model).index? }
|
||||
li.dropdown-submenu
|
||||
- link = link.nil? ? "#" : link
|
||||
a.dropdown-item.dropdown-toggle href=link data-toggle="dropdown" = title
|
||||
a.dropdown-item.dropdown-toggle href=link data-bs-toggle="dropdown" = title
|
||||
ul.dropdown-menu.p-0
|
||||
- models.each do |model|
|
||||
= render('navigation_collection_link', model: model, cached: true)
|
||||
|
@ -1,7 +1,7 @@
|
||||
- if current_user
|
||||
li.nav-item.dropdown
|
||||
a.nav-link.dropdown-toggle data-toggle='dropdown' href='#'
|
||||
i.fa.fa-user
|
||||
a.nav-link.dropdown-toggle data-bs-toggle='dropdown' href='#'
|
||||
i.fa-solid.fa-user
|
||||
= current_user
|
||||
span.caret
|
||||
ul.dropdown-menu.p-0.mt-1 role='menu'
|
||||
@ -14,5 +14,5 @@
|
||||
li = link_to(t('sessions.destroy.link'), sign_out_path, method: :delete, class: 'dropdown-item')
|
||||
- else
|
||||
li.nav-item = link_to(sign_in_path, class: 'nav-link') do
|
||||
i.fa.fa-sign-in
|
||||
i.fa-solid.fa-arrow-right-to-bracket
|
||||
= t('sessions.new.link')
|
||||
|
@ -1,19 +1,19 @@
|
||||
= form_for(CodeOcean::File.new) do |f|
|
||||
.form-group
|
||||
= f.label(:name, t('activerecord.attributes.file.name'))
|
||||
.mb-3
|
||||
= f.label(:name, t('activerecord.attributes.file.name'), class: 'form-label')
|
||||
= f.text_field(:name, class: 'form-control', required: true)
|
||||
.form-group
|
||||
= f.label(:path, t('activerecord.attributes.file.path'))
|
||||
.mb-3
|
||||
= f.label(:path, t('activerecord.attributes.file.path'), class: 'form-label')
|
||||
|
|
||||
a.toggle-input data={text_initial: t('shared.new'), text_toggled: t('shared.back')} href='#' = t('shared.new')
|
||||
.original-input = f.select(:path, @paths, {}, class: 'form-control')
|
||||
= f.text_field(:path, class: 'alternative-input form-control', disabled: true)
|
||||
.form-group
|
||||
= f.label(:file_type_id, t('activerecord.attributes.file.file_type_id'))
|
||||
.mb-3
|
||||
= f.label(:file_type_id, t('activerecord.attributes.file.file_type_id'), class: 'form-label')
|
||||
= f.collection_select(:file_type_id, FileType.where(binary: false).order(:name), :id, :name, {selected: @exercise.execution_environment.file_type.try(:id)}, class: 'form-control')
|
||||
- if FileTemplate.any?
|
||||
.form-group
|
||||
= f.label(:file_template_id, t('activerecord.attributes.file.file_template_id'))
|
||||
.mb-3
|
||||
= f.label(:file_template_id, t('activerecord.attributes.file.file_template_id'), class: 'form-label')
|
||||
= f.collection_select(:file_template_id, FileTemplate.all.order(:name), :id, :name, {:include_blank => true}, class: 'form-control')
|
||||
= f.hidden_field(:context_id)
|
||||
.d-none#noTemplateLabel data-text=t('file_template.no_template_label')
|
||||
|
@ -1,13 +1,13 @@
|
||||
= form_for(@codeharbor_link) do |f|
|
||||
= render('shared/form_errors', object: @codeharbor_link)
|
||||
.form-group
|
||||
= f.label(:push_url)
|
||||
.mb-3
|
||||
= f.label(:push_url, class: 'form-label')
|
||||
= f.text_field :push_url, data: {toggle: 'tooltip', placement: 'bottom'}, title: t('codeharbor_link.info.push_url'), class: 'form-control'
|
||||
.form-group
|
||||
= f.label(:check_uuid_url)
|
||||
.mb-3
|
||||
= f.label(:check_uuid_url, class: 'form-label')
|
||||
= f.text_field :check_uuid_url, data: {toggle: 'tooltip', placement: 'bottom'}, title: t('codeharbor_link.info.check_uuid_url'), class: 'form-control'
|
||||
.form-group
|
||||
= f.label(:api_key)
|
||||
.mb-3
|
||||
= f.label(:api_key, class: 'form-label')
|
||||
.input-group
|
||||
= f.text_field(:api_key, data: {toggle: 'tooltip', placement: 'bottom'}, title: t('codeharbor_link.info.api_key'), class: 'form-control api_key')
|
||||
.input-group-btn
|
||||
@ -15,5 +15,5 @@
|
||||
.actions
|
||||
= render('shared/submit_button', f: f, object: @codeharbor_link)
|
||||
- if @codeharbor_link.persisted?
|
||||
= link_to(t('codeharbor_link.delete'), codeharbor_link_path(@codeharbor_link), data: {confirm: t('shared.confirm_destroy')}, method: :delete, class: 'btn btn-danger pull-right')
|
||||
= link_to(t('codeharbor_link.delete'), codeharbor_link_path(@codeharbor_link), data: {confirm: t('shared.confirm_destroy')}, method: :delete, class: 'btn btn-danger float-end')
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user