merge master

This commit is contained in:
Karol
2022-08-20 22:20:52 +02:00
291 changed files with 5413 additions and 9429 deletions

View File

@ -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

View File

@ -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 === '/') {

View File

@ -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()) {

View File

@ -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
});

View File

@ -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();
}
});

View File

@ -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();

View File

@ -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'));

View File

@ -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));
},

View File

@ -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');

View File

@ -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');
}
}

View File

@ -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");
});

View File

@ -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));

View File

@ -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');

View File

@ -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);

View File

@ -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">&times;</button>
// <button type="button" class="close" data-bs-dismiss="modal" aria-hidden="true">&times;</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">&times;</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).

View File

@ -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);
});
};

View File

@ -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();

View File

@ -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 ? '&nbsp<font face="webdings">5</font>' : '&nbsp;&#x25B4;';
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 ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
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 ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
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);
}
};
}());
});

View File

@ -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();
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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;

View File

@ -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==");

View File

@ -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 {

View File

@ -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";
}

View File

@ -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/

View File

@ -23,12 +23,6 @@
margin-bottom: 1em;
}
.form-group {
&:not(:last-child) {
margin-right: 1em;
}
}
input, select {
min-width: 200px !important;
}

View File

@ -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);
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -44,7 +44,6 @@ class CodeharborLinksController < ApplicationController
def set_codeharbor_link
@codeharbor_link = CodeharborLink.find(params[:id])
@codeharbor_link.user = current_user
authorize!
end

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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',
},

View File

@ -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
View File

@ -0,0 +1,5 @@
/* eslint no-console:0 */
// JS
import * as d3Tip from 'd3-tip/dist'
window.d3.tip = d3Tip;

View 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'

View File

@ -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;

View File

@ -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'

View File

@ -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;

View File

@ -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'

View File

@ -0,0 +1,5 @@
/* eslint no-console:0 */
// JS
import Sortable from 'sortablejs'
window.Sortable = Sortable;

View File

@ -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
View File

@ -0,0 +1,8 @@
/* eslint no-console:0 */
// JS
import 'vis'
window.vis = vis;
// CSS
import 'vis-timeline/dist/vis-timeline-graph2d.css';

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -14,4 +14,8 @@ class Consumer < ApplicationRecord
def to_s
name
end
def self.ransackable_attributes(_auth_object = nil)
%w[id]
end
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class TestrunExecutionEnvironment < ApplicationRecord
belongs_to :testrun
belongs_to :execution_environment
end

View 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

View File

@ -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

View File

@ -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

View File

@ -25,6 +25,10 @@ class RequestForCommentPolicy < ApplicationPolicy
admin? || author?
end
def clear_question?
admin? || teacher_in_study_group?
end
def edit?
admin?
end

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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" &times;
button.btn-close type="button" data-bs-dismiss="alert" aria-label="Close"

View File

@ -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')

View File

@ -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'

View File

@ -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)

View File

@ -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')

View File

@ -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')
| &nbsp;
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')

View File

@ -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