@@ -164,12 +164,10 @@ CodeOceanEditorEvaluation = {
|
|||||||
} else if (output.stderr) {
|
} else if (output.stderr) {
|
||||||
//element.addClass('text-warning').append(output.stderr);
|
//element.addClass('text-warning').append(output.stderr);
|
||||||
element.addClass('text-warning').text(element.text() + output.stderr);
|
element.addClass('text-warning').text(element.text() + output.stderr);
|
||||||
this.flowrOutputBuffer += output.stderr;
|
|
||||||
this.QaApiOutputBuffer.stderr += output.stderr;
|
this.QaApiOutputBuffer.stderr += output.stderr;
|
||||||
} else if (output.stdout) {
|
} else if (output.stdout) {
|
||||||
//element.addClass('text-success').append(output.stdout);
|
//element.addClass('text-success').append(output.stdout);
|
||||||
element.addClass('text-success').text(element.text() + output.stdout);
|
element.addClass('text-success').text(element.text() + output.stdout);
|
||||||
this.flowrOutputBuffer += output.stdout;
|
|
||||||
this.QaApiOutputBuffer.stdout += output.stdout;
|
this.QaApiOutputBuffer.stdout += output.stdout;
|
||||||
} else {
|
} else {
|
||||||
element.addClass('text-muted').text($('#output').data('message-no-output'));
|
element.addClass('text-muted').text($('#output').data('message-no-output'));
|
||||||
|
@@ -1,96 +0,0 @@
|
|||||||
CodeOceanEditorFlowr = {
|
|
||||||
isFlowrEnabled: true,
|
|
||||||
flowrResultHtml: '<div class="panel panel-default"><div id="{{headingId}}" role="tab" class="panel-heading"><h4 class="panel-title"><a data-toggle="collapse" data-parent="#flowrHint" href="#{{collapseId}}" aria-expanded="true" aria-controls="{{collapseId}}"></a></h4></div><div id="{{collapseId}}" role="tabpanel" aria-labelledby="{{headingId}}" class="panel-collapse collapse"><div class="card-body"></div></div></div>',
|
|
||||||
|
|
||||||
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('<a href="' + question.url + '" class="btn btn-primary btn-block">Open this question</a>');
|
|
||||||
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
179
app/assets/javascripts/editor/participantsupport.js.erb
Normal file
179
app/assets/javascripts/editor/participantsupport.js.erb
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
CodeOceanEditorFlowr = {
|
||||||
|
isFlowrEnabled: <%= CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled] %>,
|
||||||
|
flowrResultHtml:
|
||||||
|
'<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}}">' +
|
||||||
|
'<div class="clearfix" role="button">' +
|
||||||
|
'<i class="fa" aria-hidden="true" />' +
|
||||||
|
'<span>' +
|
||||||
|
'</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</a>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="{{collapseId}}" role="tabpanel" aria-labelledby="{{headingId}}" class="card card-collapse collapse">' +
|
||||||
|
'<div class="card-body"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
|
||||||
|
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('<a target="_blank" href="' + questionUrl + '" class="btn btn-primary btn-block">' +
|
||||||
|
'<%= I18n.t('exercises.implement.flowr.go_to_question') %></a>');
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
@@ -1,3 +1,4 @@
|
|||||||
#flowrHint {
|
#flowrHint {
|
||||||
display: none;
|
display: none;
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
@@ -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
|
@current_user ||= ExternalUser.find_by(id: session[:external_user_id]) || login_from_session || login_from_other_sources
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def require_user!
|
||||||
|
raise Pundit::NotAuthorizedError unless current_user
|
||||||
|
end
|
||||||
|
|
||||||
def render_not_authorized
|
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
|
end
|
||||||
private :render_not_authorized
|
private :render_not_authorized
|
||||||
|
|
||||||
|
41
app/controllers/flowr_controller.rb
Normal file
41
app/controllers/flowr_controller.rb
Normal file
@@ -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
|
@@ -8,7 +8,8 @@ class SubmissionPolicy < ApplicationPolicy
|
|||||||
everyone
|
everyone
|
||||||
end
|
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? }
|
define_method(action) { admin? || author? }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@@ -34,7 +34,6 @@ div.h-100 id='output_sidebar_uncollapsed' class='d-none col-sm-12 enforce-bottom
|
|||||||
- else
|
- 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'))
|
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
|
hr
|
||||||
|
|
||||||
div.enforce-big-top-margin
|
div.enforce-big-top-margin
|
||||||
#turtlediv
|
#turtlediv
|
||||||
canvas#turtlecanvas.d-none width=400 height=400
|
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
|
#output.mt-2
|
||||||
pre = t('exercises.implement.no_output_yet')
|
pre = t('exercises.implement.no_output_yet')
|
||||||
- if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled]
|
- 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'
|
#flowrHint.card.text-white.bg-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab'
|
||||||
.card-header = 'Gain more insights here'
|
.card-header = t('exercises.implement.flowr.heading')
|
||||||
.card-body
|
.card-body.text-dark.bg-white
|
||||||
|
@@ -1,13 +1,14 @@
|
|||||||
default: &default
|
default: &default
|
||||||
flowr:
|
flowr:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
answers_per_query: 3
|
||||||
code_pilot:
|
code_pilot:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
development:
|
development:
|
||||||
flowr:
|
flowr:
|
||||||
enabled: false
|
enabled: true
|
||||||
url: http://example.org:3000/api/exceptioninfo?id=&lang=auto
|
answers_per_query: 3
|
||||||
code_pilot:
|
code_pilot:
|
||||||
enabled: false
|
enabled: false
|
||||||
url: //localhost:3000
|
url: //localhost:3000
|
||||||
|
@@ -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?"
|
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:
|
error_hints:
|
||||||
heading: "Hinweise"
|
heading: "Hinweise"
|
||||||
|
flowr:
|
||||||
|
heading: "Weitere Hinweise | Unterstützt von StackOverflow"
|
||||||
|
go_to_question: "Lösung ansehen"
|
||||||
index:
|
index:
|
||||||
clone: Duplizieren
|
clone: Duplizieren
|
||||||
implement: Implementieren
|
implement: Implementieren
|
||||||
|
@@ -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."
|
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:
|
error_hints:
|
||||||
heading: "Hints"
|
heading: "Hints"
|
||||||
|
flowr:
|
||||||
|
heading: "Gain more insights here | Powered by StackOverflow"
|
||||||
|
go_to_question: "Go to answer"
|
||||||
index:
|
index:
|
||||||
clone: Duplicate
|
clone: Duplicate
|
||||||
implement: Implement
|
implement: Implement
|
||||||
|
@@ -40,6 +40,8 @@ Rails.application.routes.draw do
|
|||||||
get 'dashboard', to: 'dashboard#show'
|
get 'dashboard', to: 'dashboard#show'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get '/insights', to: 'flowr#insights'
|
||||||
|
|
||||||
get 'statistics/', to: 'statistics#show'
|
get 'statistics/', to: 'statistics#show'
|
||||||
get 'statistics/graphs', to: 'statistics#graphs'
|
get 'statistics/graphs', to: 'statistics#graphs'
|
||||||
get 'statistics/graphs/user-activity', to: 'statistics#user_activity'
|
get 'statistics/graphs/user-activity', to: 'statistics#user_activity'
|
||||||
|
Reference in New Issue
Block a user