Skip to content

Commit 71dba14

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

File tree

22 files changed

+535
-38
lines changed

22 files changed

+535
-38
lines changed

app/assets/javascripts/exercises.js

+81-11
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,6 @@ $(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();
348347
exportExerciseStart($(this).data().exerciseId);
349348
});
350349
body_selector.on('click', '.export-retry-button', function () {
@@ -356,11 +355,11 @@ $(document).on('turbolinks:load', function () {
356355
}
357356

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

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

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

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

387386
return $.ajax({
388387
type: 'POST',
@@ -395,11 +394,11 @@ $(document).on('turbolinks:load', function () {
395394
if (response.status === 'success') {
396395
$messageDiv.addClass('export-success');
397396
setTimeout((function () {
398-
bootstrap.Modal.getInstance($('#export-modal'))?.hide();
397+
bootstrap.Modal.getInstance($('#transfer-modal'))?.hide();
399398
$messageDiv.html('').removeClass('export-success');
400399
}), 3000);
401400
} else {
402-
$messageDiv.addClass('export-failure');
401+
$messageDiv.addClass('transfer-failure');
403402
}
404403
},
405404
error: function (a, b, c) {
@@ -408,6 +407,76 @@ $(document).on('turbolinks:load', function () {
408407
});
409408
};
410409

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

501571

502-
});
572+
});

app/assets/stylesheets/exercises.css.scss

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

+72-8
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,66 @@ 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 ProformaXML::InvalidZip => e
183+
render json: {
184+
status: 'failure',
185+
message: t('.error', message: e.message),
186+
}
187+
end
188+
189+
def import_confirm
190+
uploader = ProformaZipUploader.new
191+
uploader.retrieve_from_cache!(params[:file_id])
192+
exercise = ::ProformaService::Import.call(zip: uploader.file, user: current_user)
193+
exercise.save!
194+
195+
render json: {
196+
status: 'success',
197+
message: t('.success'),
198+
actions: render_to_string(partial: 'import_actions', locals: {exercise:, imported: true}),
199+
}
200+
rescue ProformaXML::ProformaError, ActiveRecord::RecordInvalid => e
201+
render json: {
202+
status: 'failure',
203+
message: t('.error', error: e.message),
204+
actions: '',
205+
}
206+
rescue StandardError => e
207+
Sentry.capture_exception(e)
208+
render json: {
209+
status: 'failure',
210+
message: t('exercises.import_proforma.import_errors.internal_error'),
211+
actions: '',
212+
}
160213
end
161214

162215
def import_task
@@ -175,10 +228,10 @@ def import_task
175228
rescue ProformaXML::ExerciseNotOwned
176229
render json: {}, status: :unauthorized
177230
rescue ProformaXML::ProformaError
178-
render json: t('exercises.import_codeharbor.import_errors.invalid'), status: :bad_request
231+
render json: t('exercises.import_proforma.import_errors.invalid'), status: :bad_request
179232
rescue StandardError => e
180233
Sentry.capture_exception(e)
181-
render json: t('exercises.import_codeharbor.import_errors.internal_error'), status: :internal_server_error
234+
render json: t('exercises.import_proforma.import_errors.internal_error'), status: :internal_server_error
182235
end
183236

184237
def user_from_api_key
@@ -572,4 +625,15 @@ def study_group_dashboard
572625

573626
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
574627
end
628+
629+
private
630+
631+
def uuid_check(user:, uuid:)
632+
exercise = Exercise.find_by(uuid:)
633+
634+
return {uuid_found: false} if exercise.nil?
635+
return {uuid_found: true, update_right: false} unless ExercisePolicy.new(user, exercise).update?
636+
637+
{uuid_found: true, update_right: true}
638+
end
575639
end

app/errors/proformaxml/invalid_zip.rb

+5
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

+4-1
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?
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
+7
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

-3
This file was deleted.
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')
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)