Merge remote-tracking branch 'origin/master' into error-info

# Conflicts:
#	app/controllers/concerns/submission_scoring.rb
#	app/views/application/_navigation.html.slim
#	config/locales/de.yml
#	config/locales/en.yml
#	db/schema.rb
This commit is contained in:
Maximilian Grundke
2017-10-15 17:02:19 +02:00
65 changed files with 1190 additions and 405 deletions

2
Vagrantfile vendored
View File

@ -4,7 +4,7 @@
Vagrant.configure(2) do |config| Vagrant.configure(2) do |config|
config.vm.box = "ubuntu/trusty64" config.vm.box = "ubuntu/trusty64"
config.vm.provider "virtualbox" do |v| config.vm.provider "virtualbox" do |v|
v.memory = 1024 v.memory = 8192
end end
config.vm.network "private_network", ip: "192.168.59.104" config.vm.network "private_network", ip: "192.168.59.104"
# config.vm.synced_folder "../data", "/vagrant_data" # config.vm.synced_folder "../data", "/vagrant_data"

View File

@ -14,6 +14,7 @@
// //
//= require ace/ace //= require ace/ace
//= require chosen.jquery.min //= require chosen.jquery.min
//= require jquery-ui.min
//= require d3 //= require d3
//= require jquery.turbolinks //= require jquery.turbolinks
//= require jquery_ujs //= require jquery_ujs

View File

@ -59,8 +59,6 @@ CodeOceanEditorCodePilot = {
} }
}; };
//Request for comments does currently not work on staging platform (no relative root_url used here).
//To fix this rely on ruby routes
CodeOceanEditorRequestForComments = { CodeOceanEditorRequestForComments = {
requestComments: function () { requestComments: function () {
var user_id = $('#editor').data('user-id'); var user_id = $('#editor').data('user-id');
@ -83,6 +81,8 @@ CodeOceanEditorRequestForComments = {
}).done(function () { }).done(function () {
this.hideSpinner(); this.hideSpinner();
$.flash.success({text: $('#askForCommentsButton').data('message-success')}); $.flash.success({text: $('#askForCommentsButton').data('message-success')});
// trigger a run
this.runSubmission.call(this, submission);
}.bind(this)).error(this.ajaxError.bind(this)); }.bind(this)).error(this.ajaxError.bind(this));
}; };

View File

@ -142,19 +142,21 @@ CodeOceanEditorSubmissions = {
* Execution-Logic * Execution-Logic
*/ */
runCode: function(event) { runCode: function(event) {
event.preventDefault(); event.preventDefault();
if ($('#run').is(':visible')) { if ($('#run').is(':visible')) {
this.createSubmission('#run', null, function(response) { this.createSubmission('#run', null, this.runSubmission.bind(this));
//Run part starts here }
$('#stop').data('url', response.stop_url); },
this.running = true;
this.showSpinner($('#run')); runSubmission: function (submission) {
$('#score_div').addClass('hidden'); //Run part starts here
this.toggleButtonStates(); $('#stop').data('url', submission.stop_url);
var url = response.run_url.replace(this.FILENAME_URL_PLACEHOLDER, this.active_file.filename.replace(/#$/,'')); // remove # if it is the last character, this is not part of the filename and just an anchor this.running = true;
this.initializeSocketForRunning(url); this.showSpinner($('#run'));
}.bind(this)); $('#score_div').addClass('hidden');
} this.toggleButtonStates();
var url = submission.run_url.replace(this.FILENAME_URL_PLACEHOLDER, this.active_file.filename.replace(/#$/,'')); // remove # if it is the last character, this is not part of the filename and just an anchor
this.initializeSocketForRunning(url);
}, },
saveCode: function(event) { saveCode: function(event) {

View File

@ -62,7 +62,9 @@ a.file-heading {
fill: #ffd897; fill: #ffd897;
} }
.container > form > .actions {
margin-bottom: 200px;
}
.d3-tip { .d3-tip {
line-height: 1; line-height: 1;

View File

@ -1,7 +1,98 @@
#commentitor { .rfc {
margin-top: 2rem;
height: 600px; h5 {
background-color:#f9f9f9 color: #008CBA;
}
.text {
font-size: larger;
}
.text.collapsed {
max-height: 50px;
overflow-y: hidden;
}
.collapse-button {
position: relative;
float: right;
margin-top: 5px;
margin-right: 5px;
cursor: pointer;
}
.description {
.text {
padding: 5px;
background-color: #FAFAFA;
border: 1px solid #CCCCCC;
}
}
.question {
.text {
font-weight: bold;
}
}
.testruns {
.text {
padding: 5px;
background-color: #FAFAFA;
border: 1px solid #CCCCCC;
}
pre {
background-color: inherit;
border: none;
}
}
}
.testrun-assess-results {
display: flex;
.result {
margin-right: 10px;
width: 10px;
height: 10px;
}
.passed {
border-radius: 50%;
background-color: #8efa00;
-webkit-box-shadow: 0 0 11px 1px rgba(44,222,0,1);
-moz-box-shadow: 0 0 11px 1px rgba(44,222,0,1);
box-shadow: 0 0 11px 1px rgba(44,222,0,1);
}
.unknown {
border-radius: 50%;
background-color: #ffca00;
-webkit-box-shadow: 0 0 11px 1px rgb(255, 202, 0);
-moz-box-shadow: 0 0 11px 1px rgb(255, 202, 0);
box-shadow: 0 0 11px 1px rgb(255, 202, 0);
}
.failed {
border-radius: 50%;
background-color: #ff2600;
-webkit-box-shadow: 0 0 11px 1px rgba(222,0,0,1);
-moz-box-shadow: 0 0 11px 1px rgba(222,0,0,1);
box-shadow: 0 0 11px 1px rgba(222,0,0,1);
}
}
#mark-as-solved-button {
margin-top: 20px;
} }
#thank-you-container { #thank-you-container {
@ -11,6 +102,10 @@
border: solid lightgrey 1px; border: solid lightgrey 1px;
background-color: rgba(20, 180, 20, 0.2); background-color: rgba(20, 180, 20, 0.2);
border-radius: 4px; border-radius: 4px;
button {
margin-right: 10px;
}
} }
#thank-you-note { #thank-you-note {
@ -18,49 +113,142 @@
height: 200px; height: 200px;
} }
#commentitor {
margin-bottom: 2rem;
height: 600px;
background-color:#f9f9f9
}
.ace_tooltip { .ace_tooltip {
display: none !important; display: none !important;
} }
p.comment { .modal-content {
width: 400px; min-height: 512px;
min-width: 360px;
max-height: 90vh;
display: flex;
flex-direction: column;
.modal-body {
flex-grow: 1;
display: flex;
flex-direction: column;
#otherComments {
flex-grow: 1;
display: flex;
flex-direction: column;
.container {
flex-grow: 1;
}
}
}
} }
.popover-header { .comment {
width: 100%; width: 100%;
overflow: hidden; min-width: 200px;
padding-bottom: 10px;
margin: auto; .comment-header {
width: 100%;
overflow: hidden;
padding-bottom: 10px;
margin: auto;
.comment-username {
font-weight: bold;
width: 60%;
float: left;
}
.comment-date {
text-align: right;
color: #008cba;
margin-left: 60%;
font-size: x-small;
}
.comment-updated {
text-align: right;
margin-left: 60%;
font-size: x-small;
}
}
.comment-content {
word-wrap: break-word;
margin-bottom: 10px;
}
.comment-editor {
display: none;
width: 100%;
height: auto;
background-color: inherit;
}
.comment-actions {
display: none;
}
} }
.popover-username { .comment-divider {
font-weight: bold;
width: 60%;
float: left;
}
.popover-date {
text-align: right;
color: #008cba;
margin-left: 60%;
font-size: x-small;
}
.popover-updated {
text-align: right;
margin-left: 60%;
font-size: x-small;
}
.popover-comment {
word-wrap: break-word;
margin-bottom: 10px;
}
.popover-divider {
width: 100%; width: 100%;
height: 1px; height: 1px;
background-color: #008cba; background-color: #008cba;
overflow: hidden; overflow: hidden;
margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
} }
#otherComments {
h5 {
margin-top: 0;
}
.container {
width: 100%;
overflow-y: auto;
border: 1px solid #cccccc;
padding: 15px;
.comment-removed {
margin-top: 20px;
margin-bottom: 20px;
font-style: italic;
}
.comment-actions {
display: flex;
button {
margin-right: 5px;
}
}
}
}
input#subscribe {
margin-top: 5px;
margin-right: 5px;
}
#myComment {
margin-top: 20px;
margin-bottom: 10px;
textarea {
resize: none;
}
button {
margin-top: 10px;
}
}
.popover-footer {
color: #008cba;
margin-top: 10px;
}

View File

@ -42,6 +42,14 @@ div.positive-result {
box-shadow: 0px 0px 11px 1px rgba(44,222,0,1); box-shadow: 0px 0px 11px 1px rgba(44,222,0,1);
} }
div.unknown-result {
border-radius: 50%;
background-color: #ffca00;
-webkit-box-shadow: 0px 0px 11px 1px rgb(255, 202, 0);
-moz-box-shadow: 0px 0px 11px 1px rgb(255, 202, 0);
box-shadow: 0px 0px 11px 1px rgb(255, 202, 0);
}
div.negative-result { div.negative-result {
border-radius: 50%; border-radius: 50%;
background-color: #ff2600; background-color: #ff2600;

View File

@ -10,6 +10,7 @@ class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError, with: :render_not_authorized rescue_from Pundit::NotAuthorizedError, with: :render_not_authorized
def current_user 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 @current_user ||= ExternalUser.find_by(id: session[:external_user_id]) || login_from_session || login_from_other_sources
end end

View File

@ -1,5 +1,5 @@
class CommentsController < ApplicationController class CommentsController < ApplicationController
before_action :set_comment, only: [:show, :edit, :update, :destroy_by_id] before_action :set_comment, only: [:show, :edit, :update, :destroy]
# to disable authorization check: comment the line below back in # to disable authorization check: comment the line below back in
# skip_after_action :verify_authorized # skip_after_action :verify_authorized
@ -21,6 +21,7 @@ class CommentsController < ApplicationController
comment.username = comment.user.displayname comment.username = comment.user.displayname
comment.date = comment.created_at.strftime('%d.%m.%Y %k:%M') comment.date = comment.created_at.strftime('%d.%m.%Y %k:%M')
comment.updated = (comment.created_at != comment.updated_at) comment.updated = (comment.created_at != comment.updated_at)
comment.editable = comment.user == current_user
} }
else else
@comments = [] @comments = []
@ -50,12 +51,14 @@ class CommentsController < ApplicationController
def create def create
@comment = Comment.new(comment_params_without_request_id) @comment = Comment.new(comment_params_without_request_id)
if comment_params[:request_id]
UserMailer.got_new_comment(@comment, RequestForComment.find(comment_params[:request_id]), current_user).deliver_now
end
respond_to do |format| respond_to do |format|
if @comment.save if @comment.save
if comment_params[:request_id]
request_for_comment = RequestForComment.find(comment_params[:request_id])
send_mail_to_author @comment, request_for_comment
send_mail_to_subscribers @comment, request_for_comment
end
format.html { redirect_to @comment, notice: 'Comment was successfully created.' } format.html { redirect_to @comment, notice: 'Comment was successfully created.' }
format.json { render :show, status: :created, location: @comment } format.json { render :show, status: :created, location: @comment }
else else
@ -83,7 +86,8 @@ class CommentsController < ApplicationController
# DELETE /comments/1 # DELETE /comments/1
# DELETE /comments/1.json # DELETE /comments/1.json
def destroy_by_id def destroy
authorize!
@comment.destroy @comment.destroy
respond_to do |format| respond_to do |format|
format.html { head :no_content, notice: 'Comment was successfully destroyed.' } format.html { head :no_content, notice: 'Comment was successfully destroyed.' }
@ -91,30 +95,45 @@ class CommentsController < ApplicationController
end end
end end
def destroy
@comments = Comment.where(file_id: params[:file_id], row: params[:row], user: current_user)
@comments.each { |comment| authorize comment; comment.destroy }
respond_to do |format|
#format.html { redirect_to comments_url, notice: 'Comments were successfully destroyed.' }
format.html { head :no_content, notice: 'Comments were successfully destroyed.' }
format.json { head :no_content }
end
end
private private
# Use callbacks to share common setup or constraints between actions.
def set_comment # Use callbacks to share common setup or constraints between actions.
@comment = Comment.find(params[:id]) def set_comment
end @comment = Comment.find(params[:id])
end
def comment_params_without_request_id def comment_params_without_request_id
comment_params.except :request_id comment_params.except :request_id
end end
# Never trust parameters from the scary internet, only allow the white list through. # Never trust parameters from the scary internet, only allow the white list through.
def comment_params def comment_params
#params.require(:comment).permit(:user_id, :file_id, :row, :column, :text) #params.require(:comment).permit(:user_id, :file_id, :row, :column, :text)
# fuer production mode, damit böse menschen keine falsche user_id uebergeben: # fuer production mode, damit böse menschen keine falsche user_id uebergeben:
params.require(:comment).permit(:file_id, :row, :column, :text, :request_id).merge(user_id: current_user.id, user_type: current_user.class.name) params.require(:comment).permit(:file_id, :row, :column, :text, :request_id).merge(user_id: current_user.id, user_type: current_user.class.name)
end
def send_mail_to_author(comment, request_for_comment)
if current_user != request_for_comment.user
UserMailer.got_new_comment(comment, request_for_comment, current_user).deliver_now
end end
end
def send_mail_to_subscribers(comment, request_for_comment)
request_for_comment.commenters.each do |commenter|
already_sent_mail = false
subscriptions = Subscription.where(
:request_for_comment_id => request_for_comment.id,
:user_id => commenter.id, :user_type => commenter.class.name,
:deleted => false)
subscriptions.each do |subscription|
if (subscription.subscription_type == 'author' and current_user == request_for_comment.user) or subscription.subscription_type == 'all'
unless subscription.user == current_user or already_sent_mail
UserMailer.got_new_comment_for_subscription(comment, subscription, current_user).deliver_now
already_sent_mail = true
end
end
end
end
end
end end

View File

@ -42,12 +42,12 @@ module Lti
private :external_user_email private :external_user_email
def external_user_name(provider) def external_user_name(provider)
# save person_name_full if supplied. this is the display_name, if it is set.
# else only save the firstname, we don't want lastnames (family names)
if provider.lis_person_name_full if provider.lis_person_name_full
provider.lis_person_name_full provider.lis_person_name_full
elsif provider.lis_person_name_given && provider.lis_person_name_family
"#{provider.lis_person_name_given} #{provider.lis_person_name_family}"
else else
provider.lis_person_name_given || provider.lis_person_name_family provider.lis_person_name_given
end end
end end
private :external_user_name private :external_user_name
@ -104,7 +104,7 @@ module Lti
private :return_to_consumer private :return_to_consumer
def send_score(exercise_id, score, user_id) def send_score(exercise_id, score, user_id)
::NewRelic::Agent.add_custom_parameters({ score: score, session: session }) ::NewRelic::Agent.add_custom_attributes({ score: score, session: session })
fail(Error, "Score #{score} must be between 0 and #{MAXIMUM_SCORE}!") unless (0..MAXIMUM_SCORE).include?(score) fail(Error, "Score #{score} must be between 0 and #{MAXIMUM_SCORE}!") unless (0..MAXIMUM_SCORE).include?(score)
if session[:consumer_id] if session[:consumer_id]

View File

@ -8,7 +8,7 @@ module SubmissionScoring
output = execute_test_file(file, submission) output = execute_test_file(file, submission)
assessment = assessor.assess(output) assessment = assessor.assess(output)
passed = ((assessment[:passed] == assessment[:count]) and (assessment[:score] > 0)) passed = ((assessment[:passed] == assessment[:count]) and (assessment[:score] > 0))
testrun_output = passed ? nil : output[:stderr] testrun_output = passed ? nil : 'message: ' + output[:message].to_s + "\n stdout: " + output[:stdout].to_s + "\n stderr: " + output[:stderr].to_s
if !testrun_output.blank? if !testrun_output.blank?
submission.exercise.execution_environment.error_templates.each do |template| submission.exercise.execution_environment.error_templates.each do |template|
pattern = Regexp.new(template.signature).freeze pattern = Regexp.new(template.signature).freeze
@ -17,7 +17,7 @@ module SubmissionScoring
end end
end end
end end
Testrun.new(submission: submission, file: file, passed: passed, output: testrun_output).save Testrun.new(submission: submission, cause: 'assess', file: file, passed: passed, output: testrun_output).save
output.merge!(assessment) output.merge!(assessment)
output.merge!(filename: file.name_with_extension, message: feedback_message(file, output[:score]), weight: file.weight) output.merge!(filename: file.name_with_extension, message: feedback_message(file, output[:score]), weight: file.weight)
end end

View File

@ -0,0 +1,51 @@
class ExerciseCollectionsController < ApplicationController
include CommonBehavior
before_action :set_exercise_collection, only: [:show, :edit, :update, :destroy]
def index
@exercise_collections = ExerciseCollection.all.paginate(:page => params[:page])
authorize!
end
def show
end
def new
@exercise_collection = ExerciseCollection.new
authorize!
end
def create
@exercise_collection = ExerciseCollection.new(exercise_collection_params)
authorize!
create_and_respond(object: @exercise_collection)
end
def destroy
authorize!
destroy_and_respond(object: @exercise_collection)
end
def edit
end
def update
update_and_respond(object: @exercise_collection, params: exercise_collection_params)
end
private
def set_exercise_collection
@exercise_collection = ExerciseCollection.find(params[:id])
authorize!
end
def authorize!
authorize(@exercise_collection || @exercise_collections)
end
def exercise_collection_params
params[:exercise_collection].permit(:name, :exercise_ids => [])
end
end

View File

@ -20,7 +20,7 @@ class ExercisesController < ApplicationController
end end
private :authorize! private :authorize!
def max_intervention_count def max_intervention_count_per_day
3 3
end end
@ -166,7 +166,7 @@ class ExercisesController < ApplicationController
def implement def implement
redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists? redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists?
user_solved_exercise = @exercise.has_user_solved(current_user) user_solved_exercise = @exercise.has_user_solved(current_user)
user_got_enough_interventions = UserExerciseIntervention.where(user: current_user).where("created_at >= ?", Time.zone.now.beginning_of_day).count >= max_intervention_count user_got_enough_interventions = UserExerciseIntervention.where(user: current_user).where("created_at >= ?", Time.zone.now.beginning_of_day).count >= max_intervention_count_per_day
is_java_course = @course_token && @course_token.eql?(java_course_token) is_java_course = @course_token && @course_token.eql?(java_course_token)
user_intervention_group = UserGroupSeparator.getInterventionGroup(current_user) user_intervention_group = UserGroupSeparator.getInterventionGroup(current_user)
@ -203,7 +203,7 @@ class ExercisesController < ApplicationController
if match = lti_json.match(/^.*courses\/([a-z0-9\-]+)\/sections/) if match = lti_json.match(/^.*courses\/([a-z0-9\-]+)\/sections/)
match.captures.first match.captures.first
else else
java_course_token ""
end end
else else
"" ""
@ -344,7 +344,7 @@ class ExercisesController < ApplicationController
end end
def transmit_lti_score def transmit_lti_score
::NewRelic::Agent.add_custom_parameters({ submission: @submission.id, normalized_score: @submission.normalized_score }) ::NewRelic::Agent.add_custom_attributes({ submission: @submission.id, normalized_score: @submission.normalized_score })
response = send_score(@submission.exercise_id, @submission.normalized_score, @submission.user_id) response = send_score(@submission.exercise_id, @submission.normalized_score, @submission.user_id)
if response[:status] == 'success' if response[:status] == 'success'

View File

@ -1,4 +1,5 @@
class RequestForCommentsController < ApplicationController class RequestForCommentsController < ApplicationController
include SubmissionScoring
before_action :set_request_for_comment, only: [:show, :edit, :update, :destroy, :mark_as_solved, :set_thank_you_note] before_action :set_request_for_comment, only: [:show, :edit, :update, :destroy, :mark_as_solved, :set_thank_you_note]
skip_after_action :verify_authorized skip_after_action :verify_authorized
@ -22,7 +23,7 @@ class RequestForCommentsController < ApplicationController
request_for_comments.submission_id, request_for_comments.row_number') # ugly, but rails wants it this way request_for_comments.submission_id, request_for_comments.row_number') # ugly, but rails wants it this way
.select('request_for_comments.*, max(comments.updated_at) as last_comment') .select('request_for_comments.*, max(comments.updated_at) as last_comment')
.search(params[:q]) .search(params[:q])
@request_for_comments = @search.result.order('created_at DESC').paginate(page: params[:page]) @request_for_comments = @search.result.order('created_at DESC').paginate(page: params[:page], total_entries: @search.result.length)
authorize! authorize!
end end
@ -68,11 +69,8 @@ class RequestForCommentsController < ApplicationController
def set_thank_you_note def set_thank_you_note
authorize! authorize!
@request_for_comment.thank_you_note = params[:note] @request_for_comment.thank_you_note = params[:note]
commenters = []
@request_for_comment.comments.distinct.to_a.each {|comment| commenters = @request_for_comment.commenters
commenters.append comment.user
}
commenters = commenters.uniq {|user| user.id}
commenters.each {|commenter| UserMailer.send_thank_you_note(@request_for_comment, commenter).deliver_now} commenters.each {|commenter| UserMailer.send_thank_you_note(@request_for_comment, commenter).deliver_now}
respond_to do |format| respond_to do |format|
@ -110,6 +108,10 @@ class RequestForCommentsController < ApplicationController
@request_for_comment = RequestForComment.new(request_for_comment_params) @request_for_comment = RequestForComment.new(request_for_comment_params)
respond_to do |format| respond_to do |format|
if @request_for_comment.save if @request_for_comment.save
# create thread here and execute tests. A run is triggered from the frontend and does not need to be handled here.
Thread.new do
score_submission(@request_for_comment.submission)
end
format.json { render :show, status: :created, location: @request_for_comment } format.json { render :show, status: :created, location: @request_for_comment }
else else
format.html { render :new } format.html { render :new }

View File

@ -13,8 +13,12 @@ class SubmissionsController < ApplicationController
before_action :set_mime_type, only: [:download_file, :render_file] before_action :set_mime_type, only: [:download_file, :render_file]
skip_before_action :verify_authenticity_token, only: [:download_file, :render_file] skip_before_action :verify_authenticity_token, only: [:download_file, :render_file]
def max_message_buffer_size def max_run_output_buffer_size
500 if(@submission.cause == 'requestComments')
5000
else
500
end
end end
def authorize! def authorize!
@ -210,7 +214,7 @@ class SubmissionsController < ApplicationController
end end
def handle_message(message, tubesock, container) def handle_message(message, tubesock, container)
@message_buffer ||= "" @run_output ||= ""
# Handle special commands first # Handle special commands first
if (/^#exit/.match(message)) if (/^#exit/.match(message))
# Just call exit_container on the docker_client. # Just call exit_container on the docker_client.
@ -219,19 +223,19 @@ class SubmissionsController < ApplicationController
# kill_socket is called in the "on close handler" of the websocket to the container # kill_socket is called in the "on close handler" of the websocket to the container
@docker_client.exit_container(container) @docker_client.exit_container(container)
elsif /^#timeout/.match(message) elsif /^#timeout/.match(message)
@message_buffer = 'timeout: ' + @message_buffer # add information that this run timed out to the buffer @run_output = 'timeout: ' + @run_output # add information that this run timed out to the buffer
else else
# Filter out information about run_command, test_command, user or working directory # Filter out information about run_command, test_command, user or working directory
run_command = @submission.execution_environment.run_command % command_substitutions(params[:filename]) run_command = @submission.execution_environment.run_command % command_substitutions(params[:filename])
test_command = @submission.execution_environment.test_command % command_substitutions(params[:filename]) test_command = @submission.execution_environment.test_command % command_substitutions(params[:filename])
if !(/root|workspace|#{run_command}|#{test_command}/.match(message)) if !(/root|workspace|#{run_command}|#{test_command}/.match(message))
@message_buffer += message if @message_buffer.size <= max_message_buffer_size
parse_message(message, 'stdout', tubesock) parse_message(message, 'stdout', tubesock)
end end
end end
end end
def parse_message(message, output_stream, socket, recursive = true) def parse_message(message, output_stream, socket, recursive = true)
parsed = '';
begin begin
parsed = JSON.parse(message) parsed = JSON.parse(message)
if(parsed.class == Hash && parsed.key?('cmd')) if(parsed.class == Hash && parsed.key?('cmd'))
@ -270,13 +274,16 @@ class SubmissionsController < ApplicationController
socket.send_data JSON.dump(parsed) socket.send_data JSON.dump(parsed)
Rails.logger.info('parse_message sent: ' + JSON.dump(parsed)) Rails.logger.info('parse_message sent: ' + JSON.dump(parsed))
end end
ensure
# save the data that was send to the run_output if there is enough space left. this will be persisted as a testrun with cause "run"
@run_output += JSON.dump(parsed) if @run_output.size <= max_run_output_buffer_size
end end
end end
def save_run_output def save_run_output
if !@message_buffer.blank? if !@run_output.blank?
@message_buffer = @message_buffer[(0..max_message_buffer_size-1)] # trim the string to max_message_buffer_size chars @run_output = @run_output[(0..max_run_output_buffer_size-1)] # trim the string to max_message_buffer_size chars
Testrun.create(file: @file, submission: @submission, output: @message_buffer) Testrun.create(file: @file, cause: 'run', submission: @submission, output: @run_output)
end end
end end

View File

@ -0,0 +1,62 @@
class SubscriptionsController < ApplicationController
def authorize!
authorize(@subscription || @subscriptions)
end
private :authorize!
# POST /subscriptions.json
def create
@subscription = Subscription.new(subscription_params)
respond_to do |format|
if @subscription.save
format.json { render json: @subscription, status: :created }
else
format.json { render json: @subscription.errors, status: :unprocessable_entity }
end
end
authorize!
end
# DELETE /subscriptions/1
# DELETE /subscriptions/1.json
def destroy
begin
@subscription = Subscription.find(params[:id])
rescue
skip_authorization
respond_to do |format|
format.html { redirect_to request_for_comments_url, alert: t('subscriptions.subscription_not_existent') }
format.json { render json: {message: t('subscriptions.subscription_not_existent')}, status: :not_found }
end
else
authorize!
rfc = @subscription.try(:request_for_comment)
@subscription.deleted = true
if @subscription.save
respond_to do |format|
format.html { redirect_to request_for_comment_url(rfc), notice: t('subscriptions.successfully_unsubscribed') }
format.json { render json: {message: t('subscriptions.successfully_unsubscribed')}, status: :ok}
end
else
respond_to do |format|
format.html { redirect_to request_for_comment_url(rfc), :flash => { :danger => t('shared.message_failure') } }
format.json { render json: {message: t('shared.message_failure')}, status: :internal_server_error}
end
end
end
end
def set_subscription
@subscription = Subscription.find(params[:id])
authorize!
end
private :set_subscription
def subscription_params
current_user_id = current_user.try(:id)
current_user_class_name = current_user.try(:class).try(:name)
params[:subscription].permit(:request_for_comment_id, :subscription_type).merge(user_id: current_user_id, user_type: current_user_class_name, deleted: false)
end
private :subscription_params
end

View File

@ -21,6 +21,15 @@ class UserMailer < ActionMailer::Base
mail(subject: t('mailers.user_mailer.got_new_comment.subject', commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email) mail(subject: t('mailers.user_mailer.got_new_comment.subject', commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email)
end end
def got_new_comment_for_subscription(comment, subscription, from_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)
@unsubscribe_link = unsubscribe_subscription_url(subscription)
mail(subject: t('mailers.user_mailer.got_new_comment_for_subscription.subject', author_displayname: @author_displayname), to: subscription.user.email)
end
def send_thank_you_note(request_for_comments, receiver) def send_thank_you_note(request_for_comments, receiver)
@receiver_displayname = receiver.displayname @receiver_displayname = receiver.displayname
@author = request_for_comments.user.displayname @author = request_for_comments.user.displayname

View File

@ -1,7 +1,7 @@
class Comment < ActiveRecord::Base class Comment < ActiveRecord::Base
# inherit the creation module: encapsulates that this is a polymorphic user, offers some aliases and makes sure that all necessary attributes are set. # inherit the creation module: encapsulates that this is a polymorphic user, offers some aliases and makes sure that all necessary attributes are set.
include Creation include Creation
attr_accessor :username, :date, :updated attr_accessor :username, :date, :updated, :editable
belongs_to :file, class_name: 'CodeOcean::File' belongs_to :file, class_name: 'CodeOcean::File'
belongs_to :user, polymorphic: true belongs_to :user, polymorphic: true

View File

@ -2,4 +2,8 @@ class ExerciseCollection < ActiveRecord::Base
has_and_belongs_to_many :exercises has_and_belongs_to_many :exercises
end def to_s
"#{I18n.t('activerecord.models.exercise_collection.one')}: #{name} (#{id})"
end
end

View File

@ -5,11 +5,9 @@ class ExternalUser < ActiveRecord::Base
validates :external_id, presence: true validates :external_id, presence: true
def displayname def displayname
result = "User " + id.to_s result = name
if(!consumer.nil? && consumer.name == 'openHPI') if(result == nil || result == "")
result = Rails.cache.fetch("#{cache_key}/displayname", expires_in: 12.hours) do result = "User " + id.to_s
Xikolo::UserClient.get(external_id.to_s)[:display_name]
end
end end
result result
end end

View File

@ -37,35 +37,17 @@ class ProxyExercise < ActiveRecord::Base
assigned_user_proxy_exercise.exercise assigned_user_proxy_exercise.exercise
else else
matching_exercise = matching_exercise =
if (token.eql? "47f4c736")
Rails.logger.debug("Proxy exercise with token 47f4c736, split user in groups..")
group = UserGroupSeparator.getGroupWeek2Testing(user)
Rails.logger.debug("user assigned to group #{group}")
case group
when :group_a
exercises.where(id: 348).first
when :group_b
exercises.where(id: 349).first
when :group_c
exercises.where(id: 350).first
when :group_d
exercises.where(id: 351).first
end
else
Rails.logger.debug("find new matching exercise for user #{user.id}" ) Rails.logger.debug("find new matching exercise for user #{user.id}" )
begin begin
find_matching_exercise(user) find_matching_exercise(user)
rescue #fallback rescue => e #fallback
Rails.logger.error("finding matching exercise failed. Fall back to random exercise! Error: #{$!}" ) Rails.logger.error("finding matching exercise failed. Fall back to random exercise! Error: #{$!}" )
@reason[:reason] = "fallback because of error" @reason[:reason] = "fallback because of error"
@reason[:error] = "#{$!}" @reason[:error] = "#{$!}:\n\t#{e.backtrace.join("\n\t")}"
exercises.shuffle.first exercises.where("expected_difficulty > 1").shuffle.first # difficulty should be > 1 to prevent dummy exercise from being chosen.
end end
end
user.user_proxy_exercise_exercises << UserProxyExerciseExercise.create(user: user, exercise: matching_exercise, proxy_exercise: self, reason: @reason.to_json) user.user_proxy_exercise_exercises << UserProxyExerciseExercise.create(user: user, exercise: matching_exercise, proxy_exercise: self, reason: @reason.to_json)
matching_exercise matching_exercise
end end
recommended_exercise recommended_exercise
end end
@ -136,6 +118,7 @@ class ProxyExercise < ActiveRecord::Base
relative_knowledge_improvement[potex] += old_relative_loss_tag - new_relative_loss_tag relative_knowledge_improvement[potex] += old_relative_loss_tag - new_relative_loss_tag
end end
end end
highest_difficulty_user_has_accessed = exercises_user_has_accessed.map{|e| e.expected_difficulty}.sort.last || 0 highest_difficulty_user_has_accessed = exercises_user_has_accessed.map{|e| e.expected_difficulty}.sort.last || 0
best_matching_exercise = find_best_exercise(relative_knowledge_improvement, highest_difficulty_user_has_accessed) best_matching_exercise = find_best_exercise(relative_knowledge_improvement, highest_difficulty_user_has_accessed)
@reason[:reason] = "best matching exercise" @reason[:reason] = "best matching exercise"

View File

@ -5,6 +5,7 @@ class RequestForComment < ActiveRecord::Base
belongs_to :file, class_name: 'CodeOcean::File' belongs_to :file, class_name: 'CodeOcean::File'
has_many :comments, through: :submission has_many :comments, through: :submission
has_many :subscriptions
scope :unsolved, -> { where(solved: [false, nil]) } scope :unsolved, -> { where(solved: [false, nil]) }
@ -37,6 +38,14 @@ class RequestForComment < ActiveRecord::Base
submission.files.map { |file| file.comments.size}.sum submission.files.map { |file| file.comments.size}.sum
end end
def commenters
commenters = []
comments.distinct.to_a.each {|comment|
commenters.append comment.user
}
commenters.uniq {|user| user.id}
end
def to_s def to_s
"RFC-" + self.id.to_s "RFC-" + self.id.to_s
end end

View File

@ -34,7 +34,7 @@ class Submission < ActiveRecord::Base
end end
def normalized_score def normalized_score
::NewRelic::Agent.add_custom_parameters({ unnormalized_score: score }) ::NewRelic::Agent.add_custom_attributes({ unnormalized_score: score })
if !score.nil? && !exercise.maximum_score.nil? && (exercise.maximum_score > 0) if !score.nil? && !exercise.maximum_score.nil? && (exercise.maximum_score > 0)
score / exercise.maximum_score score / exercise.maximum_score
else else

View File

@ -0,0 +1,4 @@
class Subscription < ActiveRecord::Base
belongs_to :user, polymorphic: true
belongs_to :request_for_comment
end

View File

@ -12,14 +12,10 @@ class CommentPolicy < ApplicationPolicy
everyone everyone
end end
[:new?, :destroy?, :update?].each do |action| [:new?, :destroy?, :update?, :edit?].each do |action|
define_method(action) { admin? || author? } define_method(action) { admin? || author? }
end end
def edit?
admin?
end
def index? def index?
everyone everyone
end end

View File

@ -0,0 +1,3 @@
class ExerciseCollectionPolicy < AdminOnlyPolicy
end

View File

@ -0,0 +1,18 @@
class SubscriptionPolicy < ApplicationPolicy
def create?
everyone
end
def destroy?
author? || admin?
end
def show_error?
everyone
end
def author?
@user == @record.user
end
private :author?
end

View File

@ -1,5 +1,5 @@
#flash.fixed_error_messages data-message-failure=t('shared.message_failure') #flash.fixed_error_messages data-message-failure=t('shared.message_failure')
- %w[alert danger info notice success warning].each do |severity| - %w[alert danger info notice success warning].each do |severity|
div.alert.flash class="alert-#{{'alert' => 'warning', 'notice' => 'success'}.fetch(severity, severity)}" id="flash-#{severity}" div.alert.flash class="alert-#{{'alert' => 'warning', 'notice' => 'success'}.fetch(severity, severity)}"
p = flash[severity] p id="flash-#{severity}" = flash[severity]
span.fa.fa-times span.fa.fa-times

View File

@ -8,8 +8,8 @@
- if current_user.admin? - if current_user.admin?
li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path) li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path)
li.divider li.divider
- models = [ExecutionEnvironment, Exercise, Consumer, CodeHarborLink, ExternalUser, FileType, FileTemplate, - models = [ExecutionEnvironment, Exercise, ExerciseCollection, ProxyExercise, Tag, Consumer, CodeHarborLink,
ErrorTemplate, ErrorTemplateAttribute, InternalUser].sort_by { |model| model.model_name.human(count: 2) } ErrorTemplate, ErrorTemplateAttribute, ExternalUser, FileType, FileTemplate, InternalUser].sort_by {|model| model.model_name.human(count: 2) }
- models.each do |model| - models.each do |model|
- if policy(model).index? - if policy(model).index?
li = link_to(model.model_name.human(count: 2), send(:"#{model.model_name.collection}_path")) li = link_to(model.model_name.human(count: 2), send(:"#{model.model_name.collection}_path"))

View File

@ -1,4 +1,4 @@
json.array!(@comments) do |comment| json.array!(@comments) do |comment|
json.extract! comment, :id, :user_id, :file_id, :row, :column, :text, :username, :date, :updated json.extract! comment, :id, :user_id, :file_id, :row, :column, :text, :username, :date, :updated, :editable
json.url comment_url(comment, format: :json) json.url comment_url(comment, format: :json)
end end

View File

@ -0,0 +1,11 @@
- exercises = Exercise.order(:title)
= form_for(@exercise_collection, data: {exercises: exercises}, multipart: true) do |f|
= render('shared/form_errors', object: @exercise_collection)
.form-group
= f.label(:name)
= f.text_field(:name, class: 'form-control', required: true)
.form-group
= f.label(:exercises)
= f.collection_select(:exercise_ids, exercises, :id, :title, {}, {class: 'form-control', multiple: true})
.actions = render('shared/submit_button', f: f, object: @exercise_collection)

View File

@ -0,0 +1,3 @@
h1 = @exercise_collection
= render('form')

View File

@ -0,0 +1,24 @@
h1 = ExerciseCollection.model_name.human(count: 2)
.table-responsive
table.table
thead
tr
th = t('activerecord.attributes.exercise_collections.id')
th = t('activerecord.attributes.exercise_collections.name')
th = t('activerecord.attributes.exercise_collections.updated_at')
th = t('activerecord.attributes.exercise_collections.exercises')
th colspan=3 = t('shared.actions')
tbody
- @exercise_collections.each do |collection|
tr
td = collection.id
td = link_to(collection.name, collection)
td = collection.updated_at
td = collection.exercises.size
td = link_to(t('shared.show'), collection)
td = link_to(t('shared.edit'), edit_exercise_collection_path(collection))
td = link_to(t('shared.destroy'), collection, data: {confirm: t('shared.confirm_destroy')}, method: :delete)
= render('shared/pagination', collection: @exercise_collections)
p = render('shared/new_button', model: ExerciseCollection)

View File

@ -0,0 +1,3 @@
h1 = t('shared.new_model', model: ExerciseCollection.model_name.human)
= render('form')

View File

@ -0,0 +1,11 @@
h1
= @exercise_collection
= render('shared/edit_button', object: @exercise_collection)
= row(label: 'exercise_collections.name', value: @exercise_collection.name)
= row(label: 'exercise_collections.updated_at', value: @exercise_collection.updated_at)
h4 = t('activerecord.attributes.exercise_collections.exercises')
ul.list-unstyled
- @exercise_collection.exercises.sort_by{|c| c.title}.each do |exercise|
li = link_to(exercise, exercise)

View File

@ -1,9 +1,12 @@
h5 =t('exercises.implement.comment.addyours')
textarea.form-control(style='resize:none;')
#otherComments #otherComments
h5 =t('exercises.implement.comment.others') h5 =t('exercises.implement.comment.others')
pre#otherCommentsTextfield .container
p = ''
button#addCommentButton.btn.btn-block.btn-primary(type='button') =t('exercises.implement.comment.addCommentButton') label
button#removeAllButton.btn.btn-block.btn-warning(type='button') =t('exercises.implement.comment.removeAllOnLine') input#subscribe type='checkbox' title=t('request_for_comments.subscribe_to_author') data-subscription=Subscription.where(user: current_user, request_for_comment_id: @request_for_comment.id, subscription_type: 'author', deleted: false).try(:first).try(:id)
= t('request_for_comments.subscribe_to_author')
#myComment
h5 =t('exercises.implement.comment.addyours')
textarea.form-control
button#addCommentButton.btn.btn-block.btn-primary(type='button') =t('exercises.implement.comment.addCommentButton')

View File

@ -2,10 +2,10 @@
li.panel.panel-default li.panel.panel-default
.panel-heading role="tab" id="heading" .panel-heading role="tab" id="heading"
a.file-heading data-toggle="collapse" data-parent="#files" href="#collapse#{id}" a.file-heading data-toggle="collapse" href="#collapse#{id}"
div.clearfix role="button" div.clearfix role="button"
span = f.object.name span = f.object.name
.panel-collapse.collapse-in id="collapse#{id}" role="tabpanel" .panel-collapse.collapse class=('in' if f.object.name.nil?) id="collapse#{id}" role="tabpanel"
.panel-body .panel-body
.clearfix = link_to(t('shared.destroy'), '#', class:'btn btn-warning btn-sm discard-file pull-right') .clearfix = link_to(t('shared.destroy'), '#', class:'btn btn-warning btn-sm discard-file pull-right')
.form-group .form-group

View File

@ -38,19 +38,28 @@
.form-group .form-group
= f.label(t('activerecord.attributes.exercise.worktime')) = f.label(t('activerecord.attributes.exercise.worktime'))
= f.number_field "expected_worktime_minutes", value: @exercise.expected_worktime_seconds / 60, in: 1..1000, step: 1 = f.number_field "expected_worktime_minutes", value: @exercise.expected_worktime_seconds / 60, in: 1..1000, step: 1
h2 Tags
.table-responsive h2 = t('exercises.form.tags')
table.table#tags-table ul.list-unstyled.panel-group
thead li.panel.panel-default
tr .panel-heading role="tab" id="heading"
th = t('activerecord.attributes.exercise.selection') a.file-heading data-toggle="collapse" href="#tag-collapse"
th = sort_link(@search, :title, t('activerecord.attributes.tag.name')) div.clearfix role="button"
th = t('activerecord.attributes.tag.difficulty') span = t('exercises.form.click_to_collapse')
= collection_check_boxes :exercise, :tag_ids, @exercise_tags, :tag_id, :id do |b| .panel-collapse.collapse id="tag-collapse" role="tabpanel"
tr .table-responsive
td = b.check_box table.table#tags-table
td = b.object.tag.name thead
td = number_field "tag_factors[#{b.object.tag.id}]", :factor, :value => b.object.factor, in: 1..10, step: 1 tr
th = t('activerecord.attributes.exercise.selection')
th = sort_link(@search, :title, t('activerecord.attributes.tag.name'))
th = t('activerecord.attributes.tag.difficulty')
= collection_check_boxes :exercise, :tag_ids, @exercise_tags, :tag_id, :id do |b|
tr
td = b.check_box
td = b.object.tag.name
td = number_field "tag_factors[#{b.object.tag.id}]", :factor, :value => b.object.factor, in: 1..10, step: 1
h2 = t('activerecord.attributes.exercise.files') h2 = t('activerecord.attributes.exercise.files')
ul#files.list-unstyled.panel-group ul#files.list-unstyled.panel-group
= f.fields_for :files do |files_form| = f.fields_for :files do |files_form|

View File

@ -54,8 +54,10 @@ h1 = "#{@exercise} (external user #{@external_user})"
-submission_or_intervention.testruns.each do |run| -submission_or_intervention.testruns.each do |run|
- if run.passed - if run.passed
.unit-test-result.positive-result title=run.output .unit-test-result.positive-result title=run.output
- else - elsif run.failed
.unit-test-result.negative-result title=run.output .unit-test-result.negative-result title=run.output
- else
.unit-test-result.unknown-result title=run.output
td = Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0 td = Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0
-working_times_until.push((Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0)) -working_times_until.push((Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0))
- elsif submission_or_intervention.is_a? UserExerciseIntervention - elsif submission_or_intervention.is_a? UserExerciseIntervention

View File

@ -35,7 +35,7 @@ h1 = @exercise
tbody tbody
- @exercise.send(symbol).distinct().each do |user| - @exercise.send(symbol).distinct().each do |user|
- if user_statistics[user.id] then us = user_statistics[user.id] else us = {"maximum_score" => nil, "runs" => nil} - if user_statistics[user.id] then us = user_statistics[user.id] else us = {"maximum_score" => nil, "runs" => nil}
- label = current_user.teacher? ? "#{user.name}" : "#{user.name} (#{user.email})" - label = "#{user.displayname}"
tr tr
td = link_to_if symbol==:external_users, label, {controller: "exercises", action: "statistics", external_user_id: user.id, id: @exercise.id} td = link_to_if symbol==:external_users, label, {controller: "exercises", action: "statistics", external_user_id: user.id, id: @exercise.id}
td = us['maximum_score'] or 0 td = us['maximum_score'] or 0

View File

@ -10,7 +10,7 @@ h1 = ProxyExercise.model_name.human(count: 2)
thead thead
tr tr
th = sort_link(@search, :title, t('activerecord.attributes.proxy_exercise.title')) th = sort_link(@search, :title, t('activerecord.attributes.proxy_exercise.title'))
th = "Token" th = t('activerecord.attributes.exercise.token')
th = t('activerecord.attributes.proxy_exercise.files_count') th = t('activerecord.attributes.proxy_exercise.files_count')
th colspan=6 = t('shared.actions') th colspan=6 = t('shared.actions')
tbody tbody

View File

@ -10,6 +10,7 @@ h1
= row(label: 'exercise.title', value: @proxy_exercise.title) = row(label: 'exercise.title', value: @proxy_exercise.title)
= row(label: 'proxy_exercise.files_count', value: @exercises.count) = row(label: 'proxy_exercise.files_count', value: @exercises.count)
= row(label: 'exercise.description', value: @proxy_exercise.description) = row(label: 'exercise.description', value: @proxy_exercise.description)
= row(label: 'exercise.token', value: @proxy_exercise.token)
h3 Exercises h3 Exercises
.table-responsive .table-responsive
table.table table.table

View File

@ -0,0 +1,9 @@
br
h4 Admin Menu
h5
ul
li = link_to "User's current status of this exercise", statistics_external_user_exercise_path(id: @request_for_comment.exercise_id, external_user_id: @request_for_comment.user_id)
li = link_to "All exercises of this user", statistics_external_user_path(id: @request_for_comment.user_id)
ul
li = link_to "Implement the exercise yourself", implement_exercise_path(id: @request_for_comment.exercise_id)
li = link_to "Show the exercise", exercise_path(id: @request_for_comment.exercise_id)

View File

@ -0,0 +1,7 @@
button.btn.btn-primary#mark-as-solved-button = t('request_for_comments.mark_as_solved')
#thank-you-container
p = t('request_for_comments.write_a_thank_you_node')
textarea#thank-you-note
button.btn.btn-primary#send-thank-you-note = t('request_for_comments.send_thank_you_note')
button.btn.btn-default#cancel-thank-you-note = t('request_for_comments.cancel_thank_you_note')

View File

@ -1,6 +1,6 @@
<div class="list-group"> <div class="list-group">
<h4 id ="exercise_caption" class="list-group-item-heading" data-exercise-id="<%=@request_for_comment.exercise.id%>" data-comment-exercise-url="<%=create_comment_exercise_request_for_comment_path%>" data-rfc-id = "<%= @request_for_comment.id %>" > <h4 id ="exercise_caption" class="list-group-item-heading" data-exercise-id="<%=@request_for_comment.exercise.id%>" data-comment-exercise-url="<%=create_comment_exercise_request_for_comment_path%>" data-rfc-id = "<%= @request_for_comment.id %>" >
<% if (@request_for_comment.solved?) %> <% if @request_for_comment.solved? %>
<span class="fa fa-check" aria-hidden="true"></span> <span class="fa fa-check" aria-hidden="true"></span>
<% end %> <% end %>
<%= link_to(@request_for_comment.exercise.title, [:implement, @request_for_comment.exercise]) %> <%= link_to(@request_for_comment.exercise.title, [:implement, @request_for_comment.exercise]) %>
@ -9,61 +9,76 @@
<% <%
user = @request_for_comment.user user = @request_for_comment.user
submission = @request_for_comment.submission submission = @request_for_comment.submission
testruns = Testrun.where(:submission_id => @request_for_comment.submission)
%> %>
<%= user.displayname %> | <%= @request_for_comment.created_at.localtime %> <%= user.displayname %> | <%= @request_for_comment.created_at.localtime %>
</p> </p>
<div class="rfc">
<h5> <div class="description">
<u><%= t('activerecord.attributes.exercise.description') %>:</u> <%= render_markdown(@request_for_comment.exercise.description) %>
</h5>
<h5>
<% if @request_for_comment.question and not @request_for_comment.question == '' %>
<u><%= t('activerecord.attributes.request_for_comments.question')%>:</u> "<%= @request_for_comment.question %>"
<% else %>
<u><%= t('activerecord.attributes.request_for_comments.question')%>:</u> <%= t('request_for_comments.no_question') %>
<% end %>
</h5>
<% if (policy(@request_for_comment).mark_as_solved? and not @request_for_comment.solved?) %>
<button class="btn btn-primary" id="mark-as-solved-button">
<%= t('request_for_comments.mark_as_solved') %>
</button>
<div id="thank-you-container">
<p>
<%= t('request_for_comments.write_a_thank_you_node') %>
</p>
<textarea id="thank-you-note"></textarea>
<button class="btn btn-primary" id="send-thank-you-note">
<%= t('request_for_comments.send_thank_you_note') %>
</button>
<button class="btn btn-default" id="cancel-thank-you-note">
<%= t('request_for_comments.cancel_thank_you_note') %>
</button>
</div>
<% end %>
<% if @current_user.admin? && user.is_a?(ExternalUser) %>
<br>
<br>
<h4>Admin Menu</h4>
<h5> <h5>
<ul> <%= t('activerecord.attributes.exercise.description') %>
<li><%= link_to "User's current status of this exercise", statistics_external_user_exercise_path(id: @request_for_comment.exercise_id, external_user_id: @request_for_comment.user_id) %></li>
<li><%= link_to "All exercises of this user", statistics_external_user_path(id: @request_for_comment.user_id) %></li> <br>
<li><%= link_to "Implement the exercise yourself", implement_exercise_path(id: @request_for_comment.exercise_id) %> </li>
<li><%= link_to "Show the exercise", exercise_path(id: @request_for_comment.exercise_id) %> </li>
</ul>
</h5> </h5>
<% end %> <div class="text">
<h5> <span class="fa fa-chevron-up collapse-button"></span>
<u><%= t('request_for_comments.howto_title') %></u><br> <%= render_markdown(t('request_for_comments.howto')) %> <%= render_markdown(@request_for_comment.exercise.description) %>
</h5> </div>
</div>
<div class="question">
<h5>
<%= t('activerecord.attributes.request_for_comments.question')%>
</h5>
<div class="text">
<% question = @request_for_comment.question %>
<%= question.nil? or question.empty? ? t('request_for_comments.no_question') : question %>
</div>
</div>
<% if policy(@request_for_comment).mark_as_solved? and not @request_for_comment.solved? %>
<%= render('mark_as_solved') %>
<% end %>
<% if testruns.size > 0 %>
<div class="testruns">
<% output_runs = testruns.select { |run| run.cause == 'run' } %>
<% if output_runs.size > 0 %>
<h5><%= t('request_for_comments.runtime_output') %></h5>
<div class="testrun-output text">
<span class="fa fa-chevron-up collapse-button"></span>
<% output_runs.each do |testrun| %>
<pre><%= testrun.try(:output) or t('request_for_comments.no_output') %></pre>
<% end %>
</div>
<% end %>
<% assess_runs = testruns.select { |run| run.cause == 'assess' } %>
<% if assess_runs.size > 0 %>
<h5><%= t('request_for_comments.test_results') %></h5>
<div class="testrun-assess-results">
<% assess_runs.each do |testrun| %>
<div class="result <%= testrun.passed ? 'passed' : 'failed' %>" title="<%= testrun.output %>"></div>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% if @current_user.admin? && user.is_a?(ExternalUser) %>
<%= render('admin_menu') %>
<% end %>
<hr>
<div class="howto">
<h5>
<%= t('request_for_comments.howto_title') %>
</h5>
<div class="text">
<%= render_markdown(t('request_for_comments.howto')) %>
</div>
</div>
</div>
</div> </div>
<hr>
<div class="hidden sanitizer"></div> <div class="hidden sanitizer"></div>
<!-- <!--
@ -81,8 +96,13 @@ also, all settings from the rails model needed for the editor configuration in t
<script type="text/javascript"> <script type="text/javascript">
$('.modal-content').draggable({
handle: '.modal-header'
}).resizable({
autoHide: true
});
var solvedButton = $('#mark-as-solved-button'); var solvedButton = $('#mark-as-solved-button');
var commentOnExerciseButton = $('#comment-exercise-button');
var addCommentExerciseButton = $('#addCommentExerciseButton'); var addCommentExerciseButton = $('#addCommentExerciseButton');
var thankYouContainer = $('#thank-you-container'); var thankYouContainer = $('#thank-you-container');
@ -123,6 +143,12 @@ also, all settings from the rails model needed for the editor configuration in t
thankYouContainer.hide(); thankYouContainer.hide();
}); });
$('.text > .collapse-button').on('click', function(e) {
$(this).toggleClass('fa-chevron-down');
$(this).toggleClass('fa-chevron-up');
$(this).parent().toggleClass('collapsed');
});
// set file paths for ace // set file paths for ace
var ACE_FILES_PATH = '/assets/ace/'; var ACE_FILES_PATH = '/assets/ace/';
_.each(['modePath', 'themePath', 'workerPath'], function(attribute) { _.each(['modePath', 'themePath', 'workerPath'], function(attribute) {
@ -138,102 +164,82 @@ also, all settings from the rails model needed for the editor configuration in t
currentEditor.getSession().setMode($(editor).data('mode')); currentEditor.getSession().setMode($(editor).data('mode'));
currentEditor.getSession().setOption("useWorker", false); currentEditor.getSession().setOption("useWorker", false);
currentEditor.commentVisualsByLine = {};
setAnnotations(currentEditor, $(editor).data('file-id')); setAnnotations(currentEditor, $(editor).data('file-id'));
currentEditor.on("guttermousedown", handleSidebarClick); currentEditor.on("guttermousedown", handleSidebarClick);
currentEditor.on("guttermousemove", showPopover);
}); });
function cleanupPopovers() { function preprocess(commentText) {
// remove all possible popovers // sanitize comments to deal with XSS attacks:
$('.editor > .ace_gutter > .ace_gutter-layer > .ace_gutter-cell').popover('destroy'); commentText = $('div.sanitizer').text(commentText).html();
// display original line breaks:
return commentText.replace(/\n/g, '<br>');
} }
function preprocess(commentText) { function generateCommentHtmlContent(comments) {
// sanitize comments to deal with XSS attacks: var htmlContent = '';
commentText = $('div.sanitizer').text(commentText).html(); comments.forEach(function(comment, index) {
// display original line breaks: var commentText = preprocess(comment.text);
return commentText.replace(/\n/g, '<br>'); if (index !== 0) {
htmlContent += '<div class="comment-divider"></div>'
}
htmlContent += '\
<div class="comment" data-comment-id=' + comment.id + '> \
<div class="comment-header"> \
<div class="comment-username">' + preprocess(comment.username) + '</div> \
<div class="comment-date">' + comment.date + '</div> \
<div class="comment-updated' + (comment.updated ? '' : ' hidden') + '"> \
<i class="fa fa-pencil" aria-hidden="true"></i> \
<%= t('request_for_comments.comment_edited') %> \
</div> \
</div> \
<div class="comment-content">' + commentText + '</div> \
<textarea class="comment-editor">' + commentText + '</textarea> \
<div class="comment-actions' + (comment.editable ? '' : ' hidden') + '"> \
<button class="action-edit btn btn-xs btn-warning"><%= t('shared.edit') %></button> \
<button class="action-delete btn btn-xs btn-danger"><%= t('shared.destroy') %></button> \
</div> \
</div>';
});
return htmlContent;
}
function buildPopover(comments, where) {
// only display the newest three comments in preview
var maxComments = 3;
var htmlContent = generateCommentHtmlContent(comments.reverse().slice(0, maxComments));
if (comments.length > maxComments) {
// add a hint that there are more comments than shown here
htmlContent += '<div class="popover-footer"><%= t('request_for_comments.click_for_more_comments') %></div>'
.replace('${numComments}', String(comments.length - maxComments));
}
where.popover({
content: htmlContent,
html: true, // necessary to style comments. XSS is not possible due to comment pre-processing (sanitizing)
trigger: 'manual', // can only be triggered by $(where).popover('show' | 'hide')
container: 'body'
});
} }
function setAnnotations(editor, fileid) { function setAnnotations(editor, fileid) {
var session = editor.getSession(); var session = editor.getSession();
var jqrequest = $.ajax({ var jqrequest = $.ajax({
dataType: 'json', dataType: 'json',
method: 'GET', method: 'GET',
url: '/comments', url: '/comments',
data: { data: {
file_id: fileid file_id: fileid
} }
}); });
jqrequest.done(function(response){ jqrequest.done(function(response){
// comments need to be sorted to cluster them per line
var comments = response.slice().sort(function (a, b) {
return a.row - b.row;
});
while (comments.length > 0) {
// new cluster of comments
var cluster = [];
var clusterRow = comments[0].row;
// now collect all comments on this line
while (comments.length > 0 && comments[0].row === clusterRow) {
cluster.push(comments.shift());
}
// sort the comments by creation date
cluster = cluster.sort(function (a, b) {
return a.id - b.id;
});
// build the markup for the current line's popover
var popupContent = '';
cluster.forEach(function(comment, index) {
if (index !== 0) {
popupContent += '<div class="popover-divider"></div>'
}
popupContent += '<p class="comment">';
popupContent += '<div class="popover-header">' +
'<div class="popover-username">' + preprocess(comment.username) + '</div>' +
'<div class="popover-date">' + comment.date + '</div>';
if (comment.updated) {
popupContent += '<div class="popover-updated">' +
'<i class="fa fa-pencil" aria-hidden="true"></i>' +
'<%= t('request_for_comments.comment_edited') %>' +
'</div>'
}
popupContent += '</div>';
popupContent += '<div class="popover-comment">' + preprocess(comment.text) + '</div>';
popupContent += '</p>';
});
// attach the popover to the ace sidebar (where the comment icon is displayed)
var icon = $('*[data-file-id="' + fileid + '"]') // the editor for this file
.find('.ace_gutter > .ace_gutter-layer') // the sidebar
.find('div:nth-child(' + (clusterRow + 1) + ')'); // the correct line
icon.popover({
content: popupContent,
html: true, // necessary to style comments. XSS is not possible due to comment pre-processing (sanitizing)
trigger: 'hover',
container: 'body'
});
}
$.each(response, function(index, comment) { $.each(response, function(index, comment) {
comment.className = 'code-ocean_comment'; comment.className = 'code-ocean_comment';
// if we have tabs or carriage returns in the comment, just add the name and leave it as it is. otherwise: format!
if(comment.text.includes("\n") || comment.text.includes("\t")){
comment.text = comment.username + ": " + comment.text;
} else {
comment.text = comment.username + ": " + stringDivider(comment.text, 80, "\n");
}
}); });
session.setAnnotations(response); session.setAnnotations(response);
}) });
}
function hasCommentsInRow(editor, row){
return editor.getSession().getAnnotations().some(function(element) {
return element.row === row;
})
} }
function getCommentsForRow(editor, row){ function getCommentsForRow(editor, row){
@ -242,23 +248,36 @@ also, all settings from the rails model needed for the editor configuration in t
}) })
} }
function deleteComment(file_id, row, editor) { function deleteComment(commentId, editor, file_id, callback) {
cleanupPopovers();
var jqxhr = $.ajax({ var jqxhr = $.ajax({
type: 'DELETE', type: 'DELETE',
url: "/comments", url: "/comments/" + commentId
data: {
row: row,
file_id: file_id }
}); });
jqxhr.done(function (response) { jqxhr.done(function () {
setAnnotations(editor, file_id); setAnnotations(editor, file_id);
callback();
});
jqxhr.fail(ajaxError);
}
function updateComment(commentId, text, editor, file_id, callback) {
var jqxhr = $.ajax({
type: 'PATCH',
url: "/comments/" + commentId,
data: {
comment: {
text: text
}
}
});
jqxhr.done(function () {
setAnnotations(editor, file_id);
callback();
}); });
jqxhr.fail(ajaxError); jqxhr.fail(ajaxError);
} }
function createComment(file_id, row, editor, commenttext){ function createComment(file_id, row, editor, commenttext){
cleanupPopovers();
var jqxhr = $.ajax({ var jqxhr = $.ajax({
data: { data: {
comment: { comment: {
@ -273,71 +292,173 @@ also, all settings from the rails model needed for the editor configuration in t
method: 'POST', method: 'POST',
url: "/comments" url: "/comments"
}); });
jqxhr.done(function(response){ jqxhr.done(function(){
setAnnotations(editor, file_id); setAnnotations(editor, file_id);
}); });
jqxhr.fail(ajaxError); jqxhr.fail(ajaxError);
} }
function createCommentOnExercise(file_id, row, editor, commenttext){ function subscribeToRFC(subscriptionType, checkbox){
var jqxhr = $.ajax({ checkbox.attr("disabled", true);
data: { var jqxhr = $.ajax({
comment: { data: {
file_id: file_id, subscription: {
row: row, request_for_comment_id: $('h4#exercise_caption').data('rfc-id'),
column: 0, subscription_type: subscriptionType
text: commenttext, }
request_id: $('h4#exercise_caption').data('rfc-id') },
} dataType: 'json',
}, method: 'POST',
dataType: 'json', url: "/subscriptions.json"
method: 'POST', });
url: "/comments" jqxhr.done(function(subscription) {
}); checkbox.data('subscription', subscription.id);
jqxhr.done(function(response){ checkbox.attr("disabled", false);
setAnnotations(editor, file_id); });
}); jqxhr.fail(function(response) {
jqxhr.fail(ajaxError); checkbox.prop('checked', false);
checkbox.attr("disabled", false);
ajaxError(response);
});
} }
function unsubscribeFromRFC(checkbox) {
checkbox.attr("disabled", true);
var subscriptionId = checkbox.data('subscription');
var jqxhr = $.ajax({
url: '/subscriptions/' + subscriptionId + '/unsubscribe.json'
});
jqxhr.done(function(response) {
checkbox.prop('checked', false);
checkbox.data('subscription', null);
checkbox.attr("disabled", false);
$.flash.success({text: response.message});
});
jqxhr.fail(function(response) {
checkbox.prop('checked', true);
checkbox.attr("disabled", false);
ajaxError(response);
});
}
var lastRow = null;
var lastTarget = null;
function showPopover(e) {
var target = e.domEvent.target;
var row = e.getDocumentPosition().row;
if (target.className.indexOf('ace_gutter-cell') === -1 || lastRow === row) {
return;
}
if (lastTarget === target) {
// sometimes the row gets updated before the DOM event target, so we need to wait for it to change
return;
}
lastRow = row;
var editor = e.editor;
var comments = getCommentsForRow(editor, row);
buildPopover(comments, $(target));
lastTarget = target;
$(target).popover('show');
$(target).on('mouseleave', function () {
$(this).off('mouseleave');
$(this).popover('destroy');
});
}
$('.ace_gutter').on('mouseleave', function () {
lastRow = null;
lastTarget = null;
});
function handleSidebarClick(e) { function handleSidebarClick(e) {
var target = e.domEvent.target; var target = e.domEvent.target;
var editor = e.editor; if (target.className.indexOf('ace_gutter-cell') === -1) return;
if (target.className.indexOf("ace_gutter-cell") == -1) return; var editor = e.editor;
var fileid = $(editor.container).data('file-id');
var row = e.getDocumentPosition().row; var row = e.getDocumentPosition().row;
e.stop(); e.stop();
$('.modal-title').text('<%= t('request_for_comments.modal_title') %>'.replace('${line}', row + 1));
var commentModal = $('#comment-modal'); var commentModal = $('#comment-modal');
if (hasCommentsInRow(editor, row)) { var otherComments = commentModal.find('#otherComments');
var rowComments = getCommentsForRow(editor, row); var htmlContent = generateCommentHtmlContent(getCommentsForRow(editor, row));
var comments = _.pluck(rowComments, 'text').join('\n'); if (htmlContent) {
commentModal.find('#otherComments').show(); otherComments.show();
commentModal.find('#otherCommentsTextfield').text(comments); var container = otherComments.find('.container');
container.html(htmlContent);
var deleteButtons = container.find('.action-delete');
deleteButtons.on('click', function (event) {
var button = $(event.target);
var parent = $(button).parent().parent();
var commentId = parent.data('comment-id');
deleteComment(commentId, editor, fileid, function () {
parent.html('<div class="comment-removed"><%= t('comments.deleted') %></div>');
});
});
var editButtons = container.find('.action-edit');
editButtons.on('click', function (event) {
var button = $(event.target);
var parent = $(button).parent().parent();
var commentId = parent.data('comment-id');
var currentlyEditing = button.data('editing');
var deleteButton = parent.find('.action-delete');
var commentContent = parent.find('.comment-content');
var commentEditor = parent.find('textarea.comment-editor');
var commentUpdated = parent.find('.comment-updated');
if (currentlyEditing) {
updateComment(commentId, commentEditor.val(), editor, fileid, function () {
button.text('<%= t('shared.edit') %>');
button.data('editing', false);
commentContent.text(commentEditor.val());
deleteButton.show();
commentContent.show();
commentEditor.hide();
commentUpdated.removeClass('hidden');
});
} else {
button.text('<%= t('comments.save_update') %>');
button.data('editing', true);
deleteButton.hide();
commentContent.hide();
commentEditor.show();
}
});
} else { } else {
commentModal.find('#otherComments').hide(); otherComments.hide();
} }
commentModal.find('#addCommentButton').off('click'); var subscribeCheckbox = commentModal.find('#subscribe');
commentModal.find('#removeAllButton').off('click'); subscribeCheckbox.prop('checked', subscribeCheckbox.data('subscription'));
subscribeCheckbox.off('change');
commentModal.find('#addCommentButton').on('click', function(e){ subscribeCheckbox.on('change', function() {
var commenttext = commentModal.find('textarea').val(); if (this.checked) {
var file_id = $(editor.container).data('file-id'); subscribeToRFC('author', $(this));
} else {
if (commenttext !== "") { unsubscribeFromRFC($(this));
createComment(file_id, row, editor, commenttext);
commentModal.find('textarea').val('') ;
commentModal.modal('hide');
} }
}); });
commentModal.find('#removeAllButton').on('click', function(e){ var addCommentButton = commentModal.find('#addCommentButton');
var file_id = $(editor.container).data('file-id'); addCommentButton.off('click');
deleteComment(file_id, row, editor); addCommentButton.on('click', function(){
commentModal.modal('hide'); var commentTextarea = commentModal.find('#myComment > textarea');
var commenttext = commentTextarea.val();
if (commenttext !== "") {
createComment(fileid, row, editor, commenttext);
commentTextarea.val('') ;
commentModal.modal('hide');
}
}); });
commentModal.modal('show'); commentModal.modal('show');
@ -351,19 +472,4 @@ also, all settings from the rails model needed for the editor configuration in t
}); });
} }
function stringDivider(str, width, spaceReplacer) {
if (str.length>width) {
var p=width;
for (;p>0 && str[p]!=' ';p--) {
}
if (p>0) {
var left = str.substring(0, p);
var right = str.substring(p+1);
return left + spaceReplacer + stringDivider(right, width, spaceReplacer);
}
}
return str;
}
</script> </script>

View File

@ -0,0 +1,7 @@
== t('mailers.user_mailer.got_new_comment_for_subscription.body',
receiver_displayname: @receiver_displayname, link_to_comment: link_to(@rfc_link, @rfc_link),
unsubscribe_link: link_to(@unsubscribe_link, @unsubscribe_link),
author_displayname: @author_displayname,
comment_text: @comment_text,
link_my_comments: link_to(t('request_for_comments.index.get_my_comment_requests'), my_request_for_comments_url),
link_all_comments: link_to(t('request_for_comments.index.all'), request_for_comments_url) )

View File

@ -42,6 +42,7 @@ de:
allow_file_creation: "Dateierstellung erlauben" allow_file_creation: "Dateierstellung erlauben"
difficulty: Schwierigkeitsgrad difficulty: Schwierigkeitsgrad
worktime: "vermutete Arbeitszeit in Minuten" worktime: "vermutete Arbeitszeit in Minuten"
token: "Aufgaben-Token"
proxy_exercise: proxy_exercise:
title: Title title: Title
files_count: Anzahl der Aufgaben files_count: Anzahl der Aufgaben
@ -120,6 +121,11 @@ de:
key: "Name" key: "Name"
description: "Beschreibung" description: "Beschreibung"
regex: "Regulärer Ausdruck" regex: "Regulärer Ausdruck"
exercise_collections:
id: "ID"
name: "Name"
updated_at: "Letzte Änderung"
exercises: "Aufgaben"
models: models:
code_harbor_link: code_harbor_link:
one: CodeHarbor-Link one: CodeHarbor-Link
@ -142,6 +148,9 @@ de:
exercise: exercise:
one: Aufgabe one: Aufgabe
other: Aufgaben other: Aufgaben
exercise_collection:
one: Aufgabesammlung
other: Aufgabensammlungen
proxy_exercise: proxy_exercise:
one: Proxy Aufgabe one: Proxy Aufgabe
other: Proxy Aufgaben other: Proxy Aufgaben
@ -169,9 +178,15 @@ de:
submission: submission:
one: Abgabe one: Abgabe
other: Abgaben other: Abgaben
tag:
one: Tag
other: Tags
user_exercise_feedback: user_exercise_feedback:
one: Feedback one: Feedback
other: Feedback other: Feedback
comment:
one: Kommentar
other: Kommentare
errors: errors:
messages: messages:
together: 'muss zusammen mit %{attribute} definiert werden' together: 'muss zusammen mit %{attribute} definiert werden'
@ -270,6 +285,8 @@ de:
path: 'Pfad der Datei im Projektverzeichnis. Kann auch leer gelassen werden.' path: 'Pfad der Datei im Projektverzeichnis. Kann auch leer gelassen werden.'
form: form:
add_file: Datei hinzufügen add_file: Datei hinzufügen
tags: "Tags"
click_to_collapse: "Zum Aus-/Einklappen hier klicken..."
implement: implement:
alert: alert:
text: 'Ihr Browser unterstützt nicht alle Funktionalitäten, die %{application_name} benötigt. Bitte nutzen Sie einen modernen Browser, um %{application_name} zu besuchen.' text: 'Ihr Browser unterstützt nicht alle Funktionalitäten, die %{application_name} benötigt. Bitte nutzen Sie einen modernen Browser, um %{application_name} zu besuchen.'
@ -462,14 +479,49 @@ de:
<br> <br>
This mail was automatically sent by CodeOcean. <br> This mail was automatically sent by CodeOcean. <br>
subject: "%{author} sagt Danke!" subject: "%{author} sagt Danke!"
got_new_comment_for_subscription:
body: |
English version below <br>
_________________________<br>
<br>
Hallo %{receiver_displayname}, <br>
<br>
es gibt einen neuen Kommentar von %{author_displayname} zu einer Kommentaranfrage auf CodeOcean, die Sie abonniert haben. <br>
<br>
%{author_displayname} schreibt: %{comment_text}<br>
<br>
Sie finden die Kommentaranfrage hier: %{link_to_comment} <br>
<br>
Falls Sie beim Klick auf diesen Link eine Fehlermeldung erhalten, dass Sie nicht berechtigt wären diese Aktion auszuführen, öffnen Sie bitte eine beliebige Programmieraufgabe aus einem Kurs heraus und klicken den Link danach noch einmal.<br>
<br>
Wenn Sie keine weiteren Benachrichtigungen zu dieser Anfrage erhalten möchten, klicken Sie bitte hier: %{unsubscribe_link}
<br>
Diese Mail wurde automatisch von CodeOcean verschickt.<br>
<br>
_________________________<br>
<br>
Dear %{receiver_displayname}, <br>
<br>
you received a new comment from %{author_displayname} to a request for comments on CodeOcean that you have subscribed to. <br>
<br>
%{author_displayname} wrote: %{comment_text} <br>
<br>
You can find the request for comments here: %{link_to_comment} <br>
<br>
If you receive an error that you are not authorized to perform this action when clicking the link, please log-in through any course exercise beforehand and click the link again. <br>
<br>
If you don't want to be notified about further comments, please click here: %{unsubscribe_link}
<br>
This mail was automatically sent by CodeOcean. <br>
subject: "%{author_displayname} hat einen neuen Kommentar in einer Diskussion veröffentlicht, die Sie abonniert haben."
request_for_comments: request_for_comments:
click_here: Zum Kommentieren auf die Seitenleiste klicken! click_here: Zum Kommentieren auf die Seitenleiste klicken!
comments: Kommentare comments: Kommentare
howto: | howto: |
Um Kommentare zu einer Programmzeile hinzuzufügen, kann einfach auf die jeweilige Zeilennummer auf der linken Seite geklickt werden. <br> Um Kommentare zu einer Programmzeile hinzuzufügen, kann einfach auf die jeweilige Zeilennummer auf der linken Seite geklickt werden. <br>
Es öffnet sich ein Textfeld, in dem der Kommentar eingetragen werden kann. <br> Es öffnet sich ein Textfeld, in dem der Kommentar eingetragen werden kann. <br>
Mit "Kommentieren" wird der Kommentar dann gesichert und taucht als Sprechblase neben der Zeile auf. Mit "Kommentar abschicken" wird der Kommentar dann gesichert und taucht als Sprechblase neben der Zeile auf.
howto_title: 'Anleitung:' howto_title: 'Anleitung'
index: index:
get_my_comment_requests: Meine Kommentaranfragen get_my_comment_requests: Meine Kommentaranfragen
all: "Alle Kommentaranfragen" all: "Alle Kommentaranfragen"
@ -486,6 +538,12 @@ de:
send_thank_you_note: "Senden" send_thank_you_note: "Senden"
cancel_thank_you_note: "Nichts senden" cancel_thank_you_note: "Nichts senden"
comment_edited: "bearbeitet" comment_edited: "bearbeitet"
modal_title: "Einen Kommentar in Zeile ${line} hinzufügen"
click_for_more_comments: "Klicken um ${numComments} weitere Kommentare zu sehen..."
subscribe_to_author: "Bei neuen Kommentaren des Autors per E-Mail benachrichtigt werden"
no_output: "Keine Ausgabe."
runtime_output: "Programmausgabe"
test_results: "Testergebnisse"
sessions: sessions:
create: create:
failure: Fehlerhafte E-Mail oder Passwort. failure: Fehlerhafte E-Mail oder Passwort.
@ -605,3 +663,9 @@ de:
signature: "Ein regulärer Ausdruck in Ruby-Syntax und ohne führende und schließende \"/\"" signature: "Ein regulärer Ausdruck in Ruby-Syntax und ohne führende und schließende \"/\""
attributes: "Attribute" attributes: "Attribute"
add_attribute: "Attribut hinzufügen" add_attribute: "Attribut hinzufügen"
comments:
deleted: "Gelöscht"
save_update: "Speichern"
subscriptions:
successfully_unsubscribed: "Ihr Abonnement für weitere Kommentare auf dieser Kommentaranfrage wurde erfolgreich beendet."
subscription_not_existent: "Das Abonnement, von dem Sie sich abmelden wollen, existiert nicht."

View File

@ -1,24 +1,3 @@
# Files in the config/locales directory are used for internationalization
# and are automatically loaded by Rails. If you want to use locales other
# than English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
# I18n.t 'hello'
#
# In views, this is aliased to just `t`:
#
# <%= t('hello') %>
#
# To use a different locale, set it with `I18n.locale`:
#
# I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# To learn more, please read the Rails Internationalization guide
# available at http://guides.rubyonrails.org/i18n.html.
en: en:
activerecord: activerecord:
attributes: attributes:
@ -63,6 +42,7 @@ en:
allow_file_creation: "Allow file creation" allow_file_creation: "Allow file creation"
difficulty: Difficulty difficulty: Difficulty
worktime: "Expected worktime in minutes" worktime: "Expected worktime in minutes"
token: "Exercise Token"
proxy_exercise: proxy_exercise:
title: Title title: Title
files_count: Exercises Count files_count: Exercises Count
@ -141,6 +121,11 @@ en:
key: "Identifier" key: "Identifier"
description: "Description" description: "Description"
regex: "Regular Expression" regex: "Regular Expression"
exercise_collections:
id: "ID"
name: "Name"
updated_at: "Last Update"
exercises: "Exercises"
models: models:
code_harbor_link: code_harbor_link:
one: CodeHarbor Link one: CodeHarbor Link
@ -163,6 +148,9 @@ en:
exercise: exercise:
one: Exercise one: Exercise
other: Exercises other: Exercises
exercise_collection:
one: Exercise Collection
other: Exercise Collections
proxy_exercise: proxy_exercise:
one: Proxy Exercise one: Proxy Exercise
other: Proxy Exercises other: Proxy Exercises
@ -190,9 +178,15 @@ en:
submission: submission:
one: Submission one: Submission
other: Submissions other: Submissions
tag:
one: Tag
other: Tags
user_exercise_feedback: user_exercise_feedback:
one: Feedback one: Feedback
other: Feedback other: Feedback
comment:
one: Comment
other: Comments
errors: errors:
messages: messages:
together: 'has to be set along with %{attribute}' together: 'has to be set along with %{attribute}'
@ -291,6 +285,8 @@ en:
path: "The file's path in the project tree. Can be left blank." path: "The file's path in the project tree. Can be left blank."
form: form:
add_file: Add file add_file: Add file
tags: "Tags"
click_to_collapse: "Click to expand/collapse..."
implement: implement:
alert: alert:
text: 'Your browser does not support features required for using %{application_name}. Please access %{application_name} using a modern browser.' text: 'Your browser does not support features required for using %{application_name}. Please access %{application_name} using a modern browser.'
@ -483,6 +479,41 @@ en:
<br> <br>
This mail was automatically sent by CodeOcean. <br> This mail was automatically sent by CodeOcean. <br>
subject: "%{author} says thank you!" subject: "%{author} says thank you!"
got_new_comment_for_subscription:
body: |
English version below <br>
_________________________<br>
<br>
Hallo %{receiver_displayname}, <br>
<br>
es gibt einen neuen Kommentar von %{author_displayname} zu einer Kommentaranfrage auf CodeOcean, die Sie abonniert haben. <br>
<br>
%{author_displayname} schreibt: %{comment_text}<br>
<br>
Sie finden die Kommentaranfrage hier: %{link_to_comment} <br>
<br>
Falls Sie beim Klick auf diesen Link eine Fehlermeldung erhalten, dass Sie nicht berechtigt wären diese Aktion auszuführen, öffnen Sie bitte eine beliebige Programmieraufgabe aus einem Kurs heraus und klicken den Link danach noch einmal.<br>
<br>
Wenn Sie keine weiteren Benachrichtigungen zu dieser Anfrage erhalten möchten, klicken Sie bitte hier: %{unsubscribe_link}
<br>
Diese Mail wurde automatisch von CodeOcean verschickt.<br>
<br>
_________________________<br>
<br>
Dear %{receiver_displayname}, <br>
<br>
you received a new comment from %{author_displayname} to a request for comments on CodeOcean that you have subscribed to. <br>
<br>
%{author_displayname} wrote: %{comment_text} <br>
<br>
You can find the request for comments here: %{link_to_comment} <br>
<br>
If you receive an error that you are not authorized to perform this action when clicking the link, please log-in through any course exercise beforehand and click the link again. <br>
<br>
If you don't want to be notified about further comments, please click here: %{unsubscribe_link}
<br>
This mail was automatically sent by CodeOcean. <br>
subject: "%{author_displayname} has posted a new comment to a discussion you subscribed to on CodeOcean."
request_for_comments: request_for_comments:
click_here: Click on this sidebar to comment! click_here: Click on this sidebar to comment!
comments: Comments comments: Comments
@ -490,7 +521,7 @@ en:
To leave comments to a specific code line, click on the respective line number. <br> To leave comments to a specific code line, click on the respective line number. <br>
Enter your comment in the popup and save it by clicking "Comment this". <br> Enter your comment in the popup and save it by clicking "Comment this". <br>
Your comment will show up next to the line number as a speech bubble symbol. Your comment will show up next to the line number as a speech bubble symbol.
howto_title: 'How to comment:' howto_title: 'How to comment'
index: index:
all: All Requests for Comments all: All Requests for Comments
get_my_comment_requests: My Requests for Comments get_my_comment_requests: My Requests for Comments
@ -507,6 +538,12 @@ en:
send_thank_you_note: "Send" send_thank_you_note: "Send"
cancel_thank_you_note: "Don't send" cancel_thank_you_note: "Don't send"
comment_edited: "edited" comment_edited: "edited"
modal_title: "Add a comment to line ${line}"
click_for_more_comments: "Click to view ${numComments} more comments..."
subscribe_to_author: "Receive E-Mail notifications for new comments of the original author"
no_output: "No output."
runtime_output: "Runtime Output"
test_results: "Test Results"
sessions: sessions:
create: create:
failure: Invalid email or password. failure: Invalid email or password.
@ -626,3 +663,9 @@ en:
signature: "A regular expression in Ruby syntax without leading and trailing \"/\"" signature: "A regular expression in Ruby syntax without leading and trailing \"/\""
attributes: "Attributes" attributes: "Attributes"
add_attribute: "Add attribute" add_attribute: "Add attribute"
comments:
deleted: "Deleted"
save_update: "Save"
subscriptions:
successfully_unsubscribed: "You successfully unsubscribed from this Request for Comment"
subscription_not_existent: "The subscription you want to unsubscribe from does not exist."

View File

@ -21,17 +21,19 @@ Rails.application.routes.draw do
post :set_thank_you_note post :set_thank_you_note
end end
end end
resources :comments, except: [:destroy] do resources :comments
collection do
delete :destroy
end
end
get '/my_request_for_comments', as: 'my_request_for_comments', to: 'request_for_comments#get_my_comment_requests' get '/my_request_for_comments', as: 'my_request_for_comments', to: 'request_for_comments#get_my_comment_requests'
get '/my_rfc_activity', as: 'my_rfc_activity', to: 'request_for_comments#get_rfcs_with_my_comments' get '/my_rfc_activity', as: 'my_rfc_activity', to: 'request_for_comments#get_rfcs_with_my_comments'
delete '/comment_by_id', to: 'comments#destroy_by_id' delete '/comment_by_id', to: 'comments#destroy_by_id'
put '/comments', to: 'comments#update' put '/comments', to: 'comments#update'
resources :subscriptions do
member do
get :unsubscribe, to: 'subscriptions#destroy'
end
end
root to: 'application#welcome' root to: 'application#welcome'
namespace :admin do namespace :admin do
@ -79,6 +81,8 @@ Rails.application.routes.draw do
end end
end end
resources :exercise_collections
resources :proxy_exercises do resources :proxy_exercises do
member do member do
post :clone post :clone

View File

@ -0,0 +1,18 @@
class AddCauseToTestruns < ActiveRecord::Migration
def up
add_column :testruns, :cause, :string
Testrun.reset_column_information
Testrun.all.each{ |testrun|
if(testrun.submission.nil?)
say_with_time "#{testrun.id} has no submission" do end
else
testrun.cause = testrun.submission.cause
testrun.save
end
}
end
def down
remove_column :testruns, :cause
end
end

View File

@ -0,0 +1,11 @@
class CreateSubscriptions < ActiveRecord::Migration
def change
create_table :subscriptions do |t|
t.belongs_to :user, polymorphic: true
t.references :request_for_comment
t.string :type
t.timestamps null: false
end
end
end

View File

@ -0,0 +1,5 @@
class RenameSubscriptionType < ActiveRecord::Migration
def change
rename_column :subscriptions, :type, :subscription_type
end
end

View File

@ -0,0 +1,5 @@
class AddDeletedToSubscription < ActiveRecord::Migration
def change
add_column :subscriptions, :deleted, :boolean
end
end

View File

@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170719133351) do ActiveRecord::Schema.define(version: 20170920145852) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -321,6 +321,16 @@ ActiveRecord::Schema.define(version: 20170719133351) do
add_index "submissions", ["exercise_id"], name: "index_submissions_on_exercise_id", using: :btree add_index "submissions", ["exercise_id"], name: "index_submissions_on_exercise_id", using: :btree
add_index "submissions", ["user_id"], name: "index_submissions_on_user_id", using: :btree add_index "submissions", ["user_id"], name: "index_submissions_on_user_id", using: :btree
create_table "subscriptions", force: :cascade do |t|
t.integer "user_id"
t.string "user_type"
t.integer "request_for_comment_id"
t.string "subscription_type"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "deleted"
end
create_table "tags", force: :cascade do |t| create_table "tags", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.datetime "created_at" t.datetime "created_at"
@ -334,6 +344,7 @@ ActiveRecord::Schema.define(version: 20170719133351) do
t.integer "submission_id" t.integer "submission_id"
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.string "cause"
end end
create_table "user_exercise_feedbacks", force: :cascade do |t| create_table "user_exercise_feedbacks", force: :cascade do |t|

View File

@ -25,8 +25,8 @@
var showFlashes = function() { var showFlashes = function() {
$('.flash').each(function() { $('.flash').each(function() {
var container = $(this); var container = $(this);
var message = container.children().first(); var message = container.find('p');
var button = container.children().last(); var button = container.find('span.fa-times');
var hide = function() { var hide = function() {
container.slideUp(function () { container.slideUp(function () {

View File

@ -72,7 +72,7 @@ class DockerClient
# Headers are required by Docker # Headers are required by Docker
headers = {'Origin' => 'http://localhost'} headers = {'Origin' => 'http://localhost'}
socket_url = DockerClient.config['ws_host'] + '/containers/' + @container.id + '/attach/ws?' + query_params socket_url = DockerClient.config['ws_host'] + '/v1.27/containers/' + @container.id + '/attach/ws?' + query_params
socket = Faye::WebSocket::Client.new(socket_url, [], :headers => headers) socket = Faye::WebSocket::Client.new(socket_url, [], :headers => headers)
Rails.logger.debug "Opening Websocket on URL " + socket_url Rails.logger.debug "Opening Websocket on URL " + socket_url
@ -399,6 +399,9 @@ class DockerClient
output = container.exec(['bash', '-c', command]) output = container.exec(['bash', '-c', command])
Rails.logger.debug "output from container.exec" Rails.logger.debug "output from container.exec"
Rails.logger.debug output Rails.logger.debug output
if(output == nil)
kill_container(container)
end
result = {status: output[2] == 0 ? :ok : :failed, stdout: output[0].join.force_encoding('utf-8'), stderr: output[1].join.force_encoding('utf-8')} result = {status: output[2] == 0 ? :ok : :failed, stdout: output[0].join.force_encoding('utf-8'), stderr: output[1].join.force_encoding('utf-8')}
end end
# if we use pooling and recylce the containers, put it back. otherwise, destroy it. # if we use pooling and recylce the containers, put it back. otherwise, destroy it.

View File

@ -0,0 +1,17 @@
namespace :user do
require 'csv'
desc 'write displaynames retrieved from the account service as csv into the codeocean database'
task :write_displaynames, [:file_path_read] => [ :environment ] do |t, args|
csv_input = CSV.read(args[:file_path_read], headers:true)
csv_input.each do |row|
user = ExternalUser.find_by(:external_id => row[0])
puts "Change name from #{user.name} to #{row[1]}"
user.update(name: row[1])
end
end
end

View File

@ -24,17 +24,4 @@ class UserGroupSeparator
end end
end end
def self.getGroupWeek2Testing(user)
groupById = user.id % 4
if groupById == 0
:group_a
elsif groupById == 1
:group_b
elsif groupById == 2
:group_c
else # 3
:group_d
end
end
end end

View File

@ -0,0 +1,14 @@
require 'test_helper'
class ExerciseCollectionsControllerTest < ActionController::TestCase
test "should get index" do
get :index
assert_response :success
end
test "should get show" do
get :show
assert_response :success
end
end

View File

@ -0,0 +1,7 @@
require 'test_helper'
class SubscriptionControllerTest < ActionController::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -0,0 +1,7 @@
FactoryGirl.define do
factory :subscription do
user nil
request_for_comments nil
type ""
end
end

View File

@ -0,0 +1,7 @@
require 'test_helper'
class SubscriptionTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
/*! jQuery UI - v1.12.1 - 2017-09-05
* http://jqueryui.com
* Includes: draggable.css, core.css, resizable.css, selectable.css, sortable.css
* Copyright jQuery Foundation and other contributors; Licensed MIT */
.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table;border-collapse:collapse}.ui-helper-clearfix:after{clear:both}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important;pointer-events:none}.ui-icon{display:inline-block;vertical-align:middle;margin-top:-.25em;position:relative;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-icon-block{left:50%;margin-left:-8px;display:block}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:0.1px;display:block;-ms-touch-action:none;touch-action:none}.ui-resizable-disabled .ui-resizable-handle,.ui-resizable-autohide .ui-resizable-handle{display:none}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px}.ui-selectable{-ms-touch-action:none;touch-action:none}.ui-selectable-helper{position:absolute;z-index:100;border:1px dotted black}.ui-sortable-handle{-ms-touch-action:none;touch-action:none}

View File

@ -0,0 +1,5 @@
/*! jQuery UI - v1.12.1 - 2017-09-05
* http://jqueryui.com
* Copyright jQuery Foundation and other contributors; Licensed MIT */
.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table;border-collapse:collapse}.ui-helper-clearfix:after{clear:both}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important;pointer-events:none}.ui-icon{display:inline-block;vertical-align:middle;margin-top:-.25em;position:relative;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-icon-block{left:50%;margin-left:-8px;display:block}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:0.1px;display:block;-ms-touch-action:none;touch-action:none}.ui-resizable-disabled .ui-resizable-handle,.ui-resizable-autohide .ui-resizable-handle{display:none}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px}.ui-selectable{-ms-touch-action:none;touch-action:none}.ui-selectable-helper{position:absolute;z-index:100;border:1px dotted black}.ui-sortable-handle{-ms-touch-action:none;touch-action:none}