Add CommunitySolution

* Also slightly refactor some JS files
This commit is contained in:
Sebastian Serth
2021-11-21 19:18:52 +01:00
parent d559cfb323
commit da4e10b990
21 changed files with 509 additions and 31 deletions

View File

@ -0,0 +1,27 @@
$(document).on('turbolinks:load', function() {
if ($.isController('community_solutions') && $('#community-solution-editor').isPresent()) {
CodeOceanEditor.sendEvents = false;
CodeOceanEditor.editors = [];
CodeOceanEditor.initializeDescriptionToggle();
CodeOceanEditor.configureEditors();
CodeOceanEditor.initializeEditors();
CodeOceanEditor.initializeEditors(true);
CodeOceanEditor.initializeFileTree();
CodeOceanEditor.initializeFileTree(true);
CodeOceanEditor.showFirstFile();
CodeOceanEditor.showFirstFile(true);
CodeOceanEditor.resizeAceEditors();
CodeOceanEditor.resizeAceEditors(true);
$.extend(
CodeOceanEditor,
CodeOceanEditorAJAX,
CodeOceanEditorSubmissions
)
$('#submit').one('click', CodeOceanEditorSubmissions.submitCode.bind(CodeOceanEditor));
$('#accept').one('click', CodeOceanEditorSubmissions.submitCode.bind(CodeOceanEditor));
}
});

View File

@ -15,7 +15,7 @@ var CodeOceanEditor = {
ENTER_KEY_CODE: 13,
//Request-For-Comments-Configuration
REQUEST_FOR_COMMENTS_DELAY: 3 * 60 * 1000,
REQUEST_FOR_COMMENTS_DELAY: 0,
REQUEST_TOOLTIP_TIME: 5000,
editors: [],
@ -140,20 +140,37 @@ var CodeOceanEditor = {
}
},
showFirstFile: function () {
var frame = $('.frame[data-role="main_file"]').isPresent() ? $('.frame[data-role="main_file"]') : $('.frame').first();
var file_id = frame.find('.editor').data('file-id');
showFirstFile: function (own_solution = false) {
let frame;
let filetree;
let editorSelector;
if (own_solution) {
frame = $('.own-frame[data-role="main_file"]').isPresent() ? $('.own-frame[data-role="main_file"]') : $('.own-frame').first();
filetree = $('#own-files');
editorSelector = '.own-editor';
} else {
frame = $('.frame[data-role="main_file"]').isPresent() ? $('.frame[data-role="main_file"]') : $('.frame').first();
filetree = $('#files');
editorSelector = '.editor';
}
var file_id = frame.find(editorSelector).data('file-id');
this.setActiveFile(frame.data('filename'), file_id);
var filetree = $('#files');
this.selectFileInJsTree(filetree, file_id);
this.showFrame(frame);
this.toggleButtonStates();
},
showFrame: function (frame) {
if (frame.hasClass('own-frame')) {
$('.own-frame').hide();
} else {
$('.frame').hide();
}
this.active_frame = frame;
$('.frame').hide();
frame.show();
this.resizeParentOfAceEditor(frame.find('.ace_editor.ace-tm'));
},
getProgressBarClass: function (percentage) {
@ -203,8 +220,15 @@ var CodeOceanEditor = {
},
resizeAceEditors: function () {
$('.editor').each(function (index, element) {
resizeAceEditors: function (own_solution = false) {
let editorSelector;
if (own_solution) {
editorSelector = $('.own-editor')
} else {
editorSelector = $('.editor')
}
editorSelector.each(function (index, element) {
this.resizeParentOfAceEditor(element);
}.bind(this));
window.dispatchEvent(new Event('resize'));
@ -212,13 +236,21 @@ var CodeOceanEditor = {
resizeParentOfAceEditor: function (element) {
// calculate needed size: window height - position of top of ACE editor - height of autosave label below editor - 5 for bar margins
var windowHeight = window.innerHeight - $(element).offset().top - $('#statusbar').height() - 5;
var windowHeight = window.innerHeight - $(element).offset().top - ($('#statusbar').height() || 0) - 5;
$(element).parent().height(windowHeight);
},
initializeEditors: function () {
this.editors = [];
$('.editor').each(function (index, element) {
initializeEditors: function (own_solution = false) {
// Initialize the editors array if not present already. This is mainly required for community solutions
this.editors = this.editors || [];
let editorSelector;
if (own_solution) {
editorSelector = $('.own-editor')
} else {
editorSelector = $('.editor')
}
editorSelector.each(function (index, element) {
// Resize frame on load
this.resizeParentOfAceEditor(element);
@ -279,12 +311,10 @@ var CodeOceanEditor = {
session.setUseWrapMode(true);
// set regex for parsing error traces based on the mode of the main file.
if ($(element).parent().data('role') == "main_file") {
if ($(element).parent().data('role') === "main_file") {
this.tracepositions_regex = this.regex_for_language.get($(element).data('mode'));
}
var file_id = $(element).data('id');
/*
* Register event handlers
*/
@ -326,9 +356,15 @@ var CodeOceanEditor = {
});
},
initializeFileTree: function () {
$('#files').jstree($('#files').data('entries'));
$('#files').on('click', 'li.jstree-leaf > a', function (event) {
initializeFileTree: function (own_solution = false) {
let filesInstance;
if (own_solution) {
filesInstance = $('#own-files');
} else {
filesInstance = $('#files');
}
filesInstance.jstree(filesInstance.data('entries'));
filesInstance.on('click', 'li.jstree-leaf > a', function (event) {
this.setActiveFile(
$(event.target).parent().text(),
parseInt($(event.target).parent().attr('id'))
@ -793,7 +829,7 @@ var CodeOceanEditor = {
const percentile75 = data['working_time_75_percentile'];
const accumulatedWorkTimeUser = data['working_time_accumulated'];
const minTimeIntervention = 10 * 1000;
const minTimeIntervention = 10 * 60 * 1000;
let timeUntilIntervention;
if ((accumulatedWorkTimeUser - percentile75) > 0) {
@ -879,6 +915,7 @@ var CodeOceanEditor = {
initializeEverything: function () {
CodeOceanEditor.editors = [];
this.initializeRegexes();
this.initializeCodePilot();
$('.score, #development-environment').show();

View File

@ -9,8 +9,9 @@ CodeOceanEditorSubmissions = {
* Submission-Creation
*/
createSubmission: function (initiator, filter, callback) {
const editor = $('#editor');
this.showSpinner(initiator);
var url = $(initiator).data('url') || $('#editor').data('submissions-url');
var url = $(initiator).data('url') || editor.data('submissions-url');
if (url === undefined) {
const data = {
@ -24,12 +25,12 @@ CodeOceanEditorSubmissions = {
data: {
submission: {
cause: $(initiator).data('cause') || $(initiator).prop('id'),
exercise_id: $('#editor').data('exercise-id'),
exercise_id: editor.data('exercise-id') || $(initiator).data('exercise-id'),
files_attributes: (filter || _.identity)(this.collectFiles())
}
},
dataType: 'json',
method: 'POST',
method: $(initiator).data('http-method') || 'POST',
url: url + '.json'
});
jqxhr.always(this.hideSpinner.bind(this));
@ -191,20 +192,22 @@ CodeOceanEditorSubmissions = {
}
},
submitCode: function() {
this.createSubmission($('#submit'), null, function (response) {
submitCode: function(event) {
const button = $(event.target) || $('#submit');
this.createSubmission(button, null, function (response) {
if (response.redirect) {
this.editors = [];
Turbolinks.clearCache();
clearTimeout(this.autosaveTimer);
Turbolinks.visit(response.redirect);
} else if (response.status === 'container_depleted') {
this.showContainerDepletedMessage();
$('#submit').one('click', this.submitCode.bind(this));
button.one('click', this.submitCode.bind(this));
} else if (response.message) {
$.flash.danger({
text: response.message
});
$('#submit').one('click', this.submitCode.bind(this));
button.one('click', this.submitCode.bind(this));
}
})
},

View File

@ -7,6 +7,14 @@ button i.fa-spin {
width: 100%;
}
.own-editor {
height: 100%;
width: 100%;
.ace_scroller .ace_content {
background: #FAFAFA;
}
}
/* this class is used for the edit view of an exercise. It needs the height set, as it does not automatically resize */
.edit-frame {
height: 400px;
@ -26,6 +34,15 @@ button i.fa-spin {
}
}
.own-frame {
display: none;
min-height: 300px;
audio, img, video {
max-width: 100%;
}
}
.score {
display: none;
vertical-align: bottom;
@ -64,6 +81,10 @@ button i.fa-spin {
overflow: auto;
}
#own-files {
overflow: auto;
}
#outputInformation {
#output {
max-height: 500px;

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
class CommunitySolutionsController < ApplicationController
include CommonBehavior
include RedirectBehavior
include SubmissionParameters
before_action :set_community_solution, only: %i[edit update]
before_action :set_community_solution_lock, only: %i[edit]
before_action :set_exercise_and_submission, only: %i[edit update]
# GET /community_solutions
def index
@community_solutions = CommunitySolution.all
authorize!
end
# GET /community_solutions/1/edit
def edit
authorize!
# Be safe. Only allow access to this page if user has valid lock
redirect_after_submit unless @community_solution_lock.present? && @community_solution_lock.active? && @community_solution_lock.user == current_user && @community_solution_lock.community_solution == @community_solution
# We don't want to perform any of the following steps if we rendered already (e.g. due to a redirect)
return if performed?
last_contribution = CommunitySolutionContribution.where(community_solution: @community_solution, timely_contribution: true, autosave: false, proposed_changes: true).order(created_at: :asc).last
@files = []
if last_contribution.blank?
last_contribution = @community_solution.exercise
new_readme_file = {content: '', file_type: FileType.find_by(file_extension: '.txt'), hidden: false, read_only: false, name: 'ReadMe', role: 'regular_file', context: @community_solution}
@files << CodeOcean::File.create!(new_readme_file)
end
all_visible_files = last_contribution.files.select(&:visible)
# Add the ReadMe file first
@files += all_visible_files.select {|f| CodeOcean::File.find_by(id: f.file_id).context_type == 'CommunitySolution' }
# Then, add all remaining files and sort them by name with extension
@files += (all_visible_files - @files).sort_by(&:name_with_extension)
# Own Submission as a reference
@own_files = @submission.collect_files.select(&:visible).sort_by(&:name_with_extension)
# Remove the file_id from the second graph. Otherwise, the comparison and file-tree selection does not work as expected
@own_files.map do |file|
file.file_id = nil
file.read_only = true
end
end
# PATCH/PUT /community_solutions/1
def update
authorize!
contribution_params = submission_params
cause = contribution_params.delete(:cause)
contribution_params[:proposed_changes] = cause == 'change-community-solution'
contribution_params[:autosave] = cause == 'autosave-community-solution'
contribution_params.delete(:exercise_id)
contribution_params[:community_solution] = @community_solution
# Acquire lock here! This is expensive but required for synchronization
@community_solution_lock = ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute("LOCK #{CommunitySolutionLock.table_name} IN ACCESS EXCLUSIVE MODE")
lock = CommunitySolutionLock.where(user: current_user, community_solution: @community_solution).order(locked_until: :asc).last
if lock.active?
contribution_params[:timely_contribution] = true
# Update lock: Either expand the time (autosave) or return it (change / accept)
new_lock_time = contribution_params[:autosave] ? 5.minutes.from_now : Time.zone.now
lock.update!(locked_until: new_lock_time)
else
contribution_params[:timely_contribution] = false
end
# This is returned
lock
end
contribution_params[:community_solution_lock] = @community_solution_lock
contribution_params[:working_time] = @community_solution_lock.working_time
CommunitySolutionContribution.create(contribution_params)
redirect_after_submit
end
private
def authorize!
authorize(@community_solution)
end
# Use callbacks to share common setup or constraints between actions.
def set_community_solution
@community_solution = CommunitySolution.find(params[:id])
end
def set_community_solution_lock
@community_solution_lock = CommunitySolutionLock.find(params[:lock_id])
end
def set_exercise_and_submission
@exercise = @community_solution.exercise
@submission = current_user.submissions.final.where(exercise_id: @community_solution.exercise.id).order('created_at DESC').first
end
end

View File

@ -6,7 +6,7 @@ module FileParameters
params.reject do |_, file_attributes|
file = CodeOcean::File.find_by(id: file_attributes[:file_id])
# avoid that public files from other contexts can be created
file.nil? || file.hidden || file.read_only || (file.context_type == 'Exercise' && file.context_id != exercise.id)
file.nil? || file.hidden || file.read_only || (file.context_type == 'Exercise' && file.context_id != exercise.id) || (file.context_type == 'CommunitySolution' && controller_name != 'community_solutions')
end
else
[]

View File

@ -6,6 +6,11 @@ module RedirectBehavior
def redirect_after_submit
Rails.logger.debug { "Redirecting user with score:s #{@submission.normalized_score}" }
if @submission.normalized_score.to_d == 1.0.to_d
if redirect_to_community_solution?
redirect_to_community_solution
return
end
# if user is external and has an own rfc, redirect to it and message him to clean up and accept the answer. (we need to check that the user is external,
# otherwise an internal user could be shown a false rfc here, since current_user.id is polymorphic, but only makes sense for external users when used with rfcs.)
# redirect 10 percent pseudorandomly to the feedback page
@ -63,6 +68,40 @@ module RedirectBehavior
private
def redirect_to_community_solution
url = edit_community_solution_path(@community_solution, lock_id: @community_solution_lock.id)
respond_to do |format|
format.html { redirect_to(url) }
format.json { render(json: {redirect: url}) }
end
end
def redirect_to_community_solution?
return false unless Java21Study.allow_redirect_to_community_solution?(current_user, @exercise)
@community_solution = CommunitySolution.find_by(exercise: @exercise)
return false if @community_solution.blank?
last_contribution = CommunitySolutionContribution.where(community_solution: @community_solution).order(created_at: :asc).last
# Only redirect if last contribution is from another user.
eligible = last_contribution.blank? || last_contribution.user != current_user
return false unless eligible
# Acquire lock here! This is expensive but required for synchronization
@community_solution_lock = ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute("LOCK #{CommunitySolutionLock.table_name} IN ACCESS EXCLUSIVE MODE")
# This is returned
CommunitySolutionLock.find_or_create_by(community_solution: @community_solution, locked_until: Time.zone.now...) do |lock|
lock.user = current_user
lock.locked_until = 5.minutes.from_now
end
end
@community_solution_lock.user == current_user
end
def redirect_to_user_feedback
uef = UserExerciseFeedback.find_by(exercise: @exercise, user: current_user)
url = if uef

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class CommunitySolution < ApplicationRecord
belongs_to :exercise
has_many :community_solution_locks
has_many :community_solution_contributions
has_and_belongs_to_many :users, polymorphic: true, through: :community_solution_contributions
has_many :files, class_name: 'CodeOcean::File', through: :community_solution_contributions
def to_s
"Gemeinschaftslösung für #{exercise}"
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class CommunitySolutionContribution < ApplicationRecord
include Creation
include Context
belongs_to :community_solution
belongs_to :community_solution_lock
validates :proposed_changes, boolean_presence: true
validates :timely_contribution, boolean_presence: true
validates :autosave, boolean_presence: true
validates :working_time, presence: true
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class CommunitySolutionLock < ApplicationRecord
include Creation
belongs_to :community_solution
has_many :community_solution_contributions
validates :locked_until, presence: true
def active?
Time.zone.now <= locked_until
end
def working_time
ActiveSupport::Duration.build(locked_until - created_at)
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class CommunitySolutionPolicy < AdminOnlyPolicy
def edit?
everyone
end
def update?
everyone
end
end

View File

@ -0,0 +1,66 @@
.exercise.clearfix
div
span.badge.badge-pill.badge-primary.float-right.score
h1 id="exercise-headline"
i id="description-symbol" class=(@embed_options[:collapse_exercise_description] ? 'fa fa-chevron-right' : 'fa fa-chevron-down')
=> @community_solution.model_name.human(count: 1)
= @community_solution.exercise.title
#description-card.lead class=(@embed_options[:collapse_exercise_description] ? 'description-card-collapsed' : 'description-card')
.card.border-success.mb-3
.card-header
i.fa.fa-info-circle.text-success
strong.text-success
=> t('community_solutions.help_us_out')
= t('community_solutions.explanation')
br
i.fa.fa-flask.text-success
strong.text-success
=> t('community_solutions.research_status')
== t('community_solutions.research_explanation')
hr
= render_markdown(@community_solution.exercise.description)
a#toggle href="#" data-show=t('shared.show') data-hide=t('shared.hide')
- if @embed_options[:collapse_exercise_description]
= t('shared.show')
- else
= t('shared.hide')
.row.mt-4
.col-xl-6
h4
= t('community_solutions.current_community_solution')
#community-solution-editor.row
.pr-0 class=(@community_solution.exercise.hide_file_tree ? 'd-none col-sm-3' : 'col-sm-3')
.card.border-secondary
.card-header.d-flex.justify-content-between.align-items-center.px-0.py-2
.px-2 = I18n.t('exercises.editor_file_tree.file_root')
.card-body.pt-0.pr-0.pl-1.pb-1
#files data-entries=FileTree.new(@files).to_js_tree
div class=(@community_solution.exercise.hide_file_tree ? 'col-sm-12' : 'col-sm-9')
div.editor-col.col.p-0 id='frames'
- @files.each do |file|
= render('exercises/editor_frame', exercise: @community_solution.exercise, file: file)
.col-xl-6.container-fluid
div.bg-dark.h-100.float-left.row style="width: 1px"
div
h4
= t('community_solutions.your_submission')
#own-solution-editor.row
.pr-0 class=(@community_solution.exercise.hide_file_tree ? 'd-none col-sm-3' : 'col-sm-3')
.card.border-secondary
.card-header.d-flex.justify-content-between.align-items-center.px-0.py-2
.px-2 = I18n.t('exercises.editor_file_tree.file_root')
.card-body.pt-0.pr-0.pl-1.pb-1
#own-files data-entries=FileTree.new(@own_files).to_js_tree
div class=(@community_solution.exercise.hide_file_tree ? 'col-sm-12' : 'col-sm-9')
div.editor-col.col.p-0 id='own-frames'
- @own_files.each do |file|
= render('exercises/editor_frame', exercise: @community_solution.exercise, file: file, own_solution: true)
#statusbar.visible.mt-2 style="height: 5em"
p.text-center
= render('exercises/editor_button', classes: 'btn-lg btn-success ml-5 mr-3', data: {'data-url': community_solution_path(@community_solution), 'data-http-method': 'PUT', 'data-cause': 'change-community-solution', 'data-exercise-id': @community_solution.exercise.id}, icon: 'fa fa-send', id: 'submit', label: t('community_solutions.change_community_solution'))
= render('exercises/editor_button', classes: 'btn-lg btn-secondary ml-5', data: {'data-url': community_solution_path(@community_solution), 'data-http-method': 'PUT', 'data-cause': 'accept-community-solution', 'data-exercise-id': @community_solution.exercise.id}, icon: 'fa fa-check', id: 'accept', label: t('community_solutions.accept_community_solution'))
button style="display:none" id="autosave" data-url=community_solution_path(@community_solution) data-http-method='PUT' data-cause='autosave-community-solution' data-exercise-id=@community_solution.exercise.id

View File

@ -0,0 +1,6 @@
- content_for :head do
// Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326.
Otherwise, code might not be highlighted correctly (race condition)
meta name='turbolinks-visit-control' content='reload'
== render 'form'

View File

@ -0,0 +1,19 @@
h1 Listing community_solutions
table
thead
tr
th
th
th
tbody
- @community_solutions.each do |community_solution|
tr
td = link_to 'Show', community_solution
td = link_to 'Edit', edit_community_solution_path(community_solution)
td = link_to 'Destroy', community_solution, data: { confirm: 'Are you sure?' }, method: :delete
br
= link_to 'New Community solution', new_community_solution_path

View File

@ -1,4 +1,4 @@
.frame data-executable=file.file_type.executable? data-filename=file.name_with_extension data-renderable=file.file_type.renderable? data-role=file.role data-binary=file.file_type.binary? data-context-type=file.context_type data-read-only=file.read_only
div class=(defined?(own_solution) ? "own-frame" : "frame") data-executable=file.file_type.executable? data-filename=file.name_with_extension data-renderable=file.file_type.renderable? data-role=file.role data-binary=file.file_type.binary? data-context-type=file.context_type data-read-only=file.read_only
- if file.file_type.binary?
.binary-file data-file-id=file.ancestor_id
- if file.file_type.renderable?
@ -12,4 +12,4 @@
= link_to(file.native_file.file.filename, file.native_file.url)
- else
.editor-content.d-none data-file-id=file.ancestor_id = file.content
.editor data-file-id=file.ancestor_id data-indent-size=file.file_type.indent_size data-mode=file.file_type.editor_mode data-allow-auto-completion=exercise.allow_auto_completion.to_s data-id=file.id
div class=(defined?(own_solution) ? "own-editor" : "editor") data-file-id=file.ancestor_id data-indent-size=file.file_type.indent_size data-mode=file.file_type.editor_mode data-allow-auto-completion=exercise.allow_auto_completion.to_s data-id=file.id

View File

@ -55,7 +55,7 @@ html lang="#{I18n.locale || I18n.default_locale}"
= render('flash')
- if current_user.try(:admin?) or current_user.try(:teacher?) && !@embed_options[:hide_navbar]
= yield(:breadcrumbs)
- if (controller_name == "exercises" && action_name == "implement")
- if (controller_name == "exercises" && action_name == "implement") || (controller_name == 'community_solutions' && action_name == 'edit')
.container-fluid
= yield
- else