diff --git a/Gemfile b/Gemfile
index 615dfee7..0bed2524 100644
--- a/Gemfile
+++ b/Gemfile
@@ -36,6 +36,8 @@ gem 'webpacker'
gem 'rest-client'
gem 'rubyzip'
gem 'mnemosyne-ruby'
+gem 'faraday'
+gem 'proforma', git: 'https://github.com/openHPI/proforma.git', tag: 'v0.4'
gem 'whenever', require: false
gem 'rails-timeago'
@@ -49,6 +51,7 @@ group :development, :staging do
gem 'capistrano-rails'
gem 'capistrano-rvm'
gem 'capistrano-upload-config'
+ gem 'pry-rails'
gem 'rack-mini-profiler'
gem 'rubocop', require: false
gem 'rubocop-rspec'
@@ -69,6 +72,9 @@ group :test do
gem 'database_cleaner'
gem 'nyan-cat-formatter'
gem 'rspec-autotest'
+ gem 'rspec-collection_matchers'
gem 'rspec-rails'
+ gem 'shoulda-matchers'
gem 'simplecov', require: false
+ gem 'webmock'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 2d365b33..fd2e8220 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -7,6 +7,17 @@ GIT
rack (>= 1.5.0)
websocket (>= 1.1.0)
+GIT
+ remote: https://github.com/openHPI/proforma.git
+ revision: b09c9fcc1fdb314fc1fb1396e7d0bb2d70e376c5
+ tag: v0.4
+ specs:
+ proforma (0.4)
+ activemodel (>= 5.2.3, < 6.1.0)
+ activesupport (>= 5.2.3, < 6.1.0)
+ nokogiri (~> 1.10.2)
+ rubyzip (>= 1.2.2, < 2.1.0)
+
GEM
remote: https://rubygems.org/
specs:
@@ -117,6 +128,8 @@ GEM
chronic (0.10.2)
coderay (1.1.2)
concurrent-ruby (1.1.5)
+ crack (0.4.3)
+ safe_yaml (~> 1.0.0)
crass (1.0.5)
database_cleaner (1.7.0)
debug_inspector (0.0.3)
@@ -145,6 +158,7 @@ GEM
forgery (0.7.0)
globalid (0.4.2)
activesupport (>= 4.2.0)
+ hashdiff (1.0.0)
headless (2.3.1)
highline (2.0.3)
http-accept (1.7.0)
@@ -233,6 +247,8 @@ GEM
pry-byebug (3.7.0)
byebug (~> 11.0)
pry (~> 0.10)
+ pry-rails (0.3.9)
+ pry (>= 0.10.4)
public_suffix (4.0.1)
puma (4.3.1)
nio4r (~> 2.0)
@@ -302,6 +318,8 @@ GEM
rspec-mocks (~> 3.9.0)
rspec-autotest (1.0.2)
rspec-core (>= 2.99.0.beta1, < 4.0.0)
+ rspec-collection_matchers (1.1.3)
+ rspec-expectations (>= 2.99.0.beta1)
rspec-core (3.9.0)
rspec-support (~> 3.9.0)
rspec-expectations (3.9.0)
@@ -335,6 +353,7 @@ GEM
json (~> 2.1)
structured_warnings (~> 0.3)
rubyzip (2.0.0)
+ safe_yaml (1.0.5)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.2.1)
@@ -348,6 +367,8 @@ GEM
selenium-webdriver (3.142.6)
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
+ shoulda-matchers (4.1.1)
+ activesupport (>= 4.2.0)
simplecov (0.17.1)
docile (~> 1.1)
json (>= 1.8, < 3)
@@ -396,6 +417,10 @@ GEM
activemodel (>= 5.0)
bindex (>= 0.4.0)
railties (>= 5.0)
+ webmock (3.7.6)
+ addressable (>= 2.3.6)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
webpacker (4.2.0)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
@@ -433,6 +458,7 @@ DEPENDENCIES
docker-api
eventmachine (= 1.0.9.1)
factory_bot_rails
+ faraday
faye-websocket
forgery
headless
@@ -449,7 +475,9 @@ DEPENDENCIES
nyan-cat-formatter
pagedown-bootstrap-rails
pg
+ proforma!
pry-byebug
+ pry-rails
puma
pundit
rack-mini-profiler
@@ -460,6 +488,7 @@ DEPENDENCIES
ransack
rest-client
rspec-autotest
+ rspec-collection_matchers
rspec-rails
rubocop
rubocop-rspec
@@ -467,6 +496,7 @@ DEPENDENCIES
rubyzip
sass-rails
selenium-webdriver
+ shoulda-matchers
simplecov
slim-rails
sorcery
@@ -475,6 +505,7 @@ DEPENDENCIES
turbolinks
uglifier
web-console
+ webmock
webpacker
whenever
diff --git a/app/assets/javascripts/codeharbor_link.js b/app/assets/javascripts/codeharbor_link.js
new file mode 100644
index 00000000..4cd49ece
--- /dev/null
+++ b/app/assets/javascripts/codeharbor_link.js
@@ -0,0 +1,37 @@
+$(document).on('turbolinks:load', function() {
+ $('[data-toggle="tooltip"]').tooltip();
+ if($.isController('codeharbor_links')) {
+ if ($('.edit_codeharbor_link, .new_codeharbor_link').isPresent()) {
+
+ var replace = (function(string) {
+ var d = getDate();
+ return string.replace(/[xy]/g, function (c) {
+ var r = (d + Math.random() * 16) % 16 | 0;
+ d = Math.floor(d / 16);
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
+ });
+ });
+
+ var getDate = (function () {
+ var d = new Date().getTime();
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
+ d += performance.now(); //use high-precision timer if available
+ }
+ return d
+ });
+
+ var generateUUID = (function () { // Public Domain/MIT
+ return replace('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx');
+ });
+
+ var generateRandomHex32 = (function () {
+ return replace(Array(32).join('x'));
+ });
+
+ $('.generate-api_key').on('click', function () {
+ $('.api_key').val(generateRandomHex32())
+ });
+ }
+ }
+});
+
diff --git a/app/assets/javascripts/exercises.js.erb b/app/assets/javascripts/exercises.js.erb
index efe110d2..66c8dfb6 100644
--- a/app/assets/javascripts/exercises.js.erb
+++ b/app/assets/javascripts/exercises.js.erb
@@ -209,6 +209,105 @@ $(document).on('turbolinks:load', function() {
});
};
+ var old_execution_environment = $('#exercise_execution_environment_id').val();
+ var observeExecutionEnvironment = function() {
+ $('#exercise_execution_environment_id').on('change', function(){
+ new_execution_environment = $('#exercise_execution_environment_id').val();
+
+ if(new_execution_environment == '' && !$('#exercise_unpublished').prop('checked')){
+ if(confirm('<%= I18n.t('exercises.form.unpublish_warning') %>')){
+ $('#exercise_unpublished').prop('checked', true);
+ } else {
+ return $('#exercise_execution_environment_id').val(old_execution_environment).trigger("chosen:updated");
+ }
+ }
+ old_execution_environment = new_execution_environment;
+ });
+ };
+
+ var observeUnpublishedState = function() {
+ $('#exercise_unpublished').on('change', function(){
+ if($('#exercise_unpublished').prop('checked')){
+ if(!confirm('<%= I18n.t('exercises.form.unpublish_warning') %>')){
+ $('#exercise_unpublished').prop('checked', false);
+ }
+ } else if($('#exercise_execution_environment_id').val() == '') {
+ alert('<%= I18n.t('exercises.form.no_execution_environment_selected') %>');
+ $('#exercise_unpublished').prop('checked', true);
+ }
+ })
+ };
+
+ var observeExportButtons = function(){
+ $('.export-start').on('click', function(e){
+ e.preventDefault();
+ $('#export-modal').modal({
+ height: 250
+ });
+ $('#export-modal').modal('show');
+ exportExerciseStart($(this).data().exerciseId);
+ });
+ $('body').on('click', '.export-retry-button', function(){
+ exportExerciseStart($(this).data().exerciseId);
+ });
+ $('body').on('click', '.export-action', function(){
+ exportExerciseConfirm($(this).data().exerciseId);
+ });
+ }
+
+ var exportExerciseStart = function(exerciseID) {
+ var $exerciseDiv = $('#export-exercise');
+ var $messageDiv = $exerciseDiv.children('.export-message');
+ var $actionsDiv = $exerciseDiv.children('.export-exercise-actions');
+
+ $messageDiv.removeClass('export-failure');
+
+ $messageDiv.html('<%= I18n.t('exercises.export_codeharbor.checking_codeharbor') %>');
+ $actionsDiv.html('
');
+
+ return $.ajax({
+ type: 'POST',
+ url: '/exercises/' + exerciseID + '/export_external_check',
+ dataType: 'json',
+ success: function(response) {
+ $messageDiv.html(response.message);
+ return $actionsDiv.html(response.actions);
+ },
+ error: function(a, b, c) {
+ return alert('error:' + c);
+ }
+ });
+ };
+
+ var exportExerciseConfirm = function(exerciseID) {
+ var $exerciseDiv = $('#export-exercise');
+ var $messageDiv = $exerciseDiv.children('.export-message');
+ var $actionsDiv = $exerciseDiv.children('.export-exercise-actions');
+
+ return $.ajax({
+ type: 'POST',
+ url: '/exercises/' + exerciseID + '/export_external_confirm',
+ dataType: 'json',
+ success: function(response) {
+ $messageDiv.html(response.message)
+ $actionsDiv.html(response.actions);
+
+ if(response.status == 'success') {
+ $messageDiv.addClass('export-success');
+ setTimeout((function() {
+ $('#export-modal').modal('hide');
+ $messageDiv.html('').removeClass('export-success');
+ }), 3000);
+ } else {
+ $messageDiv.addClass('export-failure');
+ }
+ },
+ error: function(a, b, c) {
+ return alert('error:' + c);
+ }
+ });
+ };
+
var overrideTextareaTabBehavior = function() {
$('.form-group textarea[name$="[content]"]').on('keydown', function(event) {
if (event.which === TAB_KEY_CODE) {
@@ -264,6 +363,7 @@ $(document).on('turbolinks:load', function() {
// ignore tags table since it is in the dom before other tables
if ($('table:not(#tags-table)').isPresent()) {
enableBatchUpdate();
+ observeExportButtons();
} else if ($('.edit_exercise, .new_exercise').isPresent()) {
execution_environments = $('form').data('execution-environments');
file_types = $('form').data('file-types');
@@ -271,6 +371,8 @@ $(document).on('turbolinks:load', function() {
enableInlineFileCreation();
inferFileAttributes();
observeFileRoleChanges();
+ observeExecutionEnvironment();
+ observeUnpublishedState();
overrideTextareaTabBehavior();
} else if ($('#files.jstree').isPresent()) {
var fileTypeSelect = $('#code_ocean_file_file_type_id');
diff --git a/app/assets/stylesheets/exercises.css.scss b/app/assets/stylesheets/exercises.css.scss
index ce3195c1..e5118ffc 100644
--- a/app/assets/stylesheets/exercises.css.scss
+++ b/app/assets/stylesheets/exercises.css.scss
@@ -176,3 +176,51 @@ a.file-heading {
}
}
}
+
+#export-modal {
+ .modal-content {
+ min-height: 300px;
+ }
+
+ .modal-body {
+ overflow: auto;
+ }
+}
+
+#export-exercise{
+ display: flex;
+}
+
+.export-message {
+ flex-grow: 1;
+ font-size: 12px;
+ padding-right: 5px;
+ word-wrap: break-word;
+}
+.export-message + :empty {
+ max-width: 100%;
+}
+
+.export-exercise-actions:empty {
+ display: none;
+}
+
+.export-exercise-actions {
+ max-width: 110px;
+ min-width: 110px;
+}
+
+.export-button {
+ font-size: 12px;
+ width: 100%;
+}
+
+.export-success {
+ color: darkgreen;
+ font-size: 12pt;
+ font-weight: 600;
+}
+
+.export-failure {
+ color: darkred;
+}
diff --git a/app/controllers/code_harbor_links_controller.rb b/app/controllers/code_harbor_links_controller.rb
deleted file mode 100644
index a4736f26..00000000
--- a/app/controllers/code_harbor_links_controller.rb
+++ /dev/null
@@ -1,68 +0,0 @@
-class CodeHarborLinksController < ApplicationController
- include CommonBehavior
- before_action :set_code_harbor_link, only: [:show, :edit, :update, :destroy]
-
- def authorize!
- authorize(@code_harbor_link || @code_harbor_links)
- end
- private :authorize!
-
- # GET /code_harbor_links
- # GET /code_harbor_links.json
- def index
- @code_harbor_links = CodeHarborLink.where(user_id: current_user.id).paginate(page: params[:page])
- authorize!
- end
-
- # GET /code_harbor_links/1
- # GET /code_harbor_links/1.json
- def show
- authorize!
- end
-
- # GET /code_harbor_links/new
- def new
- @code_harbor_link = CodeHarborLink.new
- authorize!
- end
-
- # GET /code_harbor_links/1/edit
- def edit
- authorize!
- end
-
- # POST /code_harbor_links
- # POST /code_harbor_links.json
- def create
- @code_harbor_link = CodeHarborLink.new(code_harbor_link_params)
- @code_harbor_link.user = current_user
- authorize!
- create_and_respond(object: @code_harbor_link)
- end
-
- # PATCH/PUT /code_harbor_links/1
- # PATCH/PUT /code_harbor_links/1.json
- def update
- update_and_respond(object: @code_harbor_link, params: code_harbor_link_params)
- authorize!
- end
-
- # DELETE /code_harbor_links/1
- # DELETE /code_harbor_links/1.json
- def destroy
- destroy_and_respond(object: @code_harbor_link)
- end
-
- private
- # Use callbacks to share common setup or constraints between actions.
- def set_code_harbor_link
- @code_harbor_link = CodeHarborLink.find(params[:id])
- @code_harbor_link.user = current_user
- authorize!
- end
-
- # Never trust parameters from the scary internet, only allow the white list through.
- def code_harbor_link_params
- params.require(:code_harbor_link).permit(:oauth2token)
- end
-end
diff --git a/app/controllers/codeharbor_links_controller.rb b/app/controllers/codeharbor_links_controller.rb
new file mode 100644
index 00000000..802448f6
--- /dev/null
+++ b/app/controllers/codeharbor_links_controller.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class CodeharborLinksController < ApplicationController
+ include CommonBehavior
+ before_action :set_codeharbor_link, only: %i[show edit update destroy]
+
+ def new
+ base_url = CodeOcean::Config.new(:code_ocean).read[:codeharbor][:url]
+ @codeharbor_link = CodeharborLink.new(push_url: base_url + '/import_exercise', check_uuid_url: base_url + '/import_uuid_check')
+ authorize!
+ end
+
+ def edit
+ authorize!
+ end
+
+ def create
+ @codeharbor_link = CodeharborLink.new(codeharbor_link_params)
+ @codeharbor_link.user = current_user
+ authorize!
+ create_and_respond(object: @codeharbor_link, path: -> { @codeharbor_link.user })
+ end
+
+ def update
+ authorize!
+ update_and_respond(object: @codeharbor_link, params: codeharbor_link_params, path: @codeharbor_link.user)
+ end
+
+ def destroy
+ destroy_and_respond(object: @codeharbor_link, path: @codeharbor_link.user)
+ end
+
+ private
+
+ def authorize!
+ authorize @codeharbor_link
+ end
+
+ def set_codeharbor_link
+ @codeharbor_link = CodeharborLink.find(params[:id])
+ @codeharbor_link.user = current_user
+ authorize!
+ end
+
+ def codeharbor_link_params
+ params.require(:codeharbor_link).permit(:push_url, :check_uuid_url, :api_key)
+ end
+end
diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb
index 49cf46ff..b08cecf3 100644
--- a/app/controllers/exercises_controller.rb
+++ b/app/controllers/exercises_controller.rb
@@ -7,14 +7,14 @@ class ExercisesController < ApplicationController
before_action :handle_file_uploads, only: [:create, :update]
before_action :set_execution_environments, only: [:create, :edit, :new, :update]
- before_action :set_exercise_and_authorize, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload, :feedback, :requests_for_comments, :study_group_dashboard]
+ before_action :set_exercise_and_authorize, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload, :feedback, :requests_for_comments, :study_group_dashboard, :export_external_check, :export_external_confirm]
before_action :set_external_user_and_authorize, only: [:statistics]
before_action :set_file_types, only: [:create, :edit, :new, :update]
before_action :set_course_token, only: [:implement]
- skip_before_action :verify_authenticity_token, only: [:import_proforma_xml]
- skip_after_action :verify_authorized, only: [:import_proforma_xml]
- skip_after_action :verify_policy_scoped, only: [:import_proforma_xml], raise: false
+ skip_before_action :verify_authenticity_token, only: [:import_exercise, :import_uuid_check, :export_external_confirm]
+ skip_after_action :verify_authorized, only: [:import_exercise, :import_uuid_check, :export_external_confirm]
+ skip_after_action :verify_policy_scoped, only: [:import_exercise, :import_uuid_check, :export_external_confirm], raise: false
def authorize!
authorize(@exercise || @exercises)
@@ -36,7 +36,6 @@ class ExercisesController < ApplicationController
}
end
-
def experimental_course?(course_token)
experimental_courses.has_value?(course_token)
end
@@ -122,60 +121,96 @@ class ExercisesController < ApplicationController
render 'request_for_comments/index'
end
- def import_proforma_xml
- begin
- user = user_for_oauth2_request()
- exercise = Exercise.new
- request_body = request.body.read
- exercise.from_proforma_xml(request_body)
- exercise.user = user
- saved = exercise.save
- if saved
- render :text => 'SUCCESS', :status => 200
- else
- logger.info(exercise.errors.full_messages)
- render :text => 'Invalid exercise', :status => 400
- end
- rescue => error
- if error.class == Hash
- render :text => error.message, :status => error.status
- else
- raise error
- render :text => '', :status => 500
- end
+ def export_external_check
+ codeharbor_check = ExerciseService::CheckExternal.call(uuid: @exercise.uuid, codeharbor_link: current_user.codeharbor_link)
+ render json: {
+ message: codeharbor_check[:message],
+ actions: render_to_string(
+ partial: 'export_actions',
+ locals: {
+ exercise: @exercise,
+ exercise_found: codeharbor_check[:exercise_found],
+ update_right: codeharbor_check[:update_right],
+ error: codeharbor_check[:error],
+ exported: false
+ }
+ )
+ }, status: 200
+ end
+
+ def export_external_confirm
+ @exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil?
+
+ error = ExerciseService::PushExternal.call(
+ zip: ProformaService::ExportTask.call(exercise: @exercise),
+ codeharbor_link: current_user.codeharbor_link
+ )
+ if error.nil?
+ render json: {
+ status: 'success',
+ message: t('exercises.export_codeharbor.successfully_exported', id: @exercise.id, title: @exercise.title),
+ actions: render_to_string(partial: 'export_actions', locals: {exercise: @exercise, exported: true, error: error})
+ }
+ @exercise.save
+ else
+ render json: {
+ status: 'fail',
+ message: t('exercises.export_codeharbor.export_failed', id: @exercise.id, title: @exercise.title, error: error),
+ actions: render_to_string(partial: 'export_actions', locals: {exercise: @exercise, exported: true, error: error})
+ }
end
end
- def user_for_oauth2_request
- authorizationHeader = request.headers['Authorization']
- if authorizationHeader == nil
- raise ({status: 401, message: 'No Authorization header'})
- end
+ def import_uuid_check
+ user = user_from_api_key
+ return render json: {}, status: 401 if user.nil?
- oauth2Token = authorizationHeader.split(' ')[1]
- if oauth2Token == nil || oauth2Token.size == 0
- raise ({status: 401, message: 'No token in Authorization header'})
- end
+ uuid = params[:uuid]
+ exercise = Exercise.find_by(uuid: uuid)
- user = user_by_code_harbor_token(oauth2Token)
- if user == nil
- raise ({status: 401, message: 'Unknown OAuth2 token'})
- end
+ return render json: {exercise_found: false} if exercise.nil?
+ return render json: {exercise_found: true, update_right: false} unless ExercisePolicy.new(user, exercise).update?
- return user
+ render json: {exercise_found: true, update_right: true}
end
- private :user_for_oauth2_request
- def user_by_code_harbor_token(oauth2Token)
- link = CodeHarborLink.where(:oauth2token => oauth2Token)[0]
- if link != nil
- return link.user
+ def import_exercise
+ tempfile = Tempfile.new('codeharbor_import.zip')
+ tempfile.write request.body.read.force_encoding('UTF-8')
+ tempfile.rewind
+
+ user = user_from_api_key
+ return render json: {}, status: 401 if user.nil?
+
+ exercise = nil
+ ActiveRecord::Base.transaction do
+ exercise = ::ProformaService::Import.call(zip: tempfile, user: user)
+ exercise.save!
+ return render json: {}, status: 201
end
+ rescue Proforma::ExerciseNotOwned
+ render json: {}, status: 401
+ rescue Proforma::ProformaError
+ render json: t('exercises.import_codeharbor.import_errors.invalid'), status: 400
+ rescue StandardError
+ render json: t('exercises.import_codeharbor.import_errors.internal_error'), status: 500
end
- private :user_by_code_harbor_token
+
+ def user_from_api_key
+ authorization_header = request.headers['Authorization']
+ api_key = authorization_header&.split(' ')&.second
+ user_by_codeharbor_token(api_key)
+ end
+ private :user_from_api_key
+
+ def user_by_codeharbor_token(api_key)
+ link = CodeharborLink.find_by_api_key(api_key)
+ link&.user
+ end
+ private :user_by_codeharbor_token
def exercise_params
- params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :allow_file_creation, :allow_auto_completion, :title, :expected_difficulty, files_attributes: file_attributes, :tag_ids => []).merge(user_id: current_user.id, user_type: current_user.class.name) if params[:exercise].present?
+ params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :unpublished, :hide_file_tree, :allow_file_creation, :allow_auto_completion, :title, :expected_difficulty, files_attributes: file_attributes, :tag_ids => []).merge(user_id: current_user.id, user_type: current_user.class.name) if params[:exercise].present?
end
private :exercise_params
@@ -197,6 +232,7 @@ class ExercisesController < ApplicationController
private :handle_file_uploads
def implement
+ redirect_to(@exercise, alert: t('exercises.implement.unpublished')) if @exercise.unpublished? # TODO TESTESTEST
redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists?
user_solved_exercise = @exercise.has_user_solved(current_user)
count_interventions_today = UserExerciseIntervention.where(user: current_user).where("created_at >= ?", Time.zone.now.beginning_of_day).count
diff --git a/app/errors/proforma/exercise_not_owned.rb b/app/errors/proforma/exercise_not_owned.rb
new file mode 100644
index 00000000..afe96364
--- /dev/null
+++ b/app/errors/proforma/exercise_not_owned.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+module Proforma
+ class ExerciseNotOwned < StandardError; end
+end
diff --git a/app/helpers/code_harbor_links_helper.rb b/app/helpers/code_harbor_links_helper.rb
deleted file mode 100644
index d8e92ddf..00000000
--- a/app/helpers/code_harbor_links_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module CodeHarborLinksHelper
-end
diff --git a/app/models/code_harbor_link.rb b/app/models/code_harbor_link.rb
deleted file mode 100644
index 338461d1..00000000
--- a/app/models/code_harbor_link.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-class CodeHarborLink < ApplicationRecord
- validates :oauth2token, presence: true
- validates :user_id, presence: true
-
- belongs_to :internal_user, foreign_key: :user_id
- alias_method :user, :internal_user
- alias_method :user=, :internal_user=
-
- def to_s
- oauth2token
- end
-
-end
diff --git a/app/models/codeharbor_link.rb b/app/models/codeharbor_link.rb
new file mode 100644
index 00000000..b35dc6cf
--- /dev/null
+++ b/app/models/codeharbor_link.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class CodeharborLink < ApplicationRecord
+ validates :push_url, presence: true
+ validates :check_uuid_url, presence: true
+ validates :api_key, presence: true
+
+ belongs_to :user, foreign_key: :user_id, class_name: 'InternalUser'
+
+ def to_s
+ id.to_s
+ end
+end
diff --git a/app/models/exercise.rb b/app/models/exercise.rb
index def5a098..86b402d8 100644
--- a/app/models/exercise.rb
+++ b/app/models/exercise.rb
@@ -9,7 +9,7 @@ class Exercise < ApplicationRecord
after_initialize :generate_token
after_initialize :set_default_values
- belongs_to :execution_environment
+ belongs_to :execution_environment, optional: true
has_many :submissions
has_and_belongs_to_many :proxy_exercises
@@ -31,17 +31,18 @@ class Exercise < ApplicationRecord
validate :valid_main_file?
validates :description, presence: true
- validates :execution_environment_id, presence: true
+ validates :execution_environment, presence: true, if: -> { !unpublished? }
validates :public, boolean_presence: true
+ validates :unpublished, boolean_presence: true
validates :title, presence: true
validates :token, presence: true, uniqueness: true
+ validates_uniqueness_of :uuid, if: -> { uuid.present? }
@working_time_statistics = nil
attr_reader :working_time_statistics
MAX_EXERCISE_FEEDBACKS = 20
-
def average_percentage
if average_score and maximum_score != 0.0 and submissions.exists?(cause: 'submit')
(average_score / maximum_score * 100).round(2)
@@ -49,7 +50,7 @@ class Exercise < ApplicationRecord
0
end
end
-
+
def finishers_percentage
if users.distinct.count != 0
(100.0 / users.distinct.count * finishers.count).round(2)
diff --git a/app/models/user.rb b/app/models/user.rb
index a9c9dab2..686456f4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -13,6 +13,7 @@ class User < ApplicationRecord
has_many :user_proxy_exercise_exercises, as: :user
has_many :user_exercise_interventions, as: :user
has_many :interventions, through: :user_exercise_interventions
+ has_one :codeharbor_link, dependent: :destroy
accepts_nested_attributes_for :user_proxy_exercise_exercises
diff --git a/app/policies/code_harbor_link_policy.rb b/app/policies/code_harbor_link_policy.rb
deleted file mode 100644
index 8726c22a..00000000
--- a/app/policies/code_harbor_link_policy.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-class CodeHarborLinkPolicy < AdminOnlyPolicy
-
-end
diff --git a/app/policies/codeharbor_link_policy.rb b/app/policies/codeharbor_link_policy.rb
new file mode 100644
index 00000000..3dd4ff54
--- /dev/null
+++ b/app/policies/codeharbor_link_policy.rb
@@ -0,0 +1,35 @@
+class CodeharborLinkPolicy < ApplicationPolicy
+ def index?
+ false
+ end
+
+ def show?
+ false
+ end
+
+ def new?
+ teacher? || admin?
+ end
+
+ def create?
+ teacher? || admin?
+ end
+
+ def edit?
+ owner?
+ end
+
+ def update?
+ owner?
+ end
+
+ def destroy?
+ owner?
+ end
+
+ private
+
+ def owner?
+ @record.reload.user == @user
+ end
+end
diff --git a/app/policies/exercise_policy.rb b/app/policies/exercise_policy.rb
index 41f735a0..f44a302d 100644
--- a/app/policies/exercise_policy.rb
+++ b/app/policies/exercise_policy.rb
@@ -7,7 +7,7 @@ class ExercisePolicy < AdminOrAuthorPolicy
define_method(action) { admin? || teacher? }
end
- [:clone?, :destroy?, :edit?, :update?].each do |action|
+ [:clone?, :destroy?, :edit?, :update?, :export_external_check?, :export_external_confirm?].each do |action|
define_method(action) { admin? || teacher_in_study_group || author? }
end
diff --git a/app/services/exercise_service/check_external.rb b/app/services/exercise_service/check_external.rb
new file mode 100644
index 00000000..cf4a818e
--- /dev/null
+++ b/app/services/exercise_service/check_external.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module ExerciseService
+ class CheckExternal < ServiceBase
+ def initialize(uuid:, codeharbor_link:)
+ @uuid = uuid
+ @codeharbor_link = codeharbor_link
+ end
+
+ def execute
+ response = connection.post do |req|
+ req.headers['Content-Type'] = 'application/json'
+ req.headers['Authorization'] = 'Bearer ' + @codeharbor_link.api_key
+ req.body = {uuid: @uuid}.to_json
+ end
+ response_hash = JSON.parse(response.body, symbolize_names: true).slice(:exercise_found, :update_right)
+
+ {error: false, message: message(response_hash[:exercise_found], response_hash[:update_right])}.merge(response_hash)
+ rescue Faraday::Error, JSON::ParserError
+ {error: true, message: I18n.t('exercises.export_codeharbor.error')}
+ end
+
+ private
+
+ def message(exercise_found, update_right)
+ if exercise_found
+ update_right ? I18n.t('exercises.export_codeharbor.check.exercise_found') : I18n.t('exercises.export_codeharbor.check.exercise_found_no_right')
+ else
+ I18n.t('exercises.export_codeharbor.check.no_exercise')
+ end
+ end
+
+ def connection
+ Faraday.new(url: @codeharbor_link.check_uuid_url) do |faraday|
+ faraday.options[:open_timeout] = 5
+ faraday.options[:timeout] = 5
+
+ faraday.adapter Faraday.default_adapter
+ end
+ end
+ end
+end
diff --git a/app/services/exercise_service/push_external.rb b/app/services/exercise_service/push_external.rb
new file mode 100644
index 00000000..3410a7ba
--- /dev/null
+++ b/app/services/exercise_service/push_external.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module ExerciseService
+ class PushExternal < ServiceBase
+ def initialize(zip:, codeharbor_link:)
+ @zip = zip
+ @codeharbor_link = codeharbor_link
+ end
+
+ def execute
+ body = @zip.string
+ begin
+ response = connection.post do |request|
+ request.headers['Content-Type'] = 'application/zip'
+ request.headers['Content-Length'] = body.length.to_s
+ request.headers['Authorization'] = 'Bearer ' + @codeharbor_link.api_key
+ request.body = body
+ end
+
+ response.success? ? nil : response.body
+ rescue StandardError => e
+ e.message
+ end
+ end
+
+ private
+
+ def connection
+ Faraday.new(url: @codeharbor_link.push_url) do |faraday|
+ faraday.adapter Faraday.default_adapter
+ end
+ end
+ end
+end
diff --git a/app/services/proforma_service/convert_exercise_to_task.rb b/app/services/proforma_service/convert_exercise_to_task.rb
new file mode 100644
index 00000000..b628b04d
--- /dev/null
+++ b/app/services/proforma_service/convert_exercise_to_task.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'mimemagic'
+
+module ProformaService
+ class ConvertExerciseToTask < ServiceBase
+ DEFAULT_LANGUAGE = 'de'
+
+ def initialize(exercise: nil)
+ @exercise = exercise
+ end
+
+ def execute
+ create_task
+ end
+
+ private
+
+ def create_task
+ Proforma::Task.new(
+ {
+ title: @exercise.title,
+ description: @exercise.description,
+ internal_description: @exercise.instructions,
+ files: task_files,
+ tests: tests,
+ uuid: uuid,
+ language: DEFAULT_LANGUAGE,
+ model_solutions: model_solutions
+ }.compact
+ )
+ end
+
+ def uuid
+ @exercise.update(uuid: SecureRandom.uuid) if @exercise.uuid.nil?
+ @exercise.uuid
+ end
+
+ def model_solutions
+ @exercise.files.filter { |file| file.role == 'reference_implementation' }.map do |file|
+ Proforma::ModelSolution.new(
+ id: "ms-#{file.id}",
+ files: model_solution_file(file)
+ )
+ end
+ end
+
+ def model_solution_file(file)
+ [
+ task_file(file).tap do |ms_file|
+ ms_file.used_by_grader = false
+ ms_file.usage_by_lms = 'display'
+ ms_file.visible = 'delayed'
+ end
+ ]
+ end
+
+ def tests
+ @exercise.files.filter { |file| file.role == 'teacher_defined_test' }.map do |file|
+ Proforma::Test.new(
+ id: file.id,
+ title: file.name,
+ files: test_file(file),
+ meta_data: {
+ 'feedback-message' => file.feedback_message
+ }.compact
+ )
+ end
+ end
+
+ def test_file(file)
+ [
+ task_file(file).tap do |t_file|
+ t_file.used_by_grader = true
+ t_file.internal_description = 'teacher_defined_test'
+ end
+ ]
+ end
+
+ def task_files
+ @exercise.files
+ .filter { |file| !file.role.in? %w[reference_implementation teacher_defined_test] }.map do |file|
+ task_file(file)
+ end
+ end
+
+ def task_file(file)
+ task_file = Proforma::TaskFile.new(
+ id: file.id,
+ filename: filename(file),
+ usage_by_lms: file.read_only ? 'display' : 'edit',
+ visible: file.hidden ? 'no' : 'yes',
+ internal_description: file.role || 'regular_file'
+ )
+ add_content_to_task_file(file, task_file)
+ task_file
+ end
+
+ def filename(file)
+ file.path.present? && file.path != '.' ? ::File.join(file.path, file.name_with_extension) : file.name_with_extension
+ end
+
+ def add_content_to_task_file(file, task_file)
+ if file.native_file.present?
+ file = ::File.new(file.native_file.file.path, 'r')
+ task_file.content = file.read
+ task_file.used_by_grader = false
+ task_file.binary = true
+ task_file.mimetype = MimeMagic.by_magic(file).type
+ else
+ task_file.content = file.content
+ task_file.used_by_grader = true
+ task_file.binary = false
+ end
+ end
+ end
+end
diff --git a/app/services/proforma_service/convert_task_to_exercise.rb b/app/services/proforma_service/convert_task_to_exercise.rb
new file mode 100644
index 00000000..7f49fa40
--- /dev/null
+++ b/app/services/proforma_service/convert_task_to_exercise.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module ProformaService
+ class ConvertTaskToExercise < ServiceBase
+ def initialize(task:, user:, exercise: nil)
+ @task = task
+ @user = user
+ @exercise = exercise || Exercise.new(unpublished: true)
+ end
+
+ def execute
+ import_exercise
+ @exercise
+ end
+
+ private
+
+ def import_exercise
+ @exercise.assign_attributes(
+ user: @user,
+ title: @task.title,
+ description: @task.description,
+ instructions: @task.internal_description,
+ files: files
+ )
+ end
+
+ def files
+ test_files + task_files.values
+ end
+
+ def test_files
+ @task.tests.map do |test_object|
+ task_files.delete(test_object.files.first.id).tap do |file|
+ file.weight = 1.0
+ file.feedback_message = test_object.meta_data['feedback-message']
+ end
+ end
+ end
+
+ def task_files
+ @task_files ||= Hash[
+ @task.all_files.reject { |file| file.id == 'ms-placeholder-file' }.map do |task_file|
+ [task_file.id, codeocean_file_from_task_file(task_file)]
+ end
+ ]
+ end
+
+ def codeocean_file_from_task_file(file)
+ codeocean_file = CodeOcean::File.new(
+ context: @exercise,
+ file_type: FileType.find_by(file_extension: File.extname(file.filename)),
+ hidden: file.visible == 'no',
+ name: File.basename(file.filename, '.*'),
+ read_only: file.usage_by_lms != 'edit',
+ role: file.internal_description,
+ path: File.dirname(file.filename).in?(['.', '']) ? nil : File.dirname(file.filename)
+ )
+ if file.binary
+ codeocean_file.native_file = FileIO.new(file.content.dup.force_encoding('UTF-8'), File.basename(file.filename))
+ else
+ codeocean_file.content = file.content
+ end
+ codeocean_file
+ end
+ end
+end
diff --git a/app/services/proforma_service/export_task.rb b/app/services/proforma_service/export_task.rb
new file mode 100644
index 00000000..72679e23
--- /dev/null
+++ b/app/services/proforma_service/export_task.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module ProformaService
+ class ExportTask < ServiceBase
+ def initialize(exercise: nil)
+ @exercise = exercise
+ end
+
+ def execute
+ @task = ConvertExerciseToTask.call(exercise: @exercise)
+ exporter = Proforma::Exporter.new(@task)
+ exporter.perform
+ end
+ end
+end
diff --git a/app/services/proforma_service/import.rb b/app/services/proforma_service/import.rb
new file mode 100644
index 00000000..9a2e6317
--- /dev/null
+++ b/app/services/proforma_service/import.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module ProformaService
+ class Import < ServiceBase
+ def initialize(zip:, user:)
+ @zip = zip
+ @user = user
+ end
+
+ def execute
+ if single_task?
+ importer = Proforma::Importer.new(@zip)
+ @task = importer.perform
+
+ exercise = base_exercise
+ exercise_files = exercise&.files&.to_a
+
+ exercise = ConvertTaskToExercise.call(task: @task, user: @user, exercise: exercise)
+ exercise_files&.each(&:destroy) # feels suboptimal
+
+ exercise
+ else
+ import_multi
+ end
+ end
+
+ private
+
+ def base_exercise
+ exercise = Exercise.find_by(uuid: @task.uuid)
+ if exercise
+ raise Proforma::ExerciseNotOwned unless ExercisePolicy.new(@user, exercise).update?
+
+ exercise
+ else
+ Exercise.new(uuid: @task.uuid, unpublished: true)
+ end
+ end
+
+ def import_multi
+ Zip::File.open(@zip.path) do |zip_file|
+ zip_files = zip_file.filter { |entry| entry.name.match?(/\.zip$/) }
+ begin
+ zip_files.map! do |entry|
+ store_zip_entry_in_tempfile entry
+ end
+ zip_files.map do |proforma_file|
+ Import.call(zip: proforma_file, user: @user)
+ end
+ ensure
+ zip_files.each(&:unlink)
+ end
+ end
+ end
+
+ def store_zip_entry_in_tempfile(entry)
+ tempfile = Tempfile.new(entry.name)
+ tempfile.write entry.get_input_stream.read.force_encoding('UTF-8')
+ tempfile.rewind
+ tempfile
+ end
+
+ def single_task?
+ filenames = Zip::File.open(@zip.path) do |zip_file|
+ zip_file.map(&:name)
+ end
+
+ filenames.select { |f| f[/\.xml$/] }.any?
+ rescue Zip::Error
+ raise Proforma::InvalidZip
+ end
+ end
+end
diff --git a/app/services/service_base.rb b/app/services/service_base.rb
new file mode 100644
index 00000000..39010bb7
--- /dev/null
+++ b/app/services/service_base.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class ServiceBase
+ def self.call(*args)
+ new(*args).execute
+ end
+end
diff --git a/app/views/application/_navigation.html.slim b/app/views/application/_navigation.html.slim
index 31115189..738dfc9d 100644
--- a/app/views/application/_navigation.html.slim
+++ b/app/views/application/_navigation.html.slim
@@ -19,5 +19,5 @@
models: [ErrorTemplate, ErrorTemplateAttribute], cached: true)
= render('navigation_submenu', title: t('navigation.sections.files'), models: [FileType, FileTemplate],
cached: true)
- = render('navigation_submenu', title: t('navigation.sections.integrations'), models: [Consumer, CodeHarborLink],
+ = render('navigation_submenu', title: t('navigation.sections.integrations'), models: [Consumer],
cached: true)
diff --git a/app/views/code_harbor_links/_form.html.slim b/app/views/code_harbor_links/_form.html.slim
deleted file mode 100644
index f7b449ee..00000000
--- a/app/views/code_harbor_links/_form.html.slim
+++ /dev/null
@@ -1,6 +0,0 @@
-= form_for(@code_harbor_link) do |f|
- = render('shared/form_errors', object: @code_harbor_link)
- .form-group
- = f.label(:oauth2token)
- = f.text_field(:oauth2token, class: 'form-control', required: true)
- .actions = render('shared/submit_button', f: f, object: @code_harbor_link)
diff --git a/app/views/code_harbor_links/edit.html.slim b/app/views/code_harbor_links/edit.html.slim
deleted file mode 100644
index d1c7ea8f..00000000
--- a/app/views/code_harbor_links/edit.html.slim
+++ /dev/null
@@ -1,3 +0,0 @@
-h1 = @code_harbor_link
-
-= render('form')
diff --git a/app/views/code_harbor_links/index.html.slim b/app/views/code_harbor_links/index.html.slim
deleted file mode 100644
index c07e43da..00000000
--- a/app/views/code_harbor_links/index.html.slim
+++ /dev/null
@@ -1,18 +0,0 @@
-h1 = CodeHarborLink.model_name.human(count: 2)
-
-.table-responsive
- table.table
- thead
- tr
- th = t('activerecord.attributes.code_harbor_link.oauth2token')
- th colspan=3 = t('shared.actions')
- tbody
- - @code_harbor_links.each do |code_harbor_link|
- tr
- td = link_to_if(policy(code_harbor_link).show?, code_harbor_link.oauth2token, code_harbor_link)
- td = link_to(t('shared.show'), code_harbor_link) if policy(code_harbor_link).show?
- td = link_to(t('shared.edit'), edit_code_harbor_link_path(code_harbor_link)) if policy(code_harbor_link).edit?
- td = link_to(t('shared.destroy'), code_harbor_link, data: {confirm: t('shared.confirm_destroy')}, method: :delete) if policy(code_harbor_link).destroy?
-
-= render('shared/pagination', collection: @code_harbor_links)
-p = render('shared/new_button', model: CodeHarborLink)
diff --git a/app/views/code_harbor_links/new.html.slim b/app/views/code_harbor_links/new.html.slim
deleted file mode 100644
index ef19a3e6..00000000
--- a/app/views/code_harbor_links/new.html.slim
+++ /dev/null
@@ -1,3 +0,0 @@
-h1 = t('shared.new_model', model: CodeHarborLink.model_name.human)
-
-= render('form')
diff --git a/app/views/code_harbor_links/show.html.slim b/app/views/code_harbor_links/show.html.slim
deleted file mode 100644
index e4a29f75..00000000
--- a/app/views/code_harbor_links/show.html.slim
+++ /dev/null
@@ -1,7 +0,0 @@
-h1
- = @code_harbor_link
- = render('shared/edit_button', object: @code_harbor_link)
-
-- %w[oauth2token].each do |attribute|
- = row(label: "code_harbor_link.#{attribute}") do
- = content_tag(:input, nil, class: 'form-control', readonly: true, value: @code_harbor_link.send(attribute))
diff --git a/app/views/codeharbor_links/_form.html.slim b/app/views/codeharbor_links/_form.html.slim
new file mode 100644
index 00000000..f7b84ddc
--- /dev/null
+++ b/app/views/codeharbor_links/_form.html.slim
@@ -0,0 +1,19 @@
+= form_for(@codeharbor_link) do |f|
+ = render('shared/form_errors', object: @codeharbor_link)
+ .form-group
+ = f.label(:push_url)
+ = f.text_field :push_url, data: {toggle: 'tooltip', placement: 'bottom'}, title: t('codeharbor_link.info.push_url'), class: 'form-control'
+ .form-group
+ = f.label(:check_uuid_url)
+ = f.text_field :check_uuid_url, data: {toggle: 'tooltip', placement: 'bottom'}, title: t('codeharbor_link.info.check_uuid_url'), class: 'form-control'
+ .form-group
+ = f.label(:api_key)
+ .input-group
+ = f.text_field(:api_key, data: {toggle: 'tooltip', placement: 'bottom'}, title: t('codeharbor_link.info.api_key'), class: 'form-control api_key')
+ .input-group-btn
+ = button_tag t('codeharbor_link.generate'), type: 'button', class: 'generate-api_key btn btn-default'
+ .actions
+ = render('shared/submit_button', f: f, object: @codeharbor_link)
+ - if @codeharbor_link.persisted?
+ = link_to(t('codeharbor_link.delete'), codeharbor_link_path(@codeharbor_link), data: {confirm: t('shared.confirm_destroy')}, method: :delete, class: 'btn btn-danger pull-right')
+
diff --git a/app/views/codeharbor_links/edit.html.slim b/app/views/codeharbor_links/edit.html.slim
new file mode 100644
index 00000000..1ccf2a3b
--- /dev/null
+++ b/app/views/codeharbor_links/edit.html.slim
@@ -0,0 +1,3 @@
+h1 = CodeharborLink.model_name.human
+
+= render('form')
diff --git a/app/views/codeharbor_links/new.html.slim b/app/views/codeharbor_links/new.html.slim
new file mode 100644
index 00000000..c3038281
--- /dev/null
+++ b/app/views/codeharbor_links/new.html.slim
@@ -0,0 +1,3 @@
+h1 = t('shared.new_model', model: CodeharborLink.model_name.human)
+
+= render('form')
diff --git a/app/views/exercises/_export_actions.html.slim b/app/views/exercises/_export_actions.html.slim
new file mode 100644
index 00000000..0484d2b2
--- /dev/null
+++ b/app/views/exercises/_export_actions.html.slim
@@ -0,0 +1,14 @@
+- if error
+ = button_tag type: 'button', class:'btn btn-primary pull-right export-button export-retry-button', data: {exercise_id: exercise.id} do
+ i.fa.fa-refresh.confirm-icon
+ = t('exercises.export_codeharbor.buttons.retry')
+- else
+ - unless exported
+ - if !exercise_found || update_right
+ = button_tag type: 'button', class:'btn btn-primary pull-right export-action export-button', data: {exercise_id: exercise.id} do
+ i.fa.fa-check.confirm-icon
+ = t('exercises.export_codeharbor.buttons.export')
+
+= button_tag type: 'submit', class:'btn btn-secondary pull-right export-button', data: {dismiss: 'modal'} do
+ i.fa.fa-remove.abort-icon
+ = exported ? t('exercises.export_codeharbor.buttons.close') : t('exercises.export_codeharbor.buttons.abort')
diff --git a/app/views/exercises/_export_dialogcontent.html.slim b/app/views/exercises/_export_dialogcontent.html.slim
new file mode 100644
index 00000000..bae04880
--- /dev/null
+++ b/app/views/exercises/_export_dialogcontent.html.slim
@@ -0,0 +1,3 @@
+#export-exercise
+ .export-message
+ .export-exercise-actions
diff --git a/app/views/exercises/_form.html.slim b/app/views/exercises/_form.html.slim
index b6d0980b..dcf31568 100644
--- a/app/views/exercises/_form.html.slim
+++ b/app/views/exercises/_form.html.slim
@@ -11,7 +11,7 @@
= f.pagedown :description, input_html: { preview: true, rows: 10 }
.form-group
= f.label(:execution_environment_id)
- = f.collection_select(:execution_environment_id, @execution_environments, :id, :name, {}, class: 'form-control')
+ = f.collection_select(:execution_environment_id, @execution_environments, :id, :name, {include_blank: t('exercises.form.none')}, class: 'form-control')
/.form-group
= f.label(:instructions)
= f.hidden_field(:instructions)
@@ -20,6 +20,10 @@
label.form-check-label
= f.check_box(:public, class: 'form-check-input')
= t('activerecord.attributes.exercise.public')
+ .form-check
+ label.form-check-label
+ = f.check_box(:unpublished, class: 'form-check-input')
+ = t('activerecord.attributes.exercise.unpublished')
.form-check
label.form-check-label
= f.check_box(:hide_file_tree, class: 'form-check-input')
@@ -66,4 +70,4 @@
ul#dummies.d-none = f.fields_for(:files, CodeOcean::File.new, child_index: 'index') do |files_form|
= render('file_form', f: files_form)
- .actions = render('shared/submit_button', f: f, object: @exercise)
\ No newline at end of file
+ .actions = render('shared/submit_button', f: f, object: @exercise)
diff --git a/app/views/exercises/index.html.slim b/app/views/exercises/index.html.slim
index 2594fb28..c6dbc8f4 100644
--- a/app/views/exercises/index.html.slim
+++ b/app/views/exercises/index.html.slim
@@ -47,6 +47,9 @@ h1 = Exercise.model_name.human(count: 2)
li = link_to(t('activerecord.models.request_for_comment.other'), requests_for_comments_exercise_path(exercise), class: 'dropdown-item') if policy(exercise).requests_for_comments?
li = link_to(t('shared.destroy'), exercise, data: {confirm: t('shared.confirm_destroy')}, method: :delete, class: 'dropdown-item') if policy(exercise).destroy?
li = link_to(t('.clone'), clone_exercise_path(exercise), data: {confirm: t('shared.confirm_destroy')}, method: :post, class: 'dropdown-item') if policy(exercise).clone?
+ li = link_to(t('exercises.export_codeharbor.label'), '', class: 'dropdown-item export-start', data: {'exercise-id' => exercise.id}) if policy(exercise).export_external_confirm?
= render('shared/pagination', collection: @exercises)
p = render('shared/new_button', model: Exercise)
+
+= render('shared/modal', id: 'export-modal', title: t('exercises.export_codeharbor.dialogtitle'), template: 'exercises/_export_dialogcontent')
diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim
index e2f7ada9..2aaa2993 100644
--- a/app/views/exercises/show.html.slim
+++ b/app/views/exercises/show.html.slim
@@ -12,17 +12,19 @@ h1
= row(label: 'exercise.title', value: @exercise.title)
= row(label: 'exercise.user', value: link_to_if(policy(@exercise.author).show?, @exercise.author, @exercise.author))
= row(label: 'exercise.description', value: render_markdown(@exercise.description), class: 'm-0')
-= row(label: 'exercise.execution_environment', value: link_to_if(policy(@exercise.execution_environment).show?, @exercise.execution_environment, @exercise.execution_environment))
+= row(label: 'exercise.execution_environment', value: link_to_if(@exercise.execution_environment && policy(@exercise.execution_environment).show?, @exercise.execution_environment, @exercise.execution_environment))
/= row(label: 'exercise.instructions', value: render_markdown(@exercise.instructions))
= row(label: 'exercise.maximum_score', value: @exercise.maximum_score)
= row(label: 'exercise.public', value: @exercise.public?)
+= row(label: 'exercise.unpublished', value: @exercise.unpublished?)
= row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?)
= row(label: 'exercise.allow_file_creation', value: @exercise.allow_file_creation?)
= row(label: 'exercise.allow_auto_completion', value: @exercise.allow_auto_completion?)
= row(label: 'exercise.difficulty', value: @exercise.expected_difficulty)
+= row(label: 'exercise.uuid', value: @exercise.uuid)
= row(label: 'exercise.tags', value: @exercise.exercise_tags.map{|et| "#{et.tag.name} (#{et.factor})"}.sort.join(", "))
= row(label: 'exercise.embedding_parameters', class: 'mb-4') do
- = content_tag(:input, nil, class: 'form-control mb-4', readonly: true, value: embedding_parameters(@exercise))
+ = content_tag(:input, nil, class: 'form-control mb-4', readonly: true, value: @exercise.unpublished? ? t('exercises.show.is_unpublished') : embedding_parameters(@exercise))
h2.mt-4 = t('activerecord.attributes.exercise.files')
diff --git a/app/views/internal_users/show.html.slim b/app/views/internal_users/show.html.slim
index 167443f6..71a16383 100644
--- a/app/views/internal_users/show.html.slim
+++ b/app/views/internal_users/show.html.slim
@@ -7,3 +7,5 @@ h1
= row(label: 'internal_user.consumer', value: @user.consumer ? link_to_if(policy(@user.consumer).show?, @user.consumer, @user.consumer) : nil)
= row(label: 'internal_user.role', value: t("users.roles.#{@user.role}"))
= row(label: 'internal_user.activated', value: @user.activated?)
+
+= row(label: 'codeharbor_link.profile_label', value: @user.codeharbor_link.nil? ? link_to(t('codeharbor_link.new'), new_codeharbor_link_path, class: 'btn btn-secondary') : link_to(t('codeharbor_link.edit'), edit_codeharbor_link_path(@user.codeharbor_link), class: 'btn btn-secondary'))
diff --git a/config/code_ocean.yml.travis b/config/code_ocean.yml.travis
index 28485afc..c4a2b410 100644
--- a/config/code_ocean.yml.travis
+++ b/config/code_ocean.yml.travis
@@ -2,4 +2,6 @@ test:
flowr:
enabled: false
code_pilot:
- enabled: false
\ No newline at end of file
+ enabled: false
+ codeharbor:
+ url: http://test.url
diff --git a/config/locales/de.yml b/config/locales/de.yml
index dee7ba41..2b5985ac 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -1,7 +1,7 @@
de:
activerecord:
attributes:
- code_harbor_link:
+ codeharbor_link:
oauth2token: OAuth2 Token
consumer:
name: Name
@@ -44,6 +44,8 @@ de:
allow_file_creation: "Dateierstellung erlauben"
difficulty: Schwierigkeitsgrad
token: "Aufgaben-Token"
+ uuid: UUID
+ unpublished: Deaktiviert
proxy_exercise:
title: Title
files_count: Anzahl der Aufgaben
@@ -240,6 +242,16 @@ de:
consumers:
show:
link: Konsument
+ codeharbor_link:
+ generate: Generieren
+ info:
+ push_url: Die Url von Codeharbor zu der die Aufgabe exportiert werden soll. Bei Unklarheiten bitte an einen Admin wenden.
+ check_uuid_url: Die Url von Codeharbor an der geprüft werden kann, ob die Aufgabe schon existiert. Bei Unklarheiten bitte an einen Admin wenden.
+ api_key: Wird zum Authentifizieren gegenüber Codeharbor genutzt. Auf Codeharbor muss der gleiche Key eingetragen sein.
+ profile_label: Codeharbor Link
+ new: Neue Codeharbor Link anlegen
+ edit: Codeharbor Link bearbeiten
+ delete: Codeharbor Link entfernen
execution_environments:
form:
hints:
@@ -298,6 +310,26 @@ de:
request_for_comments_sent: "Kommentaranfrage gesendet."
editor_file_tree:
file_root: Dateien
+ import_codeharbor:
+ import_errors:
+ invalid: Fehlerhafte Aufgabe
+ internal_error: Beim Import der Aufgabe ist ein interner Fehler aufgetreten.
+ export_codeharbor:
+ label: Zu Codeharbor exportieren
+ dialogtitle: Zu Codeharbor exportieren
+ successfully_exported: 'Aufgabe wurde erfolgreich exportiert.
ID: %{id}
Title: %{title}'
+ export_failed: 'Export ist fehlgeschlagen.
ID: %{id}
Title: %{title}
Error: %{error}'
+ error: Es ist ein Fehler bei der Kommunikation mit Codeharbor aufgetreten.
+ checking_codeharbor: Es wird geprüft, ob auf Codeharbor eine korrespondierende Aufgabe gefunden werden kann.
+ buttons:
+ retry: Erneut probieren
+ export: Export
+ close: Schließen
+ abort: Abbrechen
+ check:
+ no_exercise: Auf Codeharbor wurde keine entsprechende Aufgabe gefunden. Mit dem Export der Aufgabe wird eine neue auf Codeharbor angelegt, die mit dieser verbunden ist. Anschließend können Veränderungen an der Aufgabe von beiden Systemen aus jeweils in das andere Übertragen werden.
+ exercise_found: Auf Codeharbor wurde eine entsprechende Aufgabe gefunden. Mit dem Export der Aufgabe werden alle Veränderungen, die auf Codeocean vorgenommen wurden, exportiert und die Aufgabe auf Codeharbor überschrieben.
+ exercise_found_no_right: Auf Codeharbor wurde eine entsprechende Aufgabe gefunden, Sie haben aber keine Rechte sie zu bearbeiten. Bitte wenden Sie sich an einen Admin, wenn Sie denken, dass Sie die Rechte dazu besitzen sollten.
file_form:
hints:
feedback_message: Diese Nachricht wird als Tipp zu fehlschlagenden Tests angezeigt.
@@ -306,6 +338,9 @@ de:
add_file: Datei hinzufügen
tags: "Tags"
click_to_collapse: "Zum Aus-/Einklappen hier klicken..."
+ unpublish_warning: Mit dieser Aktion wird die Aufgabe deaktiviert. Jeder Student, der versucht sie zu implementieren wird eine Fehlermeldung bekommen, bis die Aufgabe wieder aktiviert wurde.
+ no_execution_environment_selected: Bitte eine Ausführungsumgebung auswählen, bevor die Aufgabe aktiviert wird.
+ none: Keine
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.'
@@ -355,6 +390,8 @@ de:
feedback: Feedback
requests_for_comments: Kommentaranfragen
study_group_dashboard: Live Dashboard
+ show:
+ is_unpublished: Aufgabe ist deaktiviert
statistics:
average_score: Durchschnittliche Punktzahl
final_submissions: Finale Abgaben
diff --git a/config/locales/en.yml b/config/locales/en.yml
index eb5c399f..84c4f89f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1,7 +1,7 @@
en:
activerecord:
attributes:
- code_harbor_link:
+ codeharbor_link:
oauth2token: OAuth2 Token
consumer:
name: Name
@@ -44,6 +44,8 @@ en:
allow_file_creation: "Allow file creation"
difficulty: Difficulty
token: "Exercise Token"
+ uuid: UUID
+ unpublished: Unpublished
proxy_exercise:
title: Title
files_count: Exercises Count
@@ -240,6 +242,16 @@ en:
consumers:
show:
link: Consumer
+ codeharbor_link:
+ generate: Generate
+ info:
+ push_url: The url from Codeharbor where your exercise can be exported to. If you don't know what to write here, ask an admin.
+ check_uuid_url: The url from Codeharbor where we can check if the exercise already exists. If you don't know what to write here, ask an admin.
+ api_key: Will be used to authenticate your export request. Has to be the same on both sides.
+ profile_label: Codeharbor Link
+ new: Create link to Codeharbor
+ edit: Edit existing link
+ delete: Remove Codeharbor link
execution_environments:
form:
hints:
@@ -298,6 +310,26 @@ en:
request_for_comments_sent: "Request for comments sent."
editor_file_tree:
file_root: Files
+ import_codeharbor:
+ import_errors:
+ invalid: Invalid exercise
+ internal_error: An internal error occurred on Codeharbor while importing the exercise.
+ export_codeharbor:
+ label: Export to Codeharbor
+ dialogtitle: Export to Codeharbor
+ successfully_exported: 'Exercise has been successfully exported.
ID: %{id}
Title: %{title}'
+ export_failed: 'Export has failed.
ID: %{id}
Title: %{title}
Error: %{error}'
+ error: 'An error occurred while contacting Codeharbor'
+ checking_codeharbor: Checking if the exercise exists on Codeharbor.
+ buttons:
+ retry: Retry
+ export: Export
+ close: Close
+ abort: Abort
+ check:
+ no_exercise: No corresponding exercise found on Codeharbor. Pushing this exercise will create a new exercise on Codeharbor, which will be linked to this one on Codeocean. Any changes to either one can be pushed to the respective other platform.
+ exercise_found: 'A corresponding exercise has been found on Codeharbor. You can export the exercise to transfer all changes made on Codeocean to Codeharbor. Careful: This will overwrite all potential changes made on Codeharbor.'
+ exercise_found_no_right: A corresponding exercise has been found on Codeharbor, but you don't have the rights to edit it. Please contact an Admin if you think you should be able to edit the exercise on Codeharbor.
file_form:
hints:
feedback_message: This message is used as a hint for failing tests.
@@ -306,6 +338,9 @@ en:
add_file: Add file
tags: "Tags"
click_to_collapse: "Click to expand/collapse..."
+ unpublish_warning: This will unpublish the exercise. Any student trying to implement it will get an error message, until it is published again.
+ no_execution_environment_selected: Select an execution environment before publishing the exercise.
+ none: None
implement:
alert:
text: 'Your browser does not support features required for using %{application_name}. Please access %{application_name} using a modern browser.'
@@ -355,6 +390,8 @@ en:
feedback: Feedback
requests_for_comments: Requests for Comments
study_group_dashboard: Live Dashboard
+ show:
+ is_unpublished: Exercise is unpublished
statistics:
average_score: Average Score
final_submissions: Final Submissions
diff --git a/config/routes.rb b/config/routes.rb
index 43f54694..33d6fc0f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -13,7 +13,7 @@ Rails.application.routes.draw do
get 'by_file_type/:file_type_id', as: :by_file_type, action: :by_file_type
end
end
- resources :code_harbor_links
+ resources :codeharbor_links, only: %i[new create edit update destroy]
resources :request_for_comments do
member do
get :mark_as_solved, defaults: { format: :json }
@@ -66,7 +66,8 @@ Rails.application.routes.draw do
end
end
- post '/import_proforma_xml' => 'exercises#import_proforma_xml'
+ post '/import_exercise' => 'exercises#import_exercise'
+ post '/import_uuid_check' => 'exercises#import_uuid_check'
resources :exercises do
collection do
@@ -85,6 +86,8 @@ Rails.application.routes.draw do
get :reload
post :submit
get 'study_group_dashboard/:study_group_id', to: 'exercises#study_group_dashboard'
+ post :export_external_check
+ post :export_external_confirm
end
end
diff --git a/db/migrate/20190818104802_rename_code_harbor_links_to_codeharbor_links.rb b/db/migrate/20190818104802_rename_code_harbor_links_to_codeharbor_links.rb
new file mode 100644
index 00000000..5c7ba063
--- /dev/null
+++ b/db/migrate/20190818104802_rename_code_harbor_links_to_codeharbor_links.rb
@@ -0,0 +1,5 @@
+class RenameCodeHarborLinksToCodeharborLinks < ActiveRecord::Migration[5.2]
+ def change
+ rename_table :code_harbor_links, :codeharbor_links
+ end
+end
diff --git a/db/migrate/20190818104954_add_push_url_rename_oauth2token_in_codeharbor_links.rb b/db/migrate/20190818104954_add_push_url_rename_oauth2token_in_codeharbor_links.rb
new file mode 100644
index 00000000..24dd9137
--- /dev/null
+++ b/db/migrate/20190818104954_add_push_url_rename_oauth2token_in_codeharbor_links.rb
@@ -0,0 +1,7 @@
+class AddPushUrlRenameOauth2tokenInCodeharborLinks < ActiveRecord::Migration[5.2]
+ def change
+ add_column :codeharbor_links, :push_url, :string
+ add_column :codeharbor_links, :check_uuid_url, :string
+ rename_column :codeharbor_links, :oauth2token, :api_key
+ end
+end
diff --git a/db/migrate/20190830142809_add_uuid_to_exercise.rb b/db/migrate/20190830142809_add_uuid_to_exercise.rb
new file mode 100644
index 00000000..4dfa6e5c
--- /dev/null
+++ b/db/migrate/20190830142809_add_uuid_to_exercise.rb
@@ -0,0 +1,5 @@
+class AddUuidToExercise < ActiveRecord::Migration[5.2]
+ def change
+ add_column :exercises, :uuid, :uuid
+ end
+end
diff --git a/db/migrate/20191008163045_add_unpublished_to_exercise.rb b/db/migrate/20191008163045_add_unpublished_to_exercise.rb
new file mode 100644
index 00000000..56bc215d
--- /dev/null
+++ b/db/migrate/20191008163045_add_unpublished_to_exercise.rb
@@ -0,0 +1,5 @@
+class AddUnpublishedToExercise < ActiveRecord::Migration[5.2]
+ def change
+ add_column :exercises, :unpublished, :boolean, default: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index feb96e00..5c3ffdf7 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: 2019_02_13_131802) do
+ActiveRecord::Schema.define(version: 2019_10_08_163045) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -28,12 +28,14 @@ ActiveRecord::Schema.define(version: 2019_02_13_131802) do
t.index ["user_type", "user_id"], name: "index_anomaly_notifications_on_user_type_and_user_id"
end
- create_table "code_harbor_links", force: :cascade do |t|
- t.string "oauth2token", limit: 255
+ create_table "codeharbor_links", force: :cascade do |t|
+ t.string "api_key", limit: 255
t.datetime "created_at"
t.datetime "updated_at"
t.integer "user_id"
- t.index ["user_id"], name: "index_code_harbor_links_on_user_id"
+ t.string "push_url"
+ t.string "check_uuid_url"
+ t.index ["user_id"], name: "index_codeharbor_links_on_user_id"
end
create_table "comments", force: :cascade do |t|
@@ -150,6 +152,8 @@ ActiveRecord::Schema.define(version: 2019_02_13_131802) do
t.boolean "allow_file_creation"
t.boolean "allow_auto_completion", default: false
t.integer "expected_difficulty", default: 1
+ t.uuid "uuid"
+ t.boolean "unpublished", default: false
t.index ["id"], name: "index_exercises_on_id"
end
diff --git a/lib/file_io.rb b/lib/file_io.rb
new file mode 100644
index 00000000..b19ef187
--- /dev/null
+++ b/lib/file_io.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# stolen from: https://makandracards.com/makandra/50526-fileio-writing-strings-as-carrierwave-uploads
+class FileIO < StringIO
+ def initialize(stream, filename)
+ super(stream)
+ @original_filename = filename
+ end
+
+ attr_reader :original_filename
+end
diff --git a/lib/tasks/export_public_exercises.rake b/lib/tasks/export_public_exercises.rake
new file mode 100644
index 00000000..162c36ed
--- /dev/null
+++ b/lib/tasks/export_public_exercises.rake
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+namespace :export_exercises do
+ desc 'exports all public exercises to codeharbor'
+ task :public, [:codeharbor_link_id] => [:environment] do |_, args|
+ codeharbor_link = CodeharborLink.find(args.codeharbor_link_id)
+ successful_exports = []
+ failed_exports = []
+ Exercise.where(public: true).each do |exercise|
+ puts "Exporting exercise \##{exercise.id}"
+ error = ExerciseService::PushExternal.call(
+ zip: ProformaService::ExportTask.call(exercise: exercise),
+ codeharbor_link: codeharbor_link
+ )
+ if error.nil?
+ successful_exports << exercise.id
+ puts "Successfully exported exercise\# #{exercise.id}"
+ else
+ failed_exports << exercise.id
+ puts "An error occured during export of exercise\# #{exercise.id}: #{error}"
+ end
+ end
+
+ puts "successful exports: count: #{successful_exports.count} \nlist: #{successful_exports}"
+ puts "failed exports: count: #{failed_exports.count} \nlist: #{failed_exports}"
+ end
+end
diff --git a/spec/controllers/codeharbor_links_controller_spec.rb b/spec/controllers/codeharbor_links_controller_spec.rb
new file mode 100644
index 00000000..b057c32f
--- /dev/null
+++ b/spec/controllers/codeharbor_links_controller_spec.rb
@@ -0,0 +1,93 @@
+require 'rails_helper'
+
+describe CodeharborLinksController do
+ let(:user) { FactoryBot.create(:teacher) }
+ before(:each) { allow(controller).to receive(:current_user).and_return(user) }
+
+ describe 'GET #new' do
+ before { get :new }
+
+ expect_status(200)
+ end
+
+ describe 'GET #edit' do
+ let(:codeharbor_link) { FactoryBot.create(:codeharbor_link, user: user) }
+
+ before { get :edit, params: { id: codeharbor_link.id } }
+
+ expect_status(200)
+ end
+
+ describe 'POST #create' do
+ let(:post_request) { post :create, params: {codeharbor_link: params} }
+ let(:params) { {push_url: 'http://foo.bar/push', check_uuid_url: 'http://foo.bar/check', api_key: 'api_key'} }
+
+ it 'creates a codeharbor_link' do
+ expect { post_request }.to change(CodeharborLink, :count).by(1)
+ end
+
+ it 'redirects to user show' do
+ expect(post_request).to redirect_to(user)
+ end
+
+ context 'with invalid params' do
+ let(:params) { {push_url: '', check_uuid_url: '', api_key: ''} }
+
+ it 'does not create a codeharbor_link' do
+ expect { post_request }.not_to change(CodeharborLink, :count)
+ end
+
+ it 'redirects to user show' do
+ post_request
+ expect(response).to render_template(:new)
+ end
+ end
+ end
+
+ describe 'PUT #update' do
+ let(:codeharbor_link) { FactoryBot.create(:codeharbor_link, user: user) }
+ let(:put_request) { patch :update, params: {id: codeharbor_link.id, codeharbor_link: params} }
+ let(:params) { {push_url: 'http://foo.bar/push', check_uuid_url: 'http://foo.bar/check', api_key: 'api_key'} }
+
+ it 'updates push_url' do
+ expect { put_request }.to change { codeharbor_link.reload.push_url }.to('http://foo.bar/push')
+ end
+ it 'updates check_uuid_url' do
+ expect { put_request }.to change { codeharbor_link.reload.check_uuid_url }.to('http://foo.bar/check')
+ end
+ it 'updates api_key' do
+ expect { put_request }.to change { codeharbor_link.reload.api_key }.to('api_key')
+ end
+
+ it 'redirects to user show' do
+ expect(put_request).to redirect_to(user)
+ end
+
+ context 'with invalid params' do
+ let(:params) { {push_url: '', check_uuid_url: '', api_key: ''} }
+
+ it 'does not change codeharbor_link' do
+ expect { put_request }.not_to change { codeharbor_link.reload.attributes }
+ end
+
+ it 'redirects to user show' do
+ put_request
+ expect(response).to render_template(:edit)
+ end
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ let!(:codeharbor_link) { FactoryBot.create(:codeharbor_link, user: user) }
+ let(:destroy_request) { delete :destroy, params: {id: codeharbor_link.id} }
+
+ it 'deletes codeharbor_link' do
+ expect { destroy_request }.to change(CodeharborLink, :count).by(-1)
+ end
+
+ it 'redirects to user show' do
+ expect(destroy_request).to redirect_to(user)
+ end
+ end
+end
+
diff --git a/spec/controllers/exercises_controller_spec.rb b/spec/controllers/exercises_controller_spec.rb
index 424a5290..7a687e38 100644
--- a/spec/controllers/exercises_controller_spec.rb
+++ b/spec/controllers/exercises_controller_spec.rb
@@ -313,4 +313,206 @@ describe ExercisesController do
expect_template(:edit)
end
end
+
+ RSpec::Matchers.define_negated_matcher :not_include, :include
+ # RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = 99999
+
+ describe 'POST #export_external_check' do
+ render_views
+
+ let(:post_request) { post :export_external_check, params: { id: exercise.id } }
+ let!(:codeharbor_link) { FactoryBot.create(:codeharbor_link, user: user) }
+ let(:external_check_hash) { {message: message, exercise_found: true, update_right: update_right, error: error} }
+ let(:message) { 'message' }
+ let(:update_right) { true }
+ let(:error) {}
+
+ before { allow(ExerciseService::CheckExternal).to receive(:call).with(uuid: exercise.uuid, codeharbor_link: codeharbor_link).and_return(external_check_hash) }
+
+ it 'renders the correct contents as json' do
+ post_request
+ expect(JSON.parse(response.body).symbolize_keys[:message]).to eq('message')
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(
+ include('button').and(include('Abort').and(include('Export')))
+ )
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(
+ not_include('Retry').and(not_include('Hide'))
+ )
+ end
+
+ context 'when there is an error' do
+ let(:error) { 'error' }
+
+ it 'renders the correct contents as json' do
+ post_request
+ expect(JSON.parse(response.body).symbolize_keys[:message]).to eq('message')
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(
+ include('button').and(include('Abort')).and(include('Retry'))
+ )
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(
+ not_include('Export').and(not_include('Hide'))
+ )
+ end
+ end
+
+ context 'when update_right is false' do
+ let(:update_right) { false }
+
+ it 'renders the correct contents as json' do
+ post_request
+ expect(JSON.parse(response.body).symbolize_keys[:message]).to eq('message')
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(
+ include('button').and(include('Abort'))
+ )
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(
+ not_include('Retry').and(not_include('Export')).and(not_include('Hide'))
+ )
+ end
+ end
+ end
+
+ describe '#export_external_confirm' do
+ render_views
+
+ let!(:codeharbor_link) { FactoryBot.create(:codeharbor_link, user: user) }
+ let(:post_request) { post :export_external_confirm, params: {id: exercise.id, codeharbor_link: codeharbor_link.id} }
+ let(:error) {}
+ let(:zip) { 'zip' }
+
+ before do
+ allow(ProformaService::ExportTask).to receive(:call).with(exercise: exercise).and_return(zip)
+ allow(ExerciseService::PushExternal).to receive(:call).with(zip: zip, codeharbor_link: codeharbor_link).and_return(error)
+ end
+
+ it 'renders correct response' do
+ post_request
+
+ expect(response).to have_http_status(:success)
+ expect(JSON.parse(response.body).symbolize_keys[:message]).to(include('successfully exported'))
+ expect(JSON.parse(response.body).symbolize_keys[:status]).to(eql('success'))
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(include('button').and(include('Close')))
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(not_include('Retry').and(not_include('Abort')))
+ end
+
+ context 'when an error occurs' do
+ let(:error) { 'exampleerror' }
+
+ it 'renders correct response' do
+ post_request
+ expect(response).to have_http_status(:success)
+ expect(JSON.parse(response.body).symbolize_keys[:message]).to(include('failed').and(include('exampleerror')))
+ expect(JSON.parse(response.body).symbolize_keys[:status]).to(eql('fail'))
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(include('button').and(include('Retry')).and(include('Close')))
+ expect(JSON.parse(response.body).symbolize_keys[:actions]).to(not_include('Abort'))
+ end
+ end
+ end
+
+ describe '#import_uuid_check' do
+ let(:exercise) { FactoryBot.create(:dummy, uuid: SecureRandom.uuid) }
+ let!(:codeharbor_link) { FactoryBot.create(:codeharbor_link, user: user) }
+ let(:uuid) { exercise.reload.uuid }
+ let(:post_request) { post :import_uuid_check, params: {uuid: uuid} }
+ let(:headers) { {'Authorization' => "Bearer #{codeharbor_link.api_key}"} }
+
+ before { request.headers.merge! headers }
+
+ it 'renders correct response' do
+ post_request
+ expect(response).to have_http_status(:success)
+
+ expect(JSON.parse(response.body).symbolize_keys[:exercise_found]).to be true
+ expect(JSON.parse(response.body).symbolize_keys[:update_right]).to be true
+ end
+
+ context 'when api_key is incorrect' do
+ let(:headers) { {'Authorization' => 'Bearer XXXXXX'} }
+
+ it 'renders correct response' do
+ post_request
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when the user cannot update the exercise' do
+ let(:codeharbor_link) { FactoryBot.create(:codeharbor_link, api_key: 'anotherkey') }
+
+ it 'renders correct response' do
+ post_request
+ expect(response).to have_http_status(:success)
+
+ expect(JSON.parse(response.body).symbolize_keys[:exercise_found]).to be true
+ expect(JSON.parse(response.body).symbolize_keys[:update_right]).to be false
+ end
+ end
+
+ context 'when the searched exercise does not exist' do
+ let(:uuid) { 'anotheruuid' }
+
+ it 'renders correct response' do
+ post_request
+ expect(response).to have_http_status(:success)
+
+ expect(JSON.parse(response.body).symbolize_keys[:exercise_found]).to be false
+ end
+ end
+ end
+
+ describe 'POST #import_exercise' do
+ let(:codeharbor_link) { FactoryBot.create(:codeharbor_link, user: user) }
+ let!(:imported_exercise) { FactoryBot.create(:fibonacci) }
+ let(:post_request) { post :import_exercise, body: zip_file_content }
+ let(:zip_file_content) { 'zipped task xml' }
+ let(:headers) { {'Authorization' => "Bearer #{codeharbor_link.api_key}"} }
+
+ before do
+ request.headers.merge! headers
+ allow(ProformaService::Import).to receive(:call).and_return(imported_exercise)
+ end
+
+ it 'responds with correct status code' do
+ post_request
+ expect(response).to have_http_status(:created)
+ end
+
+ it 'calls service' do
+ post_request
+ expect(ProformaService::Import).to have_received(:call).with(zip: be_a(Tempfile).and(has_content(zip_file_content)), user: user)
+ end
+
+ context 'when import fails with ProformaError' do
+ before { allow(ProformaService::Import).to receive(:call).and_raise(Proforma::PreImportValidationError) }
+
+ it 'responds with correct status code' do
+ post_request
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+
+ context 'when import fails with ExerciseNotOwned' do
+ before { allow(ProformaService::Import).to receive(:call).and_raise(Proforma::ExerciseNotOwned) }
+
+ it 'responds with correct status code' do
+ post_request
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when import fails due to another error' do
+ before { allow(ProformaService::Import).to receive(:call).and_raise(StandardError) }
+
+ it 'responds with correct status code' do
+ post_request
+ expect(response).to have_http_status(:internal_server_error)
+ end
+ end
+
+ context 'when the imported exercise is invalid' do
+ before { allow(ProformaService::Import).to receive(:call) { imported_exercise.tap { |e| e.files = [] }.tap { |e| e.title = nil } } }
+
+ it 'responds with correct status code' do
+ expect { post_request }.not_to(change { imported_exercise.reload.files.count })
+ end
+ end
+ end
end
diff --git a/spec/factories/code_ocean/file.rb b/spec/factories/code_ocean/file.rb
index 186ad1d1..64245e97 100644
--- a/spec/factories/code_ocean/file.rb
+++ b/spec/factories/code_ocean/file.rb
@@ -10,6 +10,24 @@ module CodeOcean
name { SecureRandom.hex }
read_only { false }
role { 'main_file' }
+
+ trait(:image) do
+ association :file_type, factory: :dot_png
+ name { 'poster' }
+ native_file { Rack::Test::UploadedFile.new(Rails.root.join('db', 'seeds', 'audio_video', 'poster.png'), 'image/png') }
+ end
+ end
+
+ factory :test_file, class: CodeOcean::File do
+ content { '' }
+ association :context, factory: :dummy
+ association :file_type, factory: :dot_rb
+ hidden { true }
+ name { SecureRandom.hex }
+ read_only { true }
+ role { 'teacher_defined_test' }
+ feedback_message { 'feedback_message' }
+ weight { 1 }
end
end
end
diff --git a/spec/factories/codeharbor_link.rb b/spec/factories/codeharbor_link.rb
new file mode 100644
index 00000000..c960eb9a
--- /dev/null
+++ b/spec/factories/codeharbor_link.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :codeharbor_link do
+ user { build(:teacher) }
+ push_url { 'http://push.url' }
+ check_uuid_url { 'http://check-uuid.url' }
+ sequence(:api_key) { |n| "api_key#{n}" }
+ end
+end
diff --git a/spec/models/codeharbor_link_spec.rb b/spec/models/codeharbor_link_spec.rb
new file mode 100644
index 00000000..df1ddba4
--- /dev/null
+++ b/spec/models/codeharbor_link_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+
+describe CodeharborLink do
+ it { is_expected.to validate_presence_of(:check_uuid_url) }
+ it { is_expected.to validate_presence_of(:push_url) }
+ it { is_expected.to validate_presence_of(:api_key) }
+ it { is_expected.to belong_to(:user) }
+
+ describe '#to_s' do
+ subject { codeharbor_link.to_s }
+
+ let(:codeharbor_link) { FactoryBot.create(:codeharbor_link) }
+
+ it { is_expected.to eql codeharbor_link.id.to_s }
+ end
+end
diff --git a/spec/models/exercise_spec.rb b/spec/models/exercise_spec.rb
index edfbd1cc..3576d3a5 100644
--- a/spec/models/exercise_spec.rb
+++ b/spec/models/exercise_spec.rb
@@ -22,10 +22,6 @@ describe Exercise do
expect(exercise.errors[:description]).to be_present
end
- it 'validates the presence of an execution environment' do
- expect(exercise.errors[:execution_environment_id]).to be_present
- end
-
it 'validates the presence of the public flag' do
expect(exercise.errors[:public]).to be_present
exercise.update(public: false)
@@ -45,6 +41,30 @@ describe Exercise do
expect(exercise.errors[:user_type]).to be_present
end
+ context 'when exercise is unpublished' do
+ subject { FactoryBot.build(:dummy, unpublished: true) }
+
+ it { is_expected.not_to validate_presence_of(:execution_environment) }
+ end
+
+ context 'when exercise is not unpublished' do
+ subject { FactoryBot.build(:dummy, unpublished: false) }
+
+ it { is_expected.to validate_presence_of(:execution_environment) }
+ end
+
+ context 'with uuid' do
+ subject { FactoryBot.build(:dummy, uuid: SecureRandom.uuid) }
+
+ it { is_expected.to validate_uniqueness_of(:uuid).case_insensitive }
+ end
+
+ context 'without uuid' do
+ subject { FactoryBot.build(:dummy, uuid: nil) }
+
+ it { is_expected.not_to validate_uniqueness_of(:uuid) }
+ end
+
describe '#average_percentage' do
let(:exercise) { FactoryBot.create(:fibonacci) }
diff --git a/spec/policies/codeharbor_link_policy_spec.rb b/spec/policies/codeharbor_link_policy_spec.rb
new file mode 100644
index 00000000..dcb6ace3
--- /dev/null
+++ b/spec/policies/codeharbor_link_policy_spec.rb
@@ -0,0 +1,45 @@
+require 'rails_helper'
+
+describe CodeharborLinkPolicy do
+ subject(:policy) { described_class }
+
+ let(:codeharbor_link) { FactoryBot.create(:codeharbor_link) }
+
+ %i[index? show?].each do |action|
+ permissions(action) do
+ it 'does not grant access any user' do
+ %i[external_user teacher admin].each do |factory_name|
+ expect(policy).not_to permit(FactoryBot.create(factory_name), codeharbor_link)
+ end
+ end
+ end
+ end
+
+ %i[new? create?].each do |action|
+ permissions(action) do
+ it 'grants access to teachers' do
+ %i[teacher admin].each do |factory_name|
+ expect(policy).to permit(FactoryBot.create(factory_name), codeharbor_link)
+ end
+ end
+
+ it 'does not grant access to all other users' do
+ expect(policy).not_to permit(FactoryBot.create(:external_user), codeharbor_link)
+ end
+ end
+ end
+
+ %i[destroy? edit? update?].each do |action|
+ permissions(action) do
+ it 'grants access to the owner of the link' do
+ expect(policy).to permit(codeharbor_link.user, codeharbor_link)
+ end
+
+ it 'does not grant access to arbitrary users' do
+ %i[external_user admin teacher].each do |factory_name|
+ expect(policy).not_to permit(FactoryBot.create(factory_name), codeharbor_link)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/policies/exercise_policy_spec.rb b/spec/policies/exercise_policy_spec.rb
index 4b4acd4e..4de6a9f4 100644
--- a/spec/policies/exercise_policy_spec.rb
+++ b/spec/policies/exercise_policy_spec.rb
@@ -4,7 +4,7 @@ describe ExercisePolicy do
subject { described_class }
let(:exercise) { FactoryBot.build(:dummy) }
-
+
permissions :batch_update? do
it 'grants access to admins only' do
expect(subject).to permit(FactoryBot.build(:admin), exercise)
@@ -30,7 +30,7 @@ let(:exercise) { FactoryBot.build(:dummy) }
end
end
- [:clone?, :destroy?, :edit?, :update?].each do |action|
+ [:clone?, :destroy?, :edit?, :update?, :export_external_check?, :export_external_confirm?].each do |action|
permissions(action) do
it 'grants access to admins' do
expect(subject).to permit(FactoryBot.build(:admin), exercise)
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index faf011d2..8de4fc82 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -39,3 +39,12 @@ RSpec.configure do |config|
# https://relishapp.com/rspec/rspec-rails/docs
config.infer_spec_type_from_file_location!
end
+
+Shoulda::Matchers.configure do |config|
+ config.integrate do |with|
+ with.test_framework :rspec
+ with.library :rails
+ end
+end
+
+WebMock.allow_net_connect!
diff --git a/spec/services/exercise_service/check_external_spec.rb b/spec/services/exercise_service/check_external_spec.rb
new file mode 100644
index 00000000..f5ee4b97
--- /dev/null
+++ b/spec/services/exercise_service/check_external_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ExerciseService::CheckExternal do
+ describe '.new' do
+ subject(:export_service) { described_class.new(uuid: uuid, codeharbor_link: codeharbor_link) }
+
+ let(:uuid) { SecureRandom.uuid }
+ let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) }
+
+ it 'assigns uuid' do
+ expect(export_service.instance_variable_get(:@uuid)).to be uuid
+ end
+
+ it 'assigns codeharbor_link' do
+ expect(export_service.instance_variable_get(:@codeharbor_link)).to be codeharbor_link
+ end
+ end
+
+ describe '#execute' do
+ subject(:check_external_service) { described_class.call(uuid: uuid, codeharbor_link: codeharbor_link) }
+
+ let(:uuid) { SecureRandom.uuid }
+ let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) }
+ let(:response) { {}.to_json }
+
+ before { stub_request(:post, codeharbor_link.check_uuid_url).to_return(body: response) }
+
+ it 'calls the correct url' do
+ expect(check_external_service).to have_requested(:post, codeharbor_link.check_uuid_url)
+ end
+
+ it 'submits the correct headers' do
+ expect(check_external_service).to have_requested(:post, codeharbor_link.check_uuid_url)
+ .with(headers: {content_type: 'application/json', authorization: "Bearer #{codeharbor_link.api_key}"})
+ end
+
+ it 'submits the correct body' do
+ expect(check_external_service).to have_requested(:post, codeharbor_link.check_uuid_url)
+ .with(body: {uuid: uuid}.to_json)
+ end
+
+ context 'when response contains a JSON with expected keys' do
+ let(:response) { {exercise_found: true, update_right: true}.to_json }
+
+ it 'returns the correct hash' do
+ expect(check_external_service).to eql(error: false, message: I18n.t('exercises.export_codeharbor.check.exercise_found'), exercise_found: true, update_right: true)
+ end
+
+ context 'with exercise_found: false and no update_right' do
+ let(:response) { {exercise_found: false}.to_json }
+
+ it 'returns the correct hash' do
+ expect(check_external_service).to eql(error: false, message: I18n.t('exercises.export_codeharbor.check.no_exercise'), exercise_found: false)
+ end
+ end
+
+ context 'with exercise_found: true and update_right: false' do
+ let(:response) { {exercise_found: true, update_right: false}.to_json }
+
+ it 'returns the correct hash' do
+ expect(check_external_service).to eql(error: false, message: I18n.t('exercises.export_codeharbor.check.exercise_found_no_right'), exercise_found: true, update_right: false)
+ end
+ end
+ end
+
+ context 'when response does not contain JSON' do
+ let(:response) { 'foo' }
+
+ it 'returns the correct hash' do
+ expect(check_external_service).to eql(error: true, message: I18n.t('exercises.export_codeharbor.error'))
+ end
+ end
+
+ context 'when the request fails' do
+ before { allow(Faraday).to receive(:new).and_raise(Faraday::Error, 'error') }
+
+ it 'returns the correct hash' do
+ expect(check_external_service).to eql(error: true, message: I18n.t('exercises.export_codeharbor.error'))
+ end
+ end
+ end
+end
diff --git a/spec/services/exercise_service/push_external_spec.rb b/spec/services/exercise_service/push_external_spec.rb
new file mode 100644
index 00000000..20c07a0a
--- /dev/null
+++ b/spec/services/exercise_service/push_external_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe ExerciseService::PushExternal do
+ describe '.new' do
+ subject(:push_external) { described_class.new(zip: zip, codeharbor_link: codeharbor_link) }
+
+ let(:zip) { ProformaService::ExportTask.call(exercise: FactoryBot.build(:dummy)) }
+ let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) }
+
+ it 'assigns zip' do
+ expect(push_external.instance_variable_get(:@zip)).to be zip
+ end
+
+ it 'assigns codeharbor_link' do
+ expect(push_external.instance_variable_get(:@codeharbor_link)).to be codeharbor_link
+ end
+ end
+
+ describe '#execute' do
+ subject(:push_external) { described_class.call(zip: zip, codeharbor_link: codeharbor_link) }
+
+ let(:zip) { ProformaService::ExportTask.call(exercise: FactoryBot.build(:dummy)) }
+ let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) }
+ let(:status) { 200 }
+ let(:response) { '' }
+
+ before { stub_request(:post, codeharbor_link.push_url).to_return(status: status, body: response) }
+
+ it 'calls the correct url' do
+ expect(push_external).to have_requested(:post, codeharbor_link.push_url)
+ end
+
+ it 'submits the correct headers' do
+ expect(push_external).to have_requested(:post, codeharbor_link.push_url)
+ .with(headers: {content_type: 'application/zip',
+ authorization: "Bearer #{codeharbor_link.api_key}",
+ content_length: zip.string.length})
+ end
+
+ it 'submits the correct body' do
+ expect(push_external).to have_requested(:post, codeharbor_link.push_url)
+ .with(body: zip.string)
+ end
+
+ context 'when response status is success' do
+ it { is_expected.to be nil }
+
+ context 'when response status is 500' do
+ let(:status) { 500 }
+ let(:response) { 'an error occured' }
+
+ it { is_expected.to be response }
+ end
+ end
+
+ context 'when an error occurs' do
+ before { allow(Faraday).to receive(:new).and_raise(StandardError) }
+
+ it { is_expected.not_to be nil }
+ end
+ end
+end
diff --git a/spec/services/proforma_service/convert_exercise_to_task_spec.rb b/spec/services/proforma_service/convert_exercise_to_task_spec.rb
new file mode 100644
index 00000000..f6fe6b7d
--- /dev/null
+++ b/spec/services/proforma_service/convert_exercise_to_task_spec.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe ProformaService::ConvertExerciseToTask do
+ describe '.new' do
+ subject(:convert_to_task) { described_class.new(exercise: exercise) }
+
+ let(:exercise) { FactoryBot.build(:dummy) }
+
+ it 'assigns exercise' do
+ expect(convert_to_task.instance_variable_get(:@exercise)).to be exercise
+ end
+ end
+
+ describe '#execute' do
+ subject(:task) { convert_to_task.execute }
+
+ let(:convert_to_task) { described_class.new(exercise: exercise) }
+ let(:exercise) do
+ FactoryBot.create(:dummy,
+ instructions: 'instruction',
+ uuid: SecureRandom.uuid,
+ files: files + tests)
+ end
+ let(:files) { [] }
+ let(:tests) { [] }
+
+ it 'creates a task with all basic attributes' do
+ expect(task).to have_attributes(
+ title: exercise.title,
+ description: exercise.description,
+ internal_description: exercise.instructions,
+ # proglang: {
+ # name: exercise.execution_environment.language,
+ # version: exercise.execution_environment.version
+ # },
+ uuid: exercise.uuid,
+ language: described_class::DEFAULT_LANGUAGE,
+ # parent_uuid: exercise.clone_relations.first&.origin&.uuid,
+ files: [],
+ tests: [],
+ model_solutions: []
+ )
+ end
+
+ context 'when exercise has a mainfile' do
+ let(:files) { [file] }
+ let(:file) { FactoryBot.build(:file) }
+
+ it 'creates a task-file with the correct attributes' do
+ expect(task.files.first).to have_attributes(
+ id: file.id,
+ content: file.content,
+ filename: file.name_with_extension,
+ used_by_grader: true,
+ usage_by_lms: 'edit',
+ visible: 'yes',
+ binary: false,
+ internal_description: 'main_file'
+ )
+ end
+ end
+
+ context 'when exercise has a regular file' do
+ let(:files) { [file] }
+ let(:file) { FactoryBot.build(:file, role: 'regular_file', hidden: hidden, read_only: read_only) }
+ let(:hidden) { true }
+ let(:read_only) { true }
+
+ it 'creates a task-file with the correct attributes' do
+ expect(task.files.first).to have_attributes(
+ id: file.id,
+ content: file.content,
+ filename: file.name_with_extension,
+ used_by_grader: true,
+ usage_by_lms: 'display',
+ visible: 'no',
+ binary: false,
+ internal_description: 'regular_file'
+ )
+ end
+
+ context 'when file is not hidden' do
+ let(:hidden) { false }
+
+ it 'creates a task-file with the correct attributes' do
+ expect(task.files.first).to have_attributes(visible: 'yes')
+ end
+ end
+
+ context 'when file is not read_only' do
+ let(:read_only) { false }
+
+ it 'creates a task-file with the correct attributes' do
+ expect(task.files.first).to have_attributes(usage_by_lms: 'edit')
+ end
+ end
+
+ context 'when file has an attachment' do
+ let(:file) { FactoryBot.build(:file, :image, role: 'regular_file') }
+
+ it 'creates a task-file with the correct attributes' do
+ expect(task.files.first).to have_attributes(
+ used_by_grader: false,
+ binary: true,
+ mimetype: 'image/png'
+ )
+ end
+ end
+ end
+
+ context 'when exercise has a file with role reference implementation' do
+ let(:files) { [file] }
+ let(:file) { FactoryBot.build(:file, role: 'reference_implementation') }
+
+ it 'creates a task with one model-solution' do
+ expect(task.model_solutions).to have(1).item
+ end
+
+ it 'creates a model-solution with one file' do
+ expect(task.model_solutions.first).to have_attributes(
+ id: "ms-#{file.id}",
+ files: have(1).item
+ )
+ end
+
+ it 'creates a model-solution with one file with correct attributes' do
+ expect(task.model_solutions.first.files.first).to have_attributes(
+ id: file.id,
+ content: file.content,
+ filename: file.name_with_extension,
+ used_by_grader: false,
+ usage_by_lms: 'display',
+ visible: 'delayed',
+ binary: false,
+ internal_description: 'reference_implementation'
+ )
+ end
+ end
+
+ context 'when exercise has multiple files with role reference implementation' do
+ let(:files) { FactoryBot.build_list(:file, 2, role: 'reference_implementation') }
+
+ it 'creates a task with two model-solutions' do
+ expect(task.model_solutions).to have(2).items
+ end
+ end
+
+ context 'when exercise has a test' do
+ let(:tests) { [test_file] }
+ let(:test_file) { FactoryBot.build(:test_file) }
+ # let(:file) { FactoryBot.build(:codeharbor_test_file) }
+
+ it 'creates a task with one test' do
+ expect(task.tests).to have(1).item
+ end
+
+ it 'creates a test with one file' do
+ expect(task.tests.first).to have_attributes(
+ id: test_file.id,
+ title: test_file.name,
+ files: have(1).item,
+ meta_data: {'feedback-message' => test_file.feedback_message}
+ )
+ end
+
+ it 'creates a test with one file with correct attributes' do
+ expect(task.tests.first.files.first).to have_attributes(
+ id: test_file.id,
+ content: test_file.content,
+ filename: test_file.name_with_extension,
+ used_by_grader: true,
+ visible: 'no',
+ binary: false,
+ internal_description: 'teacher_defined_test'
+ )
+ end
+
+ context 'when exercise_file is not hidden' do
+ let(:test_file) { FactoryBot.create(:test_file, hidden: false) }
+
+ it 'creates the test file with the correct attribute' do
+ expect(task.tests.first.files.first).to have_attributes(visible: 'yes')
+ end
+ end
+ end
+
+ context 'when exercise has multiple tests' do
+ let(:tests) { FactoryBot.build_list(:test_file, 2) }
+
+ it 'creates a task with two tests' do
+ expect(task.tests).to have(2).items
+ end
+ end
+ end
+end
diff --git a/spec/services/proforma_service/convert_task_to_exercise_spec.rb b/spec/services/proforma_service/convert_task_to_exercise_spec.rb
new file mode 100644
index 00000000..ad41de3e
--- /dev/null
+++ b/spec/services/proforma_service/convert_task_to_exercise_spec.rb
@@ -0,0 +1,393 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ProformaService::ConvertTaskToExercise do
+ describe '.new' do
+ subject(:convert_to_exercise_service) { described_class.new(task: task, user: user, exercise: exercise) }
+
+ let(:task) { Proforma::Task.new }
+ let(:user) { FactoryBot.build(:teacher) }
+ let(:exercise) { FactoryBot.build(:dummy) }
+
+ it 'assigns task' do
+ expect(convert_to_exercise_service.instance_variable_get(:@task)).to be task
+ end
+
+ it 'assigns user' do
+ expect(convert_to_exercise_service.instance_variable_get(:@user)).to be user
+ end
+
+ it 'assigns exercise' do
+ expect(convert_to_exercise_service.instance_variable_get(:@exercise)).to be exercise
+ end
+ end
+
+ describe '#execute' do
+ subject(:convert_to_exercise_service) { described_class.call(task: task, user: user, exercise: exercise) }
+
+ before { FactoryBot.create(:dot_txt) }
+
+ let(:task) do
+ Proforma::Task.new(
+ title: 'title',
+ description: 'description',
+ internal_description: 'internal_description',
+ proglang: {name: 'proglang-name', version: 'proglang-version'},
+ uuid: 'uuid',
+ parent_uuid: 'parent_uuid',
+ language: 'language',
+ model_solutions: model_solutions,
+ files: files,
+ tests: tests
+ )
+ end
+ let(:user) { FactoryBot.create(:teacher) }
+ let(:files) { [] }
+ let(:tests) { [] }
+ let(:model_solutions) { [] }
+ let(:exercise) {}
+
+ it 'creates an exercise with the correct attributes' do
+ expect(convert_to_exercise_service).to have_attributes(
+ title: 'title',
+ description: 'description',
+ instructions: 'internal_description',
+ execution_environment: be_blank,
+ uuid: be_blank,
+ unpublished: true,
+ user: user,
+ files: be_empty
+ )
+ end
+
+ context 'when task has a file' do
+ let(:files) { [file] }
+ let(:file) do
+ Proforma::TaskFile.new(
+ id: 'id',
+ content: content,
+ filename: "#{path}filename.txt",
+ used_by_grader: 'used_by_grader',
+ visible: 'yes',
+ usage_by_lms: usage_by_lms,
+ binary: binary,
+ internal_description: 'regular_file',
+ mimetype: mimetype
+ )
+ end
+ let(:usage_by_lms) { 'display' }
+ let(:mimetype) { 'mimetype' }
+ let(:binary) { false }
+ let(:content) { 'content' }
+ let(:path) {}
+
+ it 'creates an exercise with a file that has the correct attributes' do
+ expect(convert_to_exercise_service.files.first).to have_attributes(
+ content: 'content',
+ name: 'filename',
+ role: 'regular_file',
+ hidden: false,
+ read_only: true,
+ file_type: be_a(FileType).and(have_attributes(file_extension: '.txt')),
+ path: nil
+ )
+ end
+
+ it 'creates a new Exercise on save' do
+ expect { convert_to_exercise_service.save! }.to change(Exercise, :count).by(1)
+ end
+
+ context 'when path is folder/' do
+ let(:path) { 'folder/' }
+
+ it 'creates an exercise with a file that has the correct path' do
+ expect(convert_to_exercise_service.files.first).to have_attributes(path: 'folder')
+ end
+ end
+
+ context 'when path is ./' do
+ let(:path) { './' }
+
+ it 'creates an exercise with a file that has the correct path' do
+ expect(convert_to_exercise_service.files.first).to have_attributes(path: nil)
+ end
+ end
+
+ context 'when file is very large' do
+ let(:content) { 'test' * 10**5 }
+
+ it 'creates an exercise with a file that has the correct attributes' do
+ expect(convert_to_exercise_service.files.first).to have_attributes(content: content)
+ end
+ end
+
+ context 'when file is binary' do
+ let(:mimetype) { 'image/png' }
+ let(:binary) { true }
+
+ it 'creates an exercise with a file with attachment and the correct attributes' do
+ expect(convert_to_exercise_service.files.first.native_file).to be_present
+ end
+ end
+
+ context 'when usage_by_lms is edit' do
+ let(:usage_by_lms) { 'edit' }
+
+ it 'creates an exercise with a file with correct attributes' do
+ expect(convert_to_exercise_service.files.first).to have_attributes(read_only: false)
+ end
+ end
+
+ context 'when file is a model-solution-placeholder (needed by proforma until issue #5 is resolved)' do
+ let(:file) { Proforma::TaskFile.new(id: 'ms-placeholder-file') }
+
+ it 'leaves exercise_files empty' do
+ expect(convert_to_exercise_service.files).to be_empty
+ end
+ end
+ end
+
+ context 'when task has a model-solution' do
+ let(:model_solutions) { [model_solution] }
+ let(:model_solution) do
+ Proforma::ModelSolution.new(
+ id: 'ms-id',
+ files: ms_files
+ )
+ end
+ let(:ms_files) { [ms_file] }
+ let(:ms_file) do
+ Proforma::TaskFile.new(
+ id: 'ms-file',
+ content: 'content',
+ filename: 'filename.txt',
+ used_by_grader: 'used_by_grader',
+ visible: 'yes',
+ usage_by_lms: 'display',
+ binary: false,
+ internal_description: 'reference_implementation'
+ )
+ end
+
+ it 'creates an exercise with a file with role Reference Implementation' do
+ expect(convert_to_exercise_service.files.first).to have_attributes(
+ role: 'reference_implementation'
+ )
+ end
+
+ context 'when task has two model-solutions' do
+ let(:model_solutions) { [model_solution, model_solution2] }
+ let(:model_solution2) do
+ Proforma::ModelSolution.new(
+ id: 'ms-id-2',
+ files: ms_files_2
+ )
+ end
+ let(:ms_files_2) { [ms_file_2] }
+ let(:ms_file_2) do
+ Proforma::TaskFile.new(
+ id: 'ms-file-2',
+ content: 'content',
+ filename: 'filename.txt',
+ used_by_grader: 'used_by_grader',
+ visible: 'yes',
+ usage_by_lms: 'display',
+ binary: false,
+ internal_description: 'reference_implementation'
+ )
+ end
+
+ it 'creates an exercise with two files with role Reference Implementation' do
+ expect(convert_to_exercise_service.files).to have(2).items.and(all(have_attributes(role: 'reference_implementation')))
+ end
+ end
+ end
+
+ context 'when task has a test' do
+ let(:tests) { [test] }
+ let(:test) do
+ Proforma::Test.new(
+ id: 'test-id',
+ title: 'title',
+ description: 'description',
+ internal_description: 'internal_description',
+ test_type: 'test_type',
+ files: test_files,
+ meta_data: {
+ 'feedback-message' => 'feedback-message',
+ 'testing-framework' => 'testing-framework',
+ 'testing-framework-version' => 'testing-framework-version'
+ }
+ )
+ end
+
+ let(:test_files) { [test_file] }
+ let(:test_file) do
+ Proforma::TaskFile.new(
+ id: 'test_file_id',
+ content: 'testfile-content',
+ filename: 'testfile.txt',
+ used_by_grader: 'yes',
+ visible: 'no',
+ usage_by_lms: 'display',
+ binary: false,
+ internal_description: 'teacher_defined_test'
+ )
+ end
+
+ it 'creates an exercise with a test' do
+ expect(convert_to_exercise_service.files.select { |file| file.role == 'teacher_defined_test' }).to have(1).item
+ end
+
+ it 'creates an exercise with a test with correct attributes' do
+ expect(convert_to_exercise_service.files.select { |file| file.role == 'teacher_defined_test' }.first).to have_attributes(
+ feedback_message: 'feedback-message',
+ content: 'testfile-content',
+ name: 'testfile',
+ role: 'teacher_defined_test',
+ hidden: true,
+ read_only: true,
+ file_type: be_a(FileType).and(have_attributes(file_extension: '.txt'))
+ )
+ end
+
+ context 'when task has multiple tests' do
+ let(:tests) { [test, test2] }
+ let(:test2) do
+ Proforma::Test.new(
+ files: test_files2,
+ meta_data: {
+ 'feedback-message' => 'feedback-message',
+ 'testing-framework' => 'testing-framework',
+ 'testing-framework-version' => 'testing-framework-version'
+ }
+ )
+ end
+ let(:test_files2) { [test_file2] }
+ let(:test_file2) do
+ Proforma::TaskFile.new(
+ id: 'test_file_id2',
+ content: 'testfile-content',
+ filename: 'testfile.txt',
+ used_by_grader: 'yes',
+ visible: 'no',
+ usage_by_lms: 'display',
+ binary: false,
+ internal_description: 'teacher_defined_test'
+ )
+ end
+
+ it 'creates an exercise with two test' do
+ expect(convert_to_exercise_service.files.select { |file| file.role == 'teacher_defined_test' }).to have(2).items
+ end
+ end
+ end
+
+ context 'when exercise is set' do
+ let(:exercise) do
+ FactoryBot.create(
+ :files,
+ title: 'exercise-title',
+ description: 'exercise-description',
+ instructions: 'exercise-instruction'
+ )
+ end
+
+ before { exercise.reload }
+
+ it 'assigns all values to given exercise' do
+ convert_to_exercise_service.save
+ expect(exercise.reload).to have_attributes(
+ id: exercise.id,
+ title: task.title,
+ description: task.description,
+ instructions: task.internal_description,
+ execution_environment: exercise.execution_environment,
+ uuid: exercise.uuid,
+ user: exercise.user,
+ files: be_empty
+ )
+ end
+
+ it 'does not create a new Exercise on save' do
+ expect { convert_to_exercise_service.save }.not_to change(Exercise, :count)
+ end
+
+ context 'with file, model solution and test' do
+ let(:files) { [file] }
+ let(:file) do
+ Proforma::TaskFile.new(
+ id: 'id',
+ content: 'content',
+ filename: 'filename.txt',
+ used_by_grader: 'used_by_grader',
+ visible: 'yes',
+ usage_by_lms: 'display',
+ binary: false,
+ internal_description: 'regular_file'
+ )
+ end
+ let(:tests) { [test] }
+ let(:test) do
+ Proforma::Test.new(
+ id: 'test-id',
+ title: 'title',
+ description: 'description',
+ internal_description: 'regular_file',
+ test_type: 'test_type',
+ files: test_files,
+ meta_data: {
+ 'feedback-message' => 'feedback-message',
+ 'testing-framework' => 'testing-framework',
+ 'testing-framework-version' => 'testing-framework-version'
+ }
+ )
+ end
+ let(:test_files) { [test_file] }
+ let(:test_file) do
+ Proforma::TaskFile.new(
+ id: 'test_file_id',
+ content: 'testfile-content',
+ filename: 'testfile.txt',
+ used_by_grader: 'yes',
+ visible: 'no',
+ usage_by_lms: 'display',
+ binary: false,
+ internal_description: 'teacher_defined_test'
+ )
+ end
+ let(:model_solutions) { [model_solution] }
+ let(:model_solution) do
+ Proforma::ModelSolution.new(
+ id: 'ms-id',
+ files: ms_files
+ )
+ end
+ let(:ms_files) { [ms_file] }
+ let(:ms_file) do
+ Proforma::TaskFile.new(
+ id: 'ms-file',
+ content: 'ms-content',
+ filename: 'filename.txt',
+ used_by_grader: 'used_by_grader',
+ visible: 'yes',
+ usage_by_lms: 'display',
+ binary: false,
+ internal_description: 'reference_implementation'
+ )
+ end
+
+ it 'assigns all values to given exercise' do
+ expect(convert_to_exercise_service).to have_attributes(
+ id: exercise.id,
+ files: have(3).items
+ .and(include(have_attributes(content: 'ms-content', role: 'reference_implementation')))
+ .and(include(have_attributes(content: 'content', role: 'regular_file')))
+ .and(include(have_attributes(content: 'testfile-content', role: 'teacher_defined_test')))
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/proforma_service/export_task_spec.rb b/spec/services/proforma_service/export_task_spec.rb
new file mode 100644
index 00000000..157094de
--- /dev/null
+++ b/spec/services/proforma_service/export_task_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ProformaService::ExportTask do
+ describe '.new' do
+ subject(:export_task) { described_class.new(exercise: exercise) }
+
+ let(:exercise) { FactoryBot.build(:dummy) }
+
+ it 'assigns exercise' do
+ expect(export_task.instance_variable_get(:@exercise)).to be exercise
+ end
+
+ context 'without exercise' do
+ subject(:export_task) { described_class.new }
+
+ it 'assigns exercise' do
+ expect(export_task.instance_variable_get(:@exercise)).to be nil
+ end
+ end
+ end
+
+ describe '#execute' do
+ subject(:export_task) { described_class.call(exercise: exercise) }
+
+ let(:task) { Proforma::Task.new }
+ let(:exercise) { FactoryBot.build(:dummy) }
+ let(:exporter) { instance_double('Proforma::Exporter', perform: 'zip') }
+
+ before do
+ allow(ProformaService::ConvertExerciseToTask).to receive(:call).with(exercise: exercise).and_return(task)
+ allow(Proforma::Exporter).to receive(:new).with(task).and_return(exporter)
+ end
+
+ it do
+ export_task
+ expect(exporter).to have_received(:perform)
+ end
+ end
+end
diff --git a/spec/services/proforma_service/import_spec.rb b/spec/services/proforma_service/import_spec.rb
new file mode 100644
index 00000000..d674a68d
--- /dev/null
+++ b/spec/services/proforma_service/import_spec.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ProformaService::Import do
+ describe '.new' do
+ subject(:import_service) { described_class.new(zip: zip, user: user) }
+
+ let(:zip) { Tempfile.new('proforma_test_zip_file') }
+ let(:user) { FactoryBot.build(:teacher) }
+
+ it 'assigns zip' do
+ expect(import_service.instance_variable_get(:@zip)).to be zip
+ end
+
+ it 'assigns user' do
+ expect(import_service.instance_variable_get(:@user)).to be user
+ end
+ end
+
+ describe '#execute' do
+ subject(:import_service) { described_class.call(zip: zip_file, user: import_user) }
+
+ let(:user) { FactoryBot.create(:teacher) }
+ let(:import_user) { user }
+ let(:zip_file) { Tempfile.new('proforma_test_zip_file') }
+ let(:exercise) do
+ FactoryBot.create(:dummy,
+ instructions: 'instruction',
+ execution_environment: execution_environment,
+ files: files + tests,
+ uuid: uuid,
+ user: user)
+ end
+
+ let(:uuid) {}
+ let(:execution_environment) { FactoryBot.build(:java) }
+ let(:files) { [] }
+ let(:tests) { [] }
+ let(:exporter) { ProformaService::ExportTask.call(exercise: exercise.reload).string }
+
+ before do
+ zip_file.write(exporter)
+ zip_file.rewind
+ end
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+
+ it 'sets the correct user as owner of the exercise' do
+ expect(import_service.user).to be user
+ end
+
+ it 'sets the uuid' do
+ expect(import_service.uuid).not_to be_blank
+ end
+
+ context 'when no exercise exists' do
+ before { exercise.destroy }
+
+ it { is_expected.to be_valid }
+
+ it 'sets the correct user as owner of the exercise' do
+ expect(import_service.user).to be user
+ end
+
+ it 'sets the uuid' do
+ expect(import_service.uuid).not_to be_blank
+ end
+
+ context 'when task has a uuid' do
+ let(:uuid) { SecureRandom.uuid }
+
+ it 'sets the uuid' do
+ expect(import_service.uuid).to eql uuid
+ end
+ end
+ end
+
+ context 'when exercise has a mainfile' do
+ let(:files) { [file] }
+ let(:file) { FactoryBot.build(:file) }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+
+ context 'when the mainfile is very large' do
+ let(:file) { FactoryBot.build(:file, content: 'test' * 10**5) }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+ end
+ end
+
+ context 'when exercise has a regular file' do
+ let(:files) { [file] }
+ let(:file) { FactoryBot.build(:file, role: 'regular_file') }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+
+ context 'when file has an attachment' do
+ let(:file) { FactoryBot.build(:file, :image, role: 'regular_file') }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+ end
+ end
+
+ context 'when exercise has a file with role reference implementation' do
+ let(:files) { [file] }
+ let(:file) { FactoryBot.build(:file, role: 'reference_implementation', read_only: true) }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+ end
+
+ context 'when exercise has multiple files with role reference implementation' do
+ let(:files) { FactoryBot.build_list(:file, 2, role: 'reference_implementation', read_only: true) }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+ end
+
+ context 'when exercise has a test' do
+ let(:tests) { [test] }
+ let(:test) { FactoryBot.build(:test_file) }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+ end
+
+ context 'when exercise has multiple tests' do
+ let(:tests) { FactoryBot.build_list(:test_file, 2) }
+
+ it { is_expected.to be_an_equal_exercise_as exercise }
+ end
+
+ context 'when task in zip has a different uuid' do
+ let(:uuid) { SecureRandom.uuid }
+ let(:new_uuid) { SecureRandom.uuid }
+ let(:imported_exercise) { import_service }
+
+ before do
+ exercise.update(uuid: new_uuid)
+ imported_exercise.save!
+ end
+
+ it 'creates a new Exercise' do
+ expect(import_service.id).not_to be exercise.id
+ end
+ end
+
+ context 'when task in zip has the same uuid and nothing has changed' do
+ let(:uuid) { SecureRandom.uuid }
+ let(:imported_exercise) { import_service }
+
+ it 'updates the old Exercise' do
+ imported_exercise.save!
+ expect(imported_exercise.id).to be exercise.id
+ end
+
+ context 'when another user imports the exercise' do
+ let(:import_user) { FactoryBot.create(:teacher) }
+
+ it 'raises a proforma error' do
+ expect { imported_exercise.save! } .to raise_error Proforma::ExerciseNotOwned
+ end
+ end
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index f13a3edb..570c271b 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -20,6 +20,8 @@ unless RUBY_PLATFORM == 'java'
SimpleCov.start('rails')
end
+require 'webmock/rspec'
+
RSpec.configure do |config|
# These two settings work together to allow you to limit a spec run
# to individual examples or groups you care about by tagging them with
diff --git a/spec/support/expectations/equal_exercise.rb b/spec/support/expectations/equal_exercise.rb
new file mode 100644
index 00000000..8f72955c
--- /dev/null
+++ b/spec/support/expectations/equal_exercise.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'rspec/expectations'
+
+RSpec::Matchers.define :be_an_equal_exercise_as do |exercise|
+ match do |actual|
+ equal?(actual, exercise)
+ end
+ failure_message do |actual|
+ "#{actual.inspect} is not equal to \n#{exercise.inspect}. \nLast checked attribute: #{@last_checked}"
+ end
+
+ def equal?(object, other)
+ return false unless object.class == other.class
+ return attributes_equal?(object, other) if object.is_a?(ApplicationRecord)
+ return array_equal?(object, other) if object.is_a?(Array) || object.is_a?(ActiveRecord::Associations::CollectionProxy)
+
+ object == other
+ end
+
+ def attributes_equal?(object, other)
+ other_attributes = attributes_and_associations(other)
+ attributes_and_associations(object).each do |k, v|
+ @last_checked = "#{k}: \n\"#{v}\" vs \n\"#{other_attributes[k]}\""
+ return false unless equal?(other_attributes[k], v)
+ end
+ true
+ end
+
+ def array_equal?(object, other)
+ return true if object == other # for []
+ return false if object.length != other.length
+
+ object.to_a.product(other.to_a).map { |k, v| equal?(k, v) }.any?
+ end
+
+ def attributes_and_associations(object)
+ object.attributes.dup.tap do |attributes|
+ attributes[:files] = object.files if defined? object.files
+ end.except('id', 'created_at', 'updated_at', 'exercise_id', 'uuid')
+ end
+end
diff --git a/spec/support/expectations/has_content.rb b/spec/support/expectations/has_content.rb
new file mode 100644
index 00000000..e0f5f60c
--- /dev/null
+++ b/spec/support/expectations/has_content.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rspec/expectations'
+
+RSpec::Matchers.define :has_content do |actual_content|
+ match do |file|
+ file.read == actual_content
+ end
+end