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|
config.vm.box = "ubuntu/trusty64"
config.vm.provider "virtualbox" do |v|
v.memory = 1024
v.memory = 8192
end
config.vm.network "private_network", ip: "192.168.59.104"
# config.vm.synced_folder "../data", "/vagrant_data"

View File

@ -14,6 +14,7 @@
//
//= require ace/ace
//= require chosen.jquery.min
//= require jquery-ui.min
//= require d3
//= require jquery.turbolinks
//= 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 = {
requestComments: function () {
var user_id = $('#editor').data('user-id');
@ -83,6 +81,8 @@ CodeOceanEditorRequestForComments = {
}).done(function () {
this.hideSpinner();
$.flash.success({text: $('#askForCommentsButton').data('message-success')});
// trigger a run
this.runSubmission.call(this, submission);
}.bind(this)).error(this.ajaxError.bind(this));
};

View File

@ -144,17 +144,19 @@ CodeOceanEditorSubmissions = {
runCode: function(event) {
event.preventDefault();
if ($('#run').is(':visible')) {
this.createSubmission('#run', null, function(response) {
this.createSubmission('#run', null, this.runSubmission.bind(this));
}
},
runSubmission: function (submission) {
//Run part starts here
$('#stop').data('url', response.stop_url);
$('#stop').data('url', submission.stop_url);
this.running = true;
this.showSpinner($('#run'));
$('#score_div').addClass('hidden');
this.toggleButtonStates();
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
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);
}.bind(this));
}
},
saveCode: function(event) {

View File

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

View File

@ -1,7 +1,98 @@
#commentitor {
margin-top: 2rem;
height: 600px;
background-color:#f9f9f9
.rfc {
h5 {
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 {
@ -11,6 +102,10 @@
border: solid lightgrey 1px;
background-color: rgba(20, 180, 20, 0.2);
border-radius: 4px;
button {
margin-right: 10px;
}
}
#thank-you-note {
@ -18,49 +113,142 @@
height: 200px;
}
#commentitor {
margin-bottom: 2rem;
height: 600px;
background-color:#f9f9f9
}
.ace_tooltip {
display: none !important;
}
p.comment {
width: 400px;
.modal-content {
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%;
min-width: 200px;
.comment-header {
width: 100%;
overflow: hidden;
padding-bottom: 10px;
margin: auto;
}
.popover-username {
.comment-username {
font-weight: bold;
width: 60%;
float: left;
}
.popover-date {
.comment-date {
text-align: right;
color: #008cba;
margin-left: 60%;
font-size: x-small;
}
.popover-updated {
.comment-updated {
text-align: right;
margin-left: 60%;
font-size: x-small;
}
}
.popover-comment {
.comment-content {
word-wrap: break-word;
margin-bottom: 10px;
}
.popover-divider {
.comment-editor {
display: none;
width: 100%;
height: auto;
background-color: inherit;
}
.comment-actions {
display: none;
}
}
.comment-divider {
width: 100%;
height: 1px;
background-color: #008cba;
overflow: hidden;
margin-top: 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);
}
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 {
border-radius: 50%;
background-color: #ff2600;

View File

@ -10,6 +10,7 @@ class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError, with: :render_not_authorized
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
end

View File

@ -1,5 +1,5 @@
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
# skip_after_action :verify_authorized
@ -21,6 +21,7 @@ class CommentsController < ApplicationController
comment.username = comment.user.displayname
comment.date = comment.created_at.strftime('%d.%m.%Y %k:%M')
comment.updated = (comment.created_at != comment.updated_at)
comment.editable = comment.user == current_user
}
else
@comments = []
@ -50,12 +51,14 @@ class CommentsController < ApplicationController
def create
@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|
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.json { render :show, status: :created, location: @comment }
else
@ -83,7 +86,8 @@ class CommentsController < ApplicationController
# DELETE /comments/1
# DELETE /comments/1.json
def destroy_by_id
def destroy
authorize!
@comment.destroy
respond_to do |format|
format.html { head :no_content, notice: 'Comment was successfully destroyed.' }
@ -91,17 +95,8 @@ class CommentsController < ApplicationController
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
# Use callbacks to share common setup or constraints between actions.
def set_comment
@comment = Comment.find(params[:id])
@ -117,4 +112,28 @@ class CommentsController < ApplicationController
# 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)
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
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

View File

@ -42,12 +42,12 @@ module Lti
private :external_user_email
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
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
provider.lis_person_name_given || provider.lis_person_name_family
provider.lis_person_name_given
end
end
private :external_user_name
@ -104,7 +104,7 @@ module Lti
private :return_to_consumer
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)
if session[:consumer_id]

View File

@ -8,7 +8,7 @@ module SubmissionScoring
output = execute_test_file(file, submission)
assessment = assessor.assess(output)
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?
submission.exercise.execution_environment.error_templates.each do |template|
pattern = Regexp.new(template.signature).freeze
@ -17,7 +17,7 @@ module SubmissionScoring
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!(filename: file.name_with_extension, message: feedback_message(file, output[:score]), weight: file.weight)
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
private :authorize!
def max_intervention_count
def max_intervention_count_per_day
3
end
@ -166,7 +166,7 @@ class ExercisesController < ApplicationController
def implement
redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists?
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)
user_intervention_group = UserGroupSeparator.getInterventionGroup(current_user)
@ -203,7 +203,7 @@ class ExercisesController < ApplicationController
if match = lti_json.match(/^.*courses\/([a-z0-9\-]+)\/sections/)
match.captures.first
else
java_course_token
""
end
else
""
@ -344,7 +344,7 @@ class ExercisesController < ApplicationController
end
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)
if response[:status] == 'success'

View File

@ -1,4 +1,5 @@
class RequestForCommentsController < ApplicationController
include SubmissionScoring
before_action :set_request_for_comment, only: [:show, :edit, :update, :destroy, :mark_as_solved, :set_thank_you_note]
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
.select('request_for_comments.*, max(comments.updated_at) as last_comment')
.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!
end
@ -68,11 +69,8 @@ class RequestForCommentsController < ApplicationController
def set_thank_you_note
authorize!
@request_for_comment.thank_you_note = params[:note]
commenters = []
@request_for_comment.comments.distinct.to_a.each {|comment|
commenters.append comment.user
}
commenters = commenters.uniq {|user| user.id}
commenters = @request_for_comment.commenters
commenters.each {|commenter| UserMailer.send_thank_you_note(@request_for_comment, commenter).deliver_now}
respond_to do |format|
@ -110,6 +108,10 @@ class RequestForCommentsController < ApplicationController
@request_for_comment = RequestForComment.new(request_for_comment_params)
respond_to do |format|
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 }
else
format.html { render :new }

View File

@ -13,9 +13,13 @@ class SubmissionsController < ApplicationController
before_action :set_mime_type, 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
if(@submission.cause == 'requestComments')
5000
else
500
end
end
def authorize!
authorize(@submission || @submissions)
@ -210,7 +214,7 @@ class SubmissionsController < ApplicationController
end
def handle_message(message, tubesock, container)
@message_buffer ||= ""
@run_output ||= ""
# Handle special commands first
if (/^#exit/.match(message))
# 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
@docker_client.exit_container(container)
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
# Filter out information about run_command, test_command, user or working directory
run_command = @submission.execution_environment.run_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))
@message_buffer += message if @message_buffer.size <= max_message_buffer_size
parse_message(message, 'stdout', tubesock)
end
end
end
def parse_message(message, output_stream, socket, recursive = true)
parsed = '';
begin
parsed = JSON.parse(message)
if(parsed.class == Hash && parsed.key?('cmd'))
@ -270,13 +274,16 @@ class SubmissionsController < ApplicationController
socket.send_data JSON.dump(parsed)
Rails.logger.info('parse_message sent: ' + JSON.dump(parsed))
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
def save_run_output
if !@message_buffer.blank?
@message_buffer = @message_buffer[(0..max_message_buffer_size-1)] # trim the string to max_message_buffer_size chars
Testrun.create(file: @file, submission: @submission, output: @message_buffer)
if !@run_output.blank?
@run_output = @run_output[(0..max_run_output_buffer_size-1)] # trim the string to max_message_buffer_size chars
Testrun.create(file: @file, cause: 'run', submission: @submission, output: @run_output)
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)
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)
@receiver_displayname = receiver.displayname
@author = request_for_comments.user.displayname

View File

@ -1,7 +1,7 @@
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.
include Creation
attr_accessor :username, :date, :updated
attr_accessor :username, :date, :updated, :editable
belongs_to :file, class_name: 'CodeOcean::File'
belongs_to :user, polymorphic: true

View File

@ -2,4 +2,8 @@ class ExerciseCollection < ActiveRecord::Base
has_and_belongs_to_many :exercises
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
def displayname
result = name
if(result == nil || result == "")
result = "User " + id.to_s
if(!consumer.nil? && consumer.name == 'openHPI')
result = Rails.cache.fetch("#{cache_key}/displayname", expires_in: 12.hours) do
Xikolo::UserClient.get(external_id.to_s)[:display_name]
end
end
result
end

View File

@ -37,35 +37,17 @@ class ProxyExercise < ActiveRecord::Base
assigned_user_proxy_exercise.exercise
else
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}" )
begin
find_matching_exercise(user)
rescue #fallback
rescue => e #fallback
Rails.logger.error("finding matching exercise failed. Fall back to random exercise! Error: #{$!}" )
@reason[:reason] = "fallback because of error"
@reason[:error] = "#{$!}"
exercises.shuffle.first
end
@reason[:error] = "#{$!}:\n\t#{e.backtrace.join("\n\t")}"
exercises.where("expected_difficulty > 1").shuffle.first # difficulty should be > 1 to prevent dummy exercise from being chosen.
end
user.user_proxy_exercise_exercises << UserProxyExerciseExercise.create(user: user, exercise: matching_exercise, proxy_exercise: self, reason: @reason.to_json)
matching_exercise
end
recommended_exercise
end
@ -136,6 +118,7 @@ class ProxyExercise < ActiveRecord::Base
relative_knowledge_improvement[potex] += old_relative_loss_tag - new_relative_loss_tag
end
end
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)
@reason[:reason] = "best matching exercise"

View File

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

View File

@ -34,7 +34,7 @@ class Submission < ActiveRecord::Base
end
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)
score / exercise.maximum_score
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
end
[:new?, :destroy?, :update?].each do |action|
[:new?, :destroy?, :update?, :edit?].each do |action|
define_method(action) { admin? || author? }
end
def edit?
admin?
end
def index?
everyone
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')
- %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}"
p = flash[severity]
div.alert.flash class="alert-#{{'alert' => 'warning', 'notice' => 'success'}.fetch(severity, severity)}"
p id="flash-#{severity}" = flash[severity]
span.fa.fa-times

View File

@ -8,8 +8,8 @@
- if current_user.admin?
li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path)
li.divider
- models = [ExecutionEnvironment, Exercise, Consumer, CodeHarborLink, ExternalUser, FileType, FileTemplate,
ErrorTemplate, ErrorTemplateAttribute, InternalUser].sort_by { |model| model.model_name.human(count: 2) }
- models = [ExecutionEnvironment, Exercise, ExerciseCollection, ProxyExercise, Tag, Consumer, CodeHarborLink,
ErrorTemplate, ErrorTemplateAttribute, ExternalUser, FileType, FileTemplate, InternalUser].sort_by {|model| model.model_name.human(count: 2) }
- models.each do |model|
- if policy(model).index?
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.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)
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
h5 =t('exercises.implement.comment.others')
pre#otherCommentsTextfield
p = ''
.container
label
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')
button#removeAllButton.btn.btn-block.btn-warning(type='button') =t('exercises.implement.comment.removeAllOnLine')

View File

@ -2,10 +2,10 @@
li.panel.panel-default
.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"
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
.clearfix = link_to(t('shared.destroy'), '#', class:'btn btn-warning btn-sm discard-file pull-right')
.form-group

View File

@ -38,7 +38,15 @@
.form-group
= f.label(t('activerecord.attributes.exercise.worktime'))
= f.number_field "expected_worktime_minutes", value: @exercise.expected_worktime_seconds / 60, in: 1..1000, step: 1
h2 Tags
h2 = t('exercises.form.tags')
ul.list-unstyled.panel-group
li.panel.panel-default
.panel-heading role="tab" id="heading"
a.file-heading data-toggle="collapse" href="#tag-collapse"
div.clearfix role="button"
span = t('exercises.form.click_to_collapse')
.panel-collapse.collapse id="tag-collapse" role="tabpanel"
.table-responsive
table.table#tags-table
thead
@ -51,6 +59,7 @@
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')
ul#files.list-unstyled.panel-group
= 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|
- if run.passed
.unit-test-result.positive-result title=run.output
- else
- elsif run.failed
.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
-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

View File

@ -35,7 +35,7 @@ h1 = @exercise
tbody
- @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}
- label = current_user.teacher? ? "#{user.name}" : "#{user.name} (#{user.email})"
- label = "#{user.displayname}"
tr
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

View File

@ -10,7 +10,7 @@ h1 = ProxyExercise.model_name.human(count: 2)
thead
tr
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 colspan=6 = t('shared.actions')
tbody

View File

@ -10,6 +10,7 @@ h1
= row(label: 'exercise.title', value: @proxy_exercise.title)
= row(label: 'proxy_exercise.files_count', value: @exercises.count)
= row(label: 'exercise.description', value: @proxy_exercise.description)
= row(label: 'exercise.token', value: @proxy_exercise.token)
h3 Exercises
.table-responsive
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">
<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>
<% end %>
<%= link_to(@request_for_comment.exercise.title, [:implement, @request_for_comment.exercise]) %>
@ -9,62 +9,77 @@
<%
user = @request_for_comment.user
submission = @request_for_comment.submission
testruns = Testrun.where(:submission_id => @request_for_comment.submission)
%>
<%= user.displayname %> | <%= @request_for_comment.created_at.localtime %>
</p>
<div class="rfc">
<div class="description">
<h5>
<u><%= t('activerecord.attributes.exercise.description') %>:</u> <%= render_markdown(@request_for_comment.exercise.description) %>
<%= t('activerecord.attributes.exercise.description') %>
</h5>
<div class="text">
<span class="fa fa-chevron-up collapse-button"></span>
<%= render_markdown(@request_for_comment.exercise.description) %>
</div>
</div>
<div class="question">
<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') %>
<%= 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 %>
</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>
<% 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) %>
<br>
<br>
<h4>Admin Menu</h4>
<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>
<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>
<%= render('admin_menu') %>
<% end %>
<h5>
<u><%= t('request_for_comments.howto_title') %></u><br> <%= render_markdown(t('request_for_comments.howto')) %>
</h5>
</div>
<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 class="hidden sanitizer"></div>
<!--
do not put a carriage return in the line below. it will be present in the presentation of the source code, otherwise.
@ -81,8 +96,13 @@ also, all settings from the rails model needed for the editor configuration in t
<script type="text/javascript">
$('.modal-content').draggable({
handle: '.modal-header'
}).resizable({
autoHide: true
});
var solvedButton = $('#mark-as-solved-button');
var commentOnExerciseButton = $('#comment-exercise-button');
var addCommentExerciseButton = $('#addCommentExerciseButton');
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();
});
$('.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
var ACE_FILES_PATH = '/assets/ace/';
_.each(['modePath', 'themePath', 'workerPath'], function(attribute) {
@ -138,15 +164,12 @@ also, all settings from the rails model needed for the editor configuration in t
currentEditor.getSession().setMode($(editor).data('mode'));
currentEditor.getSession().setOption("useWorker", false);
currentEditor.commentVisualsByLine = {};
setAnnotations(currentEditor, $(editor).data('file-id'));
currentEditor.on("guttermousedown", handleSidebarClick);
currentEditor.on("guttermousemove", showPopover);
});
function cleanupPopovers() {
// remove all possible popovers
$('.editor > .ace_gutter > .ace_gutter-layer > .ace_gutter-cell').popover('destroy');
}
function preprocess(commentText) {
// sanitize comments to deal with XSS attacks:
commentText = $('div.sanitizer').text(commentText).html();
@ -154,6 +177,51 @@ also, all settings from the rails model needed for the editor configuration in t
return commentText.replace(/\n/g, '<br>');
}
function generateCommentHtmlContent(comments) {
var htmlContent = '';
comments.forEach(function(comment, index) {
var commentText = preprocess(comment.text);
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) {
var session = editor.getSession();
@ -167,73 +235,11 @@ also, all settings from the rails model needed for the editor configuration in t
});
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) {
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);
})
}
function hasCommentsInRow(editor, row){
return editor.getSession().getAnnotations().some(function(element) {
return element.row === 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) {
cleanupPopovers();
function deleteComment(commentId, editor, file_id, callback) {
var jqxhr = $.ajax({
type: 'DELETE',
url: "/comments",
data: {
row: row,
file_id: file_id }
url: "/comments/" + commentId
});
jqxhr.done(function (response) {
jqxhr.done(function () {
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);
}
function createComment(file_id, row, editor, commenttext){
cleanupPopovers();
var jqxhr = $.ajax({
data: {
comment: {
@ -273,71 +292,173 @@ also, all settings from the rails model needed for the editor configuration in t
method: 'POST',
url: "/comments"
});
jqxhr.done(function(response){
jqxhr.done(function(){
setAnnotations(editor, file_id);
});
jqxhr.fail(ajaxError);
}
function createCommentOnExercise(file_id, row, editor, commenttext){
function subscribeToRFC(subscriptionType, checkbox){
checkbox.attr("disabled", true);
var jqxhr = $.ajax({
data: {
comment: {
file_id: file_id,
row: row,
column: 0,
text: commenttext,
request_id: $('h4#exercise_caption').data('rfc-id')
subscription: {
request_for_comment_id: $('h4#exercise_caption').data('rfc-id'),
subscription_type: subscriptionType
}
},
dataType: 'json',
method: 'POST',
url: "/comments"
url: "/subscriptions.json"
});
jqxhr.done(function(subscription) {
checkbox.data('subscription', subscription.id);
checkbox.attr("disabled", false);
});
jqxhr.fail(function(response) {
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) {
setAnnotations(editor, file_id);
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);
});
jqxhr.fail(ajaxError);
}
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) {
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;
e.stop();
$('.modal-title').text('<%= t('request_for_comments.modal_title') %>'.replace('${line}', row + 1));
var commentModal = $('#comment-modal');
if (hasCommentsInRow(editor, row)) {
var rowComments = getCommentsForRow(editor, row);
var comments = _.pluck(rowComments, 'text').join('\n');
commentModal.find('#otherComments').show();
commentModal.find('#otherCommentsTextfield').text(comments);
var otherComments = commentModal.find('#otherComments');
var htmlContent = generateCommentHtmlContent(getCommentsForRow(editor, row));
if (htmlContent) {
otherComments.show();
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 {
commentModal.find('#otherComments').hide();
button.text('<%= t('comments.save_update') %>');
button.data('editing', true);
deleteButton.hide();
commentContent.hide();
commentEditor.show();
}
});
} else {
otherComments.hide();
}
commentModal.find('#addCommentButton').off('click');
commentModal.find('#removeAllButton').off('click');
commentModal.find('#addCommentButton').on('click', function(e){
var commenttext = commentModal.find('textarea').val();
var file_id = $(editor.container).data('file-id');
if (commenttext !== "") {
createComment(file_id, row, editor, commenttext);
commentModal.find('textarea').val('') ;
commentModal.modal('hide');
var subscribeCheckbox = commentModal.find('#subscribe');
subscribeCheckbox.prop('checked', subscribeCheckbox.data('subscription'));
subscribeCheckbox.off('change');
subscribeCheckbox.on('change', function() {
if (this.checked) {
subscribeToRFC('author', $(this));
} else {
unsubscribeFromRFC($(this));
}
});
commentModal.find('#removeAllButton').on('click', function(e){
var file_id = $(editor.container).data('file-id');
deleteComment(file_id, row, editor);
var addCommentButton = commentModal.find('#addCommentButton');
addCommentButton.off('click');
addCommentButton.on('click', function(){
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');
@ -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>

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"
difficulty: Schwierigkeitsgrad
worktime: "vermutete Arbeitszeit in Minuten"
token: "Aufgaben-Token"
proxy_exercise:
title: Title
files_count: Anzahl der Aufgaben
@ -120,6 +121,11 @@ de:
key: "Name"
description: "Beschreibung"
regex: "Regulärer Ausdruck"
exercise_collections:
id: "ID"
name: "Name"
updated_at: "Letzte Änderung"
exercises: "Aufgaben"
models:
code_harbor_link:
one: CodeHarbor-Link
@ -142,6 +148,9 @@ de:
exercise:
one: Aufgabe
other: Aufgaben
exercise_collection:
one: Aufgabesammlung
other: Aufgabensammlungen
proxy_exercise:
one: Proxy Aufgabe
other: Proxy Aufgaben
@ -169,9 +178,15 @@ de:
submission:
one: Abgabe
other: Abgaben
tag:
one: Tag
other: Tags
user_exercise_feedback:
one: Feedback
other: Feedback
comment:
one: Kommentar
other: Kommentare
errors:
messages:
together: 'muss zusammen mit %{attribute} definiert werden'
@ -270,6 +285,8 @@ de:
path: 'Pfad der Datei im Projektverzeichnis. Kann auch leer gelassen werden.'
form:
add_file: Datei hinzufügen
tags: "Tags"
click_to_collapse: "Zum Aus-/Einklappen hier klicken..."
implement:
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.'
@ -462,14 +479,49 @@ de:
<br>
This mail was automatically sent by CodeOcean. <br>
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:
click_here: Zum Kommentieren auf die Seitenleiste klicken!
comments: Kommentare
howto: |
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>
Mit "Kommentieren" wird der Kommentar dann gesichert und taucht als Sprechblase neben der Zeile auf.
howto_title: 'Anleitung:'
Mit "Kommentar abschicken" wird der Kommentar dann gesichert und taucht als Sprechblase neben der Zeile auf.
howto_title: 'Anleitung'
index:
get_my_comment_requests: Meine Kommentaranfragen
all: "Alle Kommentaranfragen"
@ -486,6 +538,12 @@ de:
send_thank_you_note: "Senden"
cancel_thank_you_note: "Nichts senden"
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:
create:
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 \"/\""
attributes: "Attribute"
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:
activerecord:
attributes:
@ -63,6 +42,7 @@ en:
allow_file_creation: "Allow file creation"
difficulty: Difficulty
worktime: "Expected worktime in minutes"
token: "Exercise Token"
proxy_exercise:
title: Title
files_count: Exercises Count
@ -141,6 +121,11 @@ en:
key: "Identifier"
description: "Description"
regex: "Regular Expression"
exercise_collections:
id: "ID"
name: "Name"
updated_at: "Last Update"
exercises: "Exercises"
models:
code_harbor_link:
one: CodeHarbor Link
@ -163,6 +148,9 @@ en:
exercise:
one: Exercise
other: Exercises
exercise_collection:
one: Exercise Collection
other: Exercise Collections
proxy_exercise:
one: Proxy Exercise
other: Proxy Exercises
@ -190,9 +178,15 @@ en:
submission:
one: Submission
other: Submissions
tag:
one: Tag
other: Tags
user_exercise_feedback:
one: Feedback
other: Feedback
comment:
one: Comment
other: Comments
errors:
messages:
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."
form:
add_file: Add file
tags: "Tags"
click_to_collapse: "Click to expand/collapse..."
implement:
alert:
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>
This mail was automatically sent by CodeOcean. <br>
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:
click_here: Click on this sidebar to comment!
comments: Comments
@ -490,7 +521,7 @@ en:
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>
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:
all: All Requests for Comments
get_my_comment_requests: My Requests for Comments
@ -507,6 +538,12 @@ en:
send_thank_you_note: "Send"
cancel_thank_you_note: "Don't send"
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:
create:
failure: Invalid email or password.
@ -626,3 +663,9 @@ en:
signature: "A regular expression in Ruby syntax without leading and trailing \"/\""
attributes: "Attributes"
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
end
end
resources :comments, except: [:destroy] do
collection do
delete :destroy
end
end
resources :comments
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'
delete '/comment_by_id', to: 'comments#destroy_by_id'
put '/comments', to: 'comments#update'
resources :subscriptions do
member do
get :unsubscribe, to: 'subscriptions#destroy'
end
end
root to: 'application#welcome'
namespace :admin do
@ -79,6 +81,8 @@ Rails.application.routes.draw do
end
end
resources :exercise_collections
resources :proxy_exercises do
member do
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.
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
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", ["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|
t.string "name", null: false
t.datetime "created_at"
@ -334,6 +344,7 @@ ActiveRecord::Schema.define(version: 20170719133351) do
t.integer "submission_id"
t.datetime "created_at"
t.datetime "updated_at"
t.string "cause"
end
create_table "user_exercise_feedbacks", force: :cascade do |t|

View File

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

View File

@ -72,7 +72,7 @@ class DockerClient
# Headers are required by Docker
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)
Rails.logger.debug "Opening Websocket on URL " + socket_url
@ -399,6 +399,9 @@ class DockerClient
output = container.exec(['bash', '-c', command])
Rails.logger.debug "output from container.exec"
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')}
end
# 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
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

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}