Skip to content

Commit 5983561

Browse files
committed
add direct import of proforma zip
(WIP, better UX, file validation and errors missing)
1 parent c127256 commit 5983561

File tree

22 files changed

+521
-36
lines changed

22 files changed

+521
-36
lines changed

app/assets/javascripts/exercises.js

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ $(document).on('turbolinks:load', function () {
344344
var observeExportButtons = function () {
345345
$('.export-start').on('click', function (e) {
346346
e.preventDefault();
347-
new bootstrap.Modal($('#export-modal')).show();
347+
new bootstrap.Modal($('#transfer-modal')).show();
348348
exportExerciseStart($(this).data().exerciseId);
349349
});
350350
body_selector.on('click', '.export-retry-button', function () {
@@ -356,11 +356,11 @@ $(document).on('turbolinks:load', function () {
356356
}
357357

358358
var exportExerciseStart = function (exerciseID) {
359-
const $exerciseDiv = $('#export-exercise');
360-
const $messageDiv = $exerciseDiv.children('.export-message');
361-
const $actionsDiv = $exerciseDiv.children('.export-exercise-actions');
359+
const $exerciseDiv = $('#exercise-transfer');
360+
const $messageDiv = $exerciseDiv.children('.transfer-message');
361+
const $actionsDiv = $exerciseDiv.children('.transfer-exercise-actions');
362362

363-
$messageDiv.removeClass('export-failure');
363+
$messageDiv.removeClass('transfer-failure');
364364

365365
$messageDiv.html(I18n.t('exercises.export_codeharbor.checking_codeharbor'));
366366
$actionsDiv.html('<div class="spinner-border"></div>');
@@ -380,9 +380,9 @@ $(document).on('turbolinks:load', function () {
380380
};
381381

382382
var exportExerciseConfirm = function (exerciseID) {
383-
const $exerciseDiv = $('#export-exercise');
384-
const $messageDiv = $exerciseDiv.children('.export-message');
385-
const $actionsDiv = $exerciseDiv.children('.export-exercise-actions');
383+
const $exerciseDiv = $('#exercise-transfer');
384+
const $messageDiv = $exerciseDiv.children('.transfer-message');
385+
const $actionsDiv = $exerciseDiv.children('.transfer-exercise-actions');
386386

387387
return $.ajax({
388388
type: 'POST',
@@ -395,11 +395,11 @@ $(document).on('turbolinks:load', function () {
395395
if (response.status === 'success') {
396396
$messageDiv.addClass('export-success');
397397
setTimeout((function () {
398-
bootstrap.Modal.getInstance($('#export-modal'))?.hide();
398+
bootstrap.Modal.getInstance($('#transfer-modal'))?.hide();
399399
$messageDiv.html('').removeClass('export-success');
400400
}), 3000);
401401
} else {
402-
$messageDiv.addClass('export-failure');
402+
$messageDiv.addClass('transfer-failure');
403403
}
404404
},
405405
error: function (a, b, c) {
@@ -408,6 +408,76 @@ $(document).on('turbolinks:load', function () {
408408
});
409409
};
410410

411+
var observeImportButtons = function () {
412+
const $exerciseDiv = $('#exercise-transfer');
413+
const $messageDiv = $exerciseDiv.children('.transfer-message');
414+
const $actionsDiv = $exerciseDiv.children('.transfer-exercise-actions');
415+
416+
$('.import-start').on('click', function (e) {
417+
e.preventDefault();
418+
new bootstrap.Modal($('#transfer-modal')).show();
419+
importExerciseStart();
420+
});
421+
body_selector.on('change', '#proforma-file', async function () {
422+
const file = event.target.files[0];
423+
const formData = new FormData();
424+
formData.append('file', file);
425+
426+
return $.ajax({
427+
type: 'POST',
428+
url: Routes.import_start_exercises_path(),
429+
data: formData,
430+
processData: false,
431+
contentType: false,
432+
433+
success: function (response) {
434+
$messageDiv.html(response.message);
435+
return $actionsDiv.html(response.actions);
436+
},
437+
error: function (a, b, c) {
438+
return alert(`error: ${c}`);
439+
}
440+
});
441+
});
442+
body_selector.on('click', '.import-action', async function () {
443+
let fileId = $(this).attr('data-file-id')
444+
let importType = $(this).attr('data-import-type')
445+
importExerciseConfirm(fileId, importType)
446+
});
447+
}
448+
var importExerciseStart = function () {
449+
// e.preventDefault();
450+
new bootstrap.Modal($('#transfer-modal')).show();
451+
452+
const $exerciseDiv = $('#exercise-transfer');
453+
const $messageDiv = $exerciseDiv.children('.transfer-message');
454+
const $actionsDiv = $exerciseDiv.children('.transfer-exercise-actions');
455+
456+
$messageDiv.removeClass('transfer-failure');
457+
$messageDiv.html(I18n.t('exercises.import_proforma.dialog.start'));
458+
$actionsDiv.html('<label for="proforma-file">Upload file</label><input type="file" id="proforma-file" name="proforma-file">');
459+
}
460+
461+
var importExerciseConfirm = function (fileId, importType) {
462+
const $exerciseDiv = $('#exercise-transfer');
463+
const $messageDiv = $exerciseDiv.children('.transfer-message');
464+
const $actionsDiv = $exerciseDiv.children('.transfer-exercise-actions');
465+
466+
$.ajax({
467+
type: 'POST',
468+
url: Routes.import_confirm_exercises_path(),
469+
data: {file_id: fileId, import_type: importType},
470+
dataType: 'json',
471+
472+
success: function (response) {
473+
$messageDiv.html(response.message);
474+
return $actionsDiv.html(response.actions);
475+
},
476+
error: function (a, b, c) {
477+
return alert(`error: ${c}`);
478+
}
479+
});
480+
}
411481
var overrideTextareaTabBehavior = function () {
412482
$('.mb-3 textarea[name$="[content]"]').on('keydown', function (event) {
413483
if (event.which === TAB_KEY_CODE) {
@@ -463,6 +533,7 @@ $(document).on('turbolinks:load', function () {
463533
if ($('table:not(#tags-table)').isPresent()) {
464534
enableBatchUpdate();
465535
observeExportButtons();
536+
observeImportButtons();
466537
} else if ($('.edit_exercise, .new_exercise').isPresent()) {
467538
const form_selector = $('form');
468539
execution_environments = form_selector.data('execution-environments');

app/assets/stylesheets/exercises.css.scss

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ a.file-heading {
179179
}
180180
}
181181

182-
#export-modal {
182+
#transfer-modal {
183183
.modal-content {
184184
min-height: 300px;
185185
}
@@ -189,25 +189,25 @@ a.file-heading {
189189
}
190190
}
191191

192-
#export-exercise{
192+
#exercise-transfer{
193193
display: flex;
194194
}
195195

196-
.export-message {
196+
.transfer-message {
197197
flex-grow: 1;
198198
font-size: 12px;
199199
padding-right: 5px;
200200
word-wrap: break-word;
201201
}
202-
.export-message + :empty {
202+
.transfer-message + :empty {
203203
max-width: 100%;
204204
}
205205

206-
.export-exercise-actions:empty {
206+
.transfer-exercise-actions:empty {
207207
display: none;
208208
}
209209

210-
.export-exercise-actions {
210+
.transfer-exercise-actions {
211211
max-width: 110px;
212212
min-width: 110px;
213213
}
@@ -223,6 +223,6 @@ a.file-heading {
223223
font-weight: 600;
224224
}
225225

226-
.export-failure {
226+
.transfer-failure {
227227
color: var(--bs-danger);
228228
}

app/controllers/exercises_controller.rb

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class ExercisesController < ApplicationController
2020

2121
skip_before_action :verify_authenticity_token, only: %i[import_task import_uuid_check]
2222
skip_before_action :require_fully_authenticated_user!, only: %i[import_task import_uuid_check]
23-
skip_after_action :verify_authorized, only: %i[import_task import_uuid_check]
23+
skip_after_action :verify_authorized, only: %i[import_task import_uuid_check import_start import_confirm]
2424
skip_after_action :verify_policy_scoped, only: %i[import_task import_uuid_check], raise: false
2525

2626
rescue_from Pundit::NotAuthorizedError, with: :not_authorized_for_exercise
@@ -150,13 +150,68 @@ def import_uuid_check
150150
user = user_from_api_key
151151
return render json: {}, status: :unauthorized if user.nil?
152152

153-
uuid = params[:uuid]
154-
exercise = Exercise.find_by(uuid:)
153+
render json: uuid_check(user:, uuid: params[:uuid])
154+
end
155+
156+
def import_start
157+
zip_file = params[:file]
158+
unless zip_file.is_a?(ActionDispatch::Http::UploadedFile)
159+
return render json: {status: 'failure', message: t('.choose_file_error')}
160+
end
155161

156-
return render json: {uuid_found: false} if exercise.nil?
157-
return render json: {uuid_found: true, update_right: false} unless ExercisePolicy.new(user, exercise).update?
162+
uuid = ProformaService::UuidFromZip.call(zip: zip_file)
163+
exists, updatable = uuid_check(user: current_user, uuid:).values_at(:uuid_found, :update_right)
158164

159-
render json: {uuid_found: true, update_right: true}
165+
uploader = ProformaZipUploader.new
166+
uploader.cache!(params[:file])
167+
168+
message = if exists && updatable
169+
t('.exercise_exists_and_is_updatable')
170+
elsif exists
171+
t('.exercise_exists_and_is_not_updatable')
172+
else
173+
t('.exercise_is_importable')
174+
end
175+
176+
render json: {
177+
status: 'success',
178+
message:,
179+
actions: render_to_string(partial: 'import_actions',
180+
locals: {exercise: @exercise, imported: false, exists:, updatable:, file_id: uploader.cache_name}),
181+
}
182+
rescue ProformaError::InvalidZip => e
183+
render json: {
184+
status: 'failure',
185+
message: e.message,
186+
actions: render_to_string(partial: 'import_actions',
187+
locals: {exercise: @exercise, imported: false, exists:, updatable:, file_id: uploader.cache_name}),
188+
}
189+
end
190+
191+
def import_confirm
192+
uploader = ProformaZipUploader.new
193+
uploader.retrieve_from_cache!(params[:file_id])
194+
exercise = ::ProformaService::Import.call(zip: uploader.file, user: current_user)
195+
exercise.save!
196+
197+
render json: {
198+
status: 'success',
199+
message: t('.success'),
200+
actions: render_to_string(partial: 'import_actions', locals: {exercise:, imported: true}),
201+
}
202+
rescue ProformaXML::ProformaError, ActiveRecord::RecordInvalid => e
203+
render json: {
204+
status: 'failure',
205+
message: t('.error', error: e.message),
206+
actions: '',
207+
}
208+
rescue StandardError => e
209+
Sentry.capture_exception(e)
210+
render json: {
211+
status: 'failure',
212+
message: t('exercises.import_proforma.import_errors.internal_error'),
213+
actions: '',
214+
}
160215
end
161216

162217
def import_task
@@ -175,10 +230,10 @@ def import_task
175230
rescue ProformaXML::ExerciseNotOwned
176231
render json: {}, status: :unauthorized
177232
rescue ProformaXML::ProformaError
178-
render json: t('exercises.import_codeharbor.import_errors.invalid'), status: :bad_request
233+
render json: t('exercises.import_proforma.import_errors.invalid'), status: :bad_request
179234
rescue StandardError => e
180235
Sentry.capture_exception(e)
181-
render json: t('exercises.import_codeharbor.import_errors.internal_error'), status: :internal_server_error
236+
render json: t('exercises.import_proforma.import_errors.internal_error'), status: :internal_server_error
182237
end
183238

184239
def user_from_api_key
@@ -572,4 +627,15 @@ def study_group_dashboard
572627

573628
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
574629
end
630+
631+
private
632+
633+
def uuid_check(user:, uuid:)
634+
exercise = Exercise.find_by(uuid:)
635+
636+
return {uuid_found: false} if exercise.nil?
637+
return {uuid_found: true, update_right: false} unless ExercisePolicy.new(user, exercise).update?
638+
639+
{uuid_found: true, update_right: true}
640+
end
575641
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
module ProformaXML
4+
class InvalidZip < ApplicationError; end
5+
end

app/services/proforma_service/import.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
module ProformaService
44
class Import < ServiceBase
5-
def initialize(zip:, user:)
5+
def initialize(zip:, user:, import_type: 'import')
66
super()
77
@zip = zip
88
@user = user
9+
@import_type = import_type
910
end
1011

1112
def execute
@@ -23,6 +24,8 @@ def execute
2324
private
2425

2526
def base_exercise
27+
return Exercise.new(uuid: SecureRandom.uuid, unpublished: true) if @import_type == 'create_new'
28+
2629
exercise = Exercise.find_by(uuid: @task.uuid)
2730
if exercise
2831
raise ProformaXML::ExerciseNotOwned unless ExercisePolicy.new(@user, exercise).update?
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
module ProformaService
4+
class UuidFromZip < ServiceBase
5+
def initialize(zip:)
6+
super()
7+
@zip = zip
8+
end
9+
10+
def execute
11+
if xml_exists_in_zip?
12+
importer = ProformaXML::Importer.new(zip: @zip)
13+
import_result = importer.perform
14+
task = import_result
15+
task.uuid
16+
end
17+
rescue Zip::Error
18+
raise ProformaXML::InvalidZip.new I18n.t('exercises.import_proforma.import_errors.invalid_zip')
19+
end
20+
21+
private
22+
23+
def xml_exists_in_zip?
24+
filenames = Zip::File.open(@zip.path) do |zip_file|
25+
zip_file.map(&:name)
26+
end
27+
28+
return true if filenames.any? {|f| f[/\.xml$/] }
29+
30+
raise ProformaXML::InvalidZip.new I18n.t('exercises.import_proforma.import_errors.no_xml_found')
31+
end
32+
end
33+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class ProformaZipUploader < CarrierWave::Uploader::Base
4+
def filename
5+
SecureRandom.uuid
6+
end
7+
end

app/views/exercises/_export_dialogcontent.html.slim

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
- if imported
2+
= link_to t('exercises.import_proforma.button.show_exercise'), exercise, class: 'btn btn-light btn-sm float-end show-action import-export-button', target: '_blank', rel: 'noopener noreferrer'
3+
- elsif exists && updatable
4+
= button_tag type: 'button', class: 'btn btn-light btn-sm float-end import-action import-export-button', data: {'import-type' => 'import', 'file-id' => file_id} do
5+
i.fa-solid.fa-check.confirm-icon.export-button-icon
6+
= t('exercises.import_proforma.button.overwrite')
7+
- elsif exists
8+
= button_tag type: 'button', class: 'btn btn-light btn-sm float-end import-action import-export-button', data: {'import-type' => 'create_new', 'file-id' => file_id} do
9+
i.fa-solid.fa-check.confirm-icon-alt.export-button-icon
10+
= t('exercises.import_proforma.button.import_copy')
11+
- else
12+
= button_tag type: 'button', class: 'btn btn-light btn-sm float-end import-action import-export-button', data: {'import-type' => 'import', 'file-id' => file_id} do
13+
i.fa-solid.fa-check.confirm-icon.export-button-icon
14+
= t('exercises.import_proforma.button.import')
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#exercise-transfer
2+
.transfer-message
3+
.transfer-exercise-actions

0 commit comments

Comments
 (0)