diff --git a/app/assets/javascripts/community_solution.js b/app/assets/javascripts/community_solution.js new file mode 100644 index 00000000..68c62d17 --- /dev/null +++ b/app/assets/javascripts/community_solution.js @@ -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)); + } + +}); diff --git a/app/assets/javascripts/editor/editor.js.erb b/app/assets/javascripts/editor/editor.js.erb index 90dce466..3b0a0281 100644 --- a/app/assets/javascripts/editor/editor.js.erb +++ b/app/assets/javascripts/editor/editor.js.erb @@ -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(); diff --git a/app/assets/javascripts/editor/submissions.js b/app/assets/javascripts/editor/submissions.js index 67efd649..219bae71 100644 --- a/app/assets/javascripts/editor/submissions.js +++ b/app/assets/javascripts/editor/submissions.js @@ -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)); } }) }, diff --git a/app/assets/stylesheets/editor.css.scss b/app/assets/stylesheets/editor.css.scss index c9d4aeb1..49141816 100644 --- a/app/assets/stylesheets/editor.css.scss +++ b/app/assets/stylesheets/editor.css.scss @@ -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; diff --git a/app/controllers/community_solutions_controller.rb b/app/controllers/community_solutions_controller.rb new file mode 100644 index 00000000..414283c8 --- /dev/null +++ b/app/controllers/community_solutions_controller.rb @@ -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 diff --git a/app/controllers/concerns/file_parameters.rb b/app/controllers/concerns/file_parameters.rb index 96491bf0..dfe4b06b 100644 --- a/app/controllers/concerns/file_parameters.rb +++ b/app/controllers/concerns/file_parameters.rb @@ -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 [] diff --git a/app/controllers/concerns/redirect_behavior.rb b/app/controllers/concerns/redirect_behavior.rb index d37030d5..5292e0d7 100644 --- a/app/controllers/concerns/redirect_behavior.rb +++ b/app/controllers/concerns/redirect_behavior.rb @@ -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 diff --git a/app/models/community_solution.rb b/app/models/community_solution.rb new file mode 100644 index 00000000..67b0e7f3 --- /dev/null +++ b/app/models/community_solution.rb @@ -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 diff --git a/app/models/community_solution_contribution.rb b/app/models/community_solution_contribution.rb new file mode 100644 index 00000000..e230456d --- /dev/null +++ b/app/models/community_solution_contribution.rb @@ -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 diff --git a/app/models/community_solution_lock.rb b/app/models/community_solution_lock.rb new file mode 100644 index 00000000..48b896c8 --- /dev/null +++ b/app/models/community_solution_lock.rb @@ -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 diff --git a/app/policies/community_solution_policy.rb b/app/policies/community_solution_policy.rb new file mode 100644 index 00000000..03fdc344 --- /dev/null +++ b/app/policies/community_solution_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CommunitySolutionPolicy < AdminOnlyPolicy + def edit? + everyone + end + + def update? + everyone + end +end diff --git a/app/views/community_solutions/_form.html.slim b/app/views/community_solutions/_form.html.slim new file mode 100644 index 00000000..3a262015 --- /dev/null +++ b/app/views/community_solutions/_form.html.slim @@ -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 diff --git a/app/views/community_solutions/edit.html.slim b/app/views/community_solutions/edit.html.slim new file mode 100644 index 00000000..a8f43e2c --- /dev/null +++ b/app/views/community_solutions/edit.html.slim @@ -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' diff --git a/app/views/community_solutions/index.html.slim b/app/views/community_solutions/index.html.slim new file mode 100644 index 00000000..97046122 --- /dev/null +++ b/app/views/community_solutions/index.html.slim @@ -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 diff --git a/app/views/exercises/_editor_frame.html.slim b/app/views/exercises/_editor_frame.html.slim index 2c86fcc8..34cb938a 100644 --- a/app/views/exercises/_editor_frame.html.slim +++ b/app/views/exercises/_editor_frame.html.slim @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index b114c764..eeb0fd5f 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -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 diff --git a/config/locales/de.yml b/config/locales/de.yml index d6e1a36f..ff60481c 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -163,6 +163,9 @@ de: exercise_collection_item: exercise: "Aufgabe" models: + community_solution: + one: Gemeinschaftslösung + other: Gemeinschaftslösungen codeharbor_link: one: CodeHarbor-Link other: CodeHarbor-Links @@ -265,6 +268,15 @@ de: rfc_activity_history: Kommentaranfragenhistorie rails_admin: show: "Rails Admin" + community_solutions: + help_us_out: Helfen Sie mit! + explanation: In diesem Kurs möchten wir gerne mit Ihnen und allen anderen Lernenden eine Gemeinschaftslösung für diese Aufgabe erarbeiten, die zum Ende des Kurses allen Teilnehmenden zugänglich gemacht werden soll. Unten finden Sie sowohl den aktuellen Stand der Gemeinschaftslösung als auch Ihre Abgabe. Bitte sehen Sie sich die Gemeinschaftslösung an und überarbeiten Sie diese bei Bedarf. Ihre eigene Lösung wird nicht verändert. + research_status: 'Neue Beta-Funktion aus der Forschung:' + research_explanation: Die hier angebotene Mitwirkungsmöglichkeit an einer Gemeinschaftslösung ist Bestandteil unserer Forschung; daher würden wir uns sehr über Ihre aktive Beteiligung freuen. + current_community_solution: Aktuelle Gemeinschaftslösung + your_submission: Ihre Abgabe (schreibgeschützt, als Referenz) + change_community_solution: Änderungen an Gemeinschaftslösung speichern + accept_community_solution: Gemeinschaftslösung ohne Änderung verlassen consumers: show: link: Konsument diff --git a/config/locales/en.yml b/config/locales/en.yml index a177de44..d6888825 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -163,6 +163,9 @@ en: exercise_collection_item: exercise: "Exercise" models: + community_solution: + one: Community Solution + other: Community Solutions codeharbor_link: one: CodeHarbor Link other: CodeHarbor Links @@ -265,6 +268,15 @@ en: rfc_activity_history: RfC Activity History rails_admin: show: "Rails Admin" + community_solutions: + help_us_out: Help us out! + explanation: In this course, we would like to work together with you and all other learners to create a community solution for this exercise, which will be made available to all participants at the end of the course. For this we need your active support. Below you will find both the current status of the community solution and your submission. Please review the community solution and revise it as needed. Your own solution will not be changed. + research_status: 'New beta feature based on our research:' + research_explanation: The opportunity to participate in a community solution is part of our research; therefore, we would greatly appreciate your active participation. + current_community_solution: Current community solution + your_submission: Your submission (read-only, for reference) + change_community_solution: Save Changes to Community Solution + accept_community_solution: Quit Community Solution without Changes consumers: show: link: Consumer diff --git a/config/routes.rb b/config/routes.rb index 96d20ffd..4d2c77ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ FILENAME_REGEXP = /[\w.]+/.freeze unless Kernel.const_defined?(:FILENAME_REGEXP) Rails.application.routes.draw do + resources :community_solutions, only: %i[index edit update] resources :error_template_attributes resources :error_templates do member do diff --git a/db/migrate/20211118185051_create_community_solution.rb b/db/migrate/20211118185051_create_community_solution.rb new file mode 100644 index 00000000..1d5069db --- /dev/null +++ b/db/migrate/20211118185051_create_community_solution.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class CreateCommunitySolution < ActiveRecord::Migration[6.1] + def change + create_table :community_solutions do |t| + t.belongs_to :exercise, foreign_key: true, null: false, index: true + + t.timestamps + end + + create_table :community_solution_locks do |t| + t.belongs_to :community_solution, foreign_key: true, null: false, index: false + t.references :user, polymorphic: true, null: false + t.timestamp :locked_until, null: true + + t.timestamps + + t.index %i[community_solution_id locked_until], unique: true, name: 'index_community_solution_locks_until' + end + + create_table :community_solution_contributions do |t| + t.belongs_to :community_solution, foreign_key: true, null: false, index: false + t.belongs_to :study_group, foreign_key: true, null: true, index: false + t.references :user, polymorphic: true, null: false + t.belongs_to :community_solution_lock, foreign_key: true, null: false, index: {name: 'index_community_solution_contributions_lock'} + t.boolean :proposed_changes, null: false + t.boolean :timely_contribution, null: false + t.boolean :autosave, null: false + t.interval :working_time, null: false + + t.timestamps + + t.index %i[community_solution_id timely_contribution autosave proposed_changes], name: 'index_community_solution_valid_contributions' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8277d07b..8a1b089a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_11_14_145024) do +ActiveRecord::Schema.define(version: 2021_11_18_185051) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -53,6 +53,41 @@ ActiveRecord::Schema.define(version: 2021_11_14_145024) do t.index ["user_id"], name: "index_comments_on_user_id" end + create_table "community_solution_contributions", force: :cascade do |t| + t.bigint "community_solution_id", null: false + t.bigint "study_group_id" + t.string "user_type", null: false + t.bigint "user_id", null: false + t.bigint "community_solution_lock_id", null: false + t.boolean "proposed_changes", null: false + t.boolean "timely_contribution", null: false + t.boolean "autosave", null: false + t.interval "working_time", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["community_solution_id", "timely_contribution", "autosave", "proposed_changes"], name: "index_community_solution_valid_contributions" + t.index ["community_solution_lock_id"], name: "index_community_solution_contributions_lock" + t.index ["user_type", "user_id"], name: "index_community_solution_contributions_on_user" + end + + create_table "community_solution_locks", force: :cascade do |t| + t.bigint "community_solution_id", null: false + t.string "user_type", null: false + t.bigint "user_id", null: false + t.datetime "locked_until" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["community_solution_id", "locked_until"], name: "index_community_solution_locks_until", unique: true + t.index ["user_type", "user_id"], name: "index_community_solution_locks_on_user" + end + + create_table "community_solutions", force: :cascade do |t| + t.bigint "exercise_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["exercise_id"], name: "index_community_solutions_on_exercise_id" + end + create_table "consumers", id: :serial, force: :cascade do |t| t.string "name" t.datetime "created_at" @@ -491,6 +526,11 @@ ActiveRecord::Schema.define(version: 2021_11_14_145024) do t.index ["user_type", "user_id"], name: "index_user_proxy_exercise_exercises_on_user" end + add_foreign_key "community_solution_contributions", "community_solution_locks" + add_foreign_key "community_solution_contributions", "community_solutions" + add_foreign_key "community_solution_contributions", "study_groups" + add_foreign_key "community_solution_locks", "community_solutions" + add_foreign_key "community_solutions", "exercises" add_foreign_key "exercise_tips", "exercise_tips", column: "parent_exercise_tip_id" add_foreign_key "exercise_tips", "exercises" add_foreign_key "exercise_tips", "tips"