diff --git a/app/assets/javascripts/editor/evaluation.js b/app/assets/javascripts/editor/evaluation.js
index 23e18bf0..79bd52a8 100644
--- a/app/assets/javascripts/editor/evaluation.js
+++ b/app/assets/javascripts/editor/evaluation.js
@@ -164,12 +164,10 @@ CodeOceanEditorEvaluation = {
} else if (output.stderr) {
//element.addClass('text-warning').append(output.stderr);
element.addClass('text-warning').text(element.text() + output.stderr);
- this.flowrOutputBuffer += 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);
- this.flowrOutputBuffer += output.stdout;
this.QaApiOutputBuffer.stdout += output.stdout;
} else {
element.addClass('text-muted').text($('#output').data('message-no-output'));
diff --git a/app/assets/javascripts/editor/participantsupport.js b/app/assets/javascripts/editor/participantsupport.js
deleted file mode 100644
index 479b09c9..00000000
--- a/app/assets/javascripts/editor/participantsupport.js
+++ /dev/null
@@ -1,96 +0,0 @@
-CodeOceanEditorFlowr = {
- isFlowrEnabled: true,
- flowrResultHtml: '
',
-
- handleStderrOutputForFlowr: function () {
- if (!this.isFlowrEnabled) return;
-
- var flowrUrl = $('#flowrHint').data('url');
- var flowrHintBody = $('#flowrHint .card-body');
- var queryParameters = {
- query: this.flowrOutputBuffer
- };
-
- flowrHintBody.empty();
-
- jQuery.getJSON(flowrUrl, queryParameters, function (data) {
- jQuery.each(data.queryResults, function (index, question) {
- var collapsibleTileHtml = this.flowrResultHtml.replace(/{{collapseId}}/g, 'collapse-' + question).replace(/{{headingId}}/g, 'heading-' + question);
- var resultTile = $(collapsibleTileHtml);
-
- resultTile.find('h4 > a').text(question.title + ' | Found via ' + question.source);
- resultTile.find('.card-body').html(question.body);
- resultTile.find('.card-body').append('Open this question');
-
- flowrHintBody.append(resultTile);
- });
-
- if (data.queryResults.length !== 0) {
- $('#flowrHint').fadeIn();
- }
- });
-
- this.flowrOutputBuffer = '';
- }
-};
-
-CodeOceanEditorCodePilot = {
- qa_api: undefined,
- QaApiOutputBuffer: {'stdout': '', 'stderr': ''},
-
- initializeCodePilot: function () {
- if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined') && QaApi.isBrowserSupported()) {
- $('#editor-column').addClass('col-md-10').removeClass('col-md-12');
- $('#questions-column').addClass('col-md-2');
-
- var node = document.getElementById('questions-holder');
- var url = $('#questions-holder').data('url');
-
- this.qa_api = new QaApi(node, url);
- }
- },
-
- handleQaApiOutput: function () {
- if (this.qa_api) {
- this.qa_api.executeCommand('syncOutput', [[this.QaApiOutputBuffer]]);
- // reset the object
- }
- this.QaApiOutputBuffer = {'stdout': '', 'stderr': ''};
- }
-};
-
-CodeOceanEditorRequestForComments = {
- requestComments: function () {
- var user_id = $('#editor').data('user-id');
- var exercise_id = $('#editor').data('exercise-id');
- var file_id = $('.editor').data('id');
- var question = $('#question').val();
-
- var createRequestForComments = function (submission) {
- $.ajax({
- method: 'POST',
- url: '/request_for_comments',
- data: {
- request_for_comment: {
- exercise_id: exercise_id,
- file_id: file_id,
- submission_id: submission.id,
- question: question
- }
- }
- }).done(function () {
- this.hideSpinner();
- $.flash.success({text: $('#askForCommentsButton').data('message-success')});
- // trigger a run
- this.runSubmission.call(this, submission);
- }.bind(this)).fail(this.ajaxError.bind(this));
- };
-
- this.createSubmission($('#requestComments'), null, createRequestForComments.bind(this));
-
- $('#comment-modal').modal('hide');
- // we disabled the button to prevent that the user spams RFCs, but decided against this now.
- //var button = $('#requestComments');
- //button.prop('disabled', true);
- },
-};
\ No newline at end of file
diff --git a/app/assets/javascripts/editor/participantsupport.js.erb b/app/assets/javascripts/editor/participantsupport.js.erb
new file mode 100644
index 00000000..9a8e54f8
--- /dev/null
+++ b/app/assets/javascripts/editor/participantsupport.js.erb
@@ -0,0 +1,179 @@
+CodeOceanEditorFlowr = {
+ isFlowrEnabled: <%= CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled] %>,
+ flowrResultHtml:
+ '',
+
+ getInsights: function () {
+ <% self.class.include Rails.application.routes.url_helpers %>
+ var flowrUrl = '<%= insights_path %>';
+ var stackOverflowUrl = 'http://api.stackexchange.com/2.2/search/advanced';
+
+ return jQuery.ajax({
+ dataType: "json",
+ url: flowrUrl,
+ data: {}
+ }).then(function (insights) {
+ var stackoverflowRequests = _.map(insights, function (insight) {
+ var queryParams = {
+ accepted: true,
+ pagesize: <%= CodeOcean::Config.new(:code_ocean).read[:flowr][:answers_per_query] or 3 %>,
+ order: 'desc',
+ sort: 'relevance',
+ site: 'stackoverflow',
+ answers: 1,
+ filter: '!23qca9v**HCO.ESF)dHfT', // title, body, accepted answer
+ q: insight.query
+ }
+
+ return jQuery.ajax({
+ dataType: "json",
+ url: stackOverflowUrl,
+ data: queryParams
+ }).promise();
+ });
+ return jQuery.when.apply(jQuery, stackoverflowRequests);
+ });
+ },
+ collectResults: function(response) {
+ var results = [];
+ var addToResultsIfSuccessful = function (data, textStatus, jqXHR) {
+ if (jqXHR && jqXHR.status === 200) {
+ _.each(data.items, function (item) {
+ if (!_.contains(results, item)) {
+ results.push(item);
+ }
+ });
+ }
+ }
+
+ if (_.isArray(response[0])) {
+ // multiple queries
+ _.each(response, function (args) {
+ addToResultsIfSuccessful.apply(this, args)
+ });
+ } else {
+ // single query
+ addToResultsIfSuccessful.apply(this, response);
+ }
+ return results;
+ },
+ createEventHandler: function (eventType, data) {
+ return function () {
+ CodeOceanEditor.publishCodeOceanEvent({
+ category: eventType,
+ data: data,
+ exercise_id: $('#editor').data('exercise-id'),
+ file_id: null
+ });
+ };
+ },
+ handleStderrOutputForFlowr: function () {
+ if (! this.isFlowrEnabled) return;
+
+ var flowrHintBody = $('#flowrHint .card-body');
+ flowrHintBody.empty();
+ var self = this;
+
+ this.getInsights().then(function () {
+ var results = self.collectResults(arguments);
+ _.each(results, function (result, index) {
+ var collapsibleTileHtml = self.flowrResultHtml
+ .replace(/{{collapseId}}/g, 'collapse-' + index).replace(/{{headingId}}/g, 'heading-' + index);
+ var resultTile = $(collapsibleTileHtml);
+ var questionUrl = 'https://stackoverflow.com/questions/' + result.question_id;
+
+ var header = resultTile.find('span');
+ header.text(result.title);
+ header.on('click', self.createEventHandler('editor_flowr_expand_question', questionUrl));
+
+ var body = resultTile.find('.card-body');
+ body.html(result.body);
+ body.append('' +
+ '<%= I18n.t('exercises.implement.flowr.go_to_question') %>');
+ body.find('.btn').on('click', self.createEventHandler('editor_flowr_click_question', questionUrl));
+
+ flowrHintBody.append(resultTile);
+ });
+
+ if (results.length > 0) {
+ $('#flowrHint').fadeIn();
+ }
+ });
+ }
+};
+
+CodeOceanEditorCodePilot = {
+ qa_api: undefined,
+ QaApiOutputBuffer: {'stdout': '', 'stderr': ''},
+
+ initializeCodePilot: function () {
+ if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined') && QaApi.isBrowserSupported()) {
+ $('#editor-column').addClass('col-md-10').removeClass('col-md-12');
+ $('#questions-column').addClass('col-md-2');
+
+ var node = document.getElementById('questions-holder');
+ var url = $('#questions-holder').data('url');
+
+ this.qa_api = new QaApi(node, url);
+ }
+ },
+
+ handleQaApiOutput: function () {
+ if (this.qa_api) {
+ this.qa_api.executeCommand('syncOutput', [[this.QaApiOutputBuffer]]);
+ // reset the object
+ }
+ this.QaApiOutputBuffer = {'stdout': '', 'stderr': ''};
+ }
+};
+
+CodeOceanEditorRequestForComments = {
+ requestComments: function () {
+ var user_id = $('#editor').data('user-id');
+ var exercise_id = $('#editor').data('exercise-id');
+ var file_id = $('.editor').data('id');
+ var question = $('#question').val();
+
+ var createRequestForComments = function (submission) {
+ $.ajax({
+ method: 'POST',
+ url: '/request_for_comments',
+ data: {
+ request_for_comment: {
+ exercise_id: exercise_id,
+ file_id: file_id,
+ submission_id: submission.id,
+ question: question
+ }
+ }
+ }).done(function () {
+ this.hideSpinner();
+ $.flash.success({text: $('#askForCommentsButton').data('message-success')});
+ // trigger a run
+ this.runSubmission.call(this, submission);
+ }.bind(this)).fail(this.ajaxError.bind(this));
+ };
+
+ this.createSubmission($('#requestComments'), null, createRequestForComments.bind(this));
+
+ $('#comment-modal').modal('hide');
+ // we disabled the button to prevent that the user spams RFCs, but decided against this now.
+ //var button = $('#requestComments');
+ //button.prop('disabled', true);
+ },
+};
diff --git a/app/assets/stylesheets/flowrdata.css.scss b/app/assets/stylesheets/flowrdata.css.scss
index c71da7ef..e4706b21 100644
--- a/app/assets/stylesheets/flowrdata.css.scss
+++ b/app/assets/stylesheets/flowrdata.css.scss
@@ -1,3 +1,4 @@
#flowrHint {
display: none;
+ margin-top: 10px;
}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 67c4742e..c58303ba 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -14,8 +14,15 @@ class ApplicationController < ActionController::Base
@current_user ||= ExternalUser.find_by(id: session[:external_user_id]) || login_from_session || login_from_other_sources
end
+ def require_user!
+ raise Pundit::NotAuthorizedError unless current_user
+ end
+
def render_not_authorized
- redirect_to(request.referrer || :root, alert: t('application.not_authorized'))
+ respond_to do |format|
+ format.html { redirect_to(request.referrer || :root, alert: t('application.not_authorized')) }
+ format.json { render json: {error: t('application.not_authorized')}, status: :unauthorized }
+ end
end
private :render_not_authorized
diff --git a/app/controllers/flowr_controller.rb b/app/controllers/flowr_controller.rb
new file mode 100644
index 00000000..e9f742b7
--- /dev/null
+++ b/app/controllers/flowr_controller.rb
@@ -0,0 +1,41 @@
+class FlowrController < ApplicationController
+
+ def insights
+ require_user!
+ # 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
+
+ # Return if no submission was found
+ if submission.blank?
+ skip_authorization
+ render json: [], status: :ok
+ return
+ end
+
+ # verify authorization for the submission, as all queried errors are generated by this submission anyway
+ # and structured_errors don't have a policy yet
+ authorize(submission)
+ errors = StructuredError.where(submission_id: submission.id)
+
+ # for each error get all attributes, filter out uninteresting ones, and build a query
+ insights = errors.map do |error|
+ attributes = error.structured_error_attributes.select do |attribute|
+ is_interesting(attribute) and attribute.match
+ end
+ # once the programming language model becomes available, the language name can be added to the query to
+ # produce more relevant results
+ query = attributes.map{|att| att.value}.join(' ')
+ { submission: submission, error: error, attributes: attributes, query: query }
+ end
+
+ # Always return JSON
+ render json: insights, status: :ok
+ end
+
+ def is_interesting(attribute)
+ attribute.error_template_attribute.key.index(/error message|error type/i) != nil
+ end
+ private :is_interesting
+end
diff --git a/app/policies/submission_policy.rb b/app/policies/submission_policy.rb
index 861f5695..32916450 100644
--- a/app/policies/submission_policy.rb
+++ b/app/policies/submission_policy.rb
@@ -8,7 +8,8 @@ class SubmissionPolicy < ApplicationPolicy
everyone
end
- [:download?, :download_file?, :render_file?, :run?, :score?, :show?, :statistics?, :stop?, :test?].each do |action|
+ # insights? is used in the flowr_controller.rb as we use it to authorize the user for a submission
+ [:download?, :download_file?, :render_file?, :run?, :score?, :show?, :statistics?, :stop?, :test?, :insights?].each do |action|
define_method(action) { admin? || author? }
end
diff --git a/app/views/exercises/_editor_output.html.slim b/app/views/exercises/_editor_output.html.slim
index 4568c313..133b558e 100644
--- a/app/views/exercises/_editor_output.html.slim
+++ b/app/views/exercises/_editor_output.html.slim
@@ -34,7 +34,6 @@ div.h-100 id='output_sidebar_uncollapsed' class='d-none col-sm-12 enforce-bottom
- else
p.text-center = render('editor_button', classes: 'btn-lg btn-secondary disabled', data: {:'data-placement' => 'bottom', :'data-tooltip' => true}, icon: 'fa fa-clock-o', id: 'submit_outdated', label: t('exercises.editor.exercise_deadline_passed'), title: t('exercises.editor.tooltips.exercise_deadline_passed'))
hr
-
div.enforce-big-top-margin
#turtlediv
canvas#turtlecanvas.d-none width=400 height=400
@@ -56,6 +55,6 @@ div.h-100 id='output_sidebar_uncollapsed' class='d-none col-sm-12 enforce-bottom
#output.mt-2
pre = t('exercises.implement.no_output_yet')
- if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled]
- #flowrHint.card.card.text-white.bg-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab'
- .card-header = 'Gain more insights here'
- .card-body
+ #flowrHint.card.text-white.bg-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab'
+ .card-header = t('exercises.implement.flowr.heading')
+ .card-body.text-dark.bg-white
diff --git a/config/code_ocean.yml.example b/config/code_ocean.yml.example
index 30cd4639..f931bc68 100644
--- a/config/code_ocean.yml.example
+++ b/config/code_ocean.yml.example
@@ -1,13 +1,14 @@
default: &default
flowr:
enabled: false
+ answers_per_query: 3
code_pilot:
enabled: false
development:
flowr:
- enabled: false
- url: http://example.org:3000/api/exceptioninfo?id=&lang=auto
+ enabled: true
+ answers_per_query: 3
code_pilot:
enabled: false
url: //localhost:3000
diff --git a/config/locales/de.yml b/config/locales/de.yml
index ae535eb9..fdaa2a18 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -338,6 +338,9 @@ de:
text: "Uns ist aufgefallen, dass du schon lange an dieser Aufgabe arbeitest. Möchtest du vielleicht später weiter machen um erstmal auf neue Gedanken zu kommen?"
error_hints:
heading: "Hinweise"
+ flowr:
+ heading: "Weitere Hinweise | Unterstützt von StackOverflow"
+ go_to_question: "Lösung ansehen"
index:
clone: Duplizieren
implement: Implementieren
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8501572f..5073d525 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -338,6 +338,9 @@ en:
text: "We recognized that you are already working quite a while on this exercise. We would like to encourage you to take a break and come back later."
error_hints:
heading: "Hints"
+ flowr:
+ heading: "Gain more insights here | Powered by StackOverflow"
+ go_to_question: "Go to answer"
index:
clone: Duplicate
implement: Implement
diff --git a/config/routes.rb b/config/routes.rb
index b65d401e..c146a69b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -40,6 +40,8 @@ Rails.application.routes.draw do
get 'dashboard', to: 'dashboard#show'
end
+ get '/insights', to: 'flowr#insights'
+
get 'statistics/', to: 'statistics#show'
get 'statistics/graphs', to: 'statistics#graphs'
get 'statistics/graphs/user-activity', to: 'statistics#user_activity'