Merge pull request #423 from openHPI/implement_codeharbor_interface

Implement codeharbor interface
This commit is contained in:
MrSerth
2019-12-20 10:53:42 +01:00
committed by GitHub
68 changed files with 2393 additions and 195 deletions

View File

@ -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

View File

@ -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

View File

@ -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())
});
}
}
});

View File

@ -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('<div class="spinner-border"></div>');
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');

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Proforma
class ExerciseNotOwned < StandardError; end
end

View File

@ -1,2 +0,0 @@
module CodeHarborLinksHelper
end

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ServiceBase
def self.call(*args)
new(*args).execute
end
end

View File

@ -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)

View File

@ -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)

View File

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

View File

@ -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)

View File

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

View File

@ -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))

View File

@ -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')

View File

@ -0,0 +1,3 @@
h1 = CodeharborLink.model_name.human
= render('form')

View File

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

View File

@ -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')

View File

@ -0,0 +1,3 @@
#export-exercise
.export-message
.export-exercise-actions

View File

@ -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)
.actions = render('shared/submit_button', f: f, object: @exercise)

View File

@ -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')

View File

@ -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')

View File

@ -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'))

View File

@ -2,4 +2,6 @@ test:
flowr:
enabled: false
code_pilot:
enabled: false
enabled: false
codeharbor:
url: http://test.url

View File

@ -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.<br>ID: %{id}<br>Title: %{title}'
export_failed: 'Export ist fehlgeschlagen.<br>ID: %{id}<br>Title: %{title}<br><br>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

View File

@ -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.<br>ID: %{id}<br>Title: %{title}'
export_failed: 'Export has failed.<br>ID: %{id}<br>Title: %{title}<br><br>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

View File

@ -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

View File

@ -0,0 +1,5 @@
class RenameCodeHarborLinksToCodeharborLinks < ActiveRecord::Migration[5.2]
def change
rename_table :code_harbor_links, :codeharbor_links
end
end

View File

@ -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

View File

@ -0,0 +1,5 @@
class AddUuidToExercise < ActiveRecord::Migration[5.2]
def change
add_column :exercises, :uuid, :uuid
end
end

View File

@ -0,0 +1,5 @@
class AddUnpublishedToExercise < ActiveRecord::Migration[5.2]
def change
add_column :exercises, :unpublished, :boolean, default: false
end
end

View File

@ -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

11
lib/file_io.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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)

View File

@ -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!

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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