Skip to content

Commit 1f9088b

Browse files
kkoehnMrSerth
andauthored
Proforma: reuse existing files on import, if possible (#2538)
* basic functionality of reusing files based on xml_id. Use xml_id_path to recreate proforma structure with tests and model_solutions containing multiple files * refactor xml_id_path to use array * Update app/services/proforma_service/convert_exercise_to_task.rb Co-authored-by: Sebastian Serth <[email protected]> * fix exercise#duplicate when xml_id_path is set --------- Co-authored-by: Sebastian Serth <[email protected]>
1 parent 74bb1c5 commit 1f9088b

File tree

12 files changed

+330
-81
lines changed

12 files changed

+330
-81
lines changed

app/controllers/exercises_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def batch_update
5656
end
5757

5858
def clone
59-
exercise = @exercise.duplicate(public: false, token: nil, user: current_user)
59+
exercise = @exercise.duplicate(public: false, token: nil, user: current_user, uuid: nil)
6060
exercise.send(:generate_token)
6161
if exercise.save
6262
redirect_to(exercise_path(exercise), notice: t('shared.object_cloned', model: Exercise.model_name.human))

app/models/code_ocean/file.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ class File < ApplicationRecord
6060
validates :weight, absence: true, unless: :teacher_defined_assessment?
6161
validates :file, presence: true if :context.is_a?(Submission)
6262
validates :context_type, inclusion: {in: ALLOWED_CONTEXT_TYPES}
63+
# xml_id_path must be unique within the scope of an exercise.
64+
# Initially, it may match the record’s id (set while exporting),
65+
# but it can later diverge as long as uniqueness is preserved.
66+
validates :xml_id_path, uniqueness: {scope: %I[context_id context_type]}, allow_blank: true
67+
validates :xml_id_path, absence: true, unless: -> { context.is_a?(Exercise) }
6368

6469
validates_with FileNameValidator, fields: %i[name path file_type_id]
6570

app/services/proforma_service/convert_exercise_to_task.rb

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def create_task
6767

6868
def proglang
6969
regex = %r{^openhpi/co_execenv_(?<language>[^:]*):(?<version>[^-]*)(?>-.*)?$}
70-
match = regex.match @exercise.execution_environment.docker_image
70+
match = regex.match @exercise&.execution_environment&.docker_image
7171
match ? {name: match[:language], version: match[:version]} : nil
7272
end
7373

@@ -77,62 +77,85 @@ def uuid
7777
end
7878

7979
def model_solutions
80-
@exercise.files.filter {|file| file.role == 'reference_implementation' }.map do |file|
80+
@exercise.files.filter(&:reference_implementation?).group_by {|file| xml_id_from_file(file).first }.map do |xml_id, files|
8181
ProformaXML::ModelSolution.new(
82-
id: "ms-#{file.id}",
83-
files: model_solution_file(file)
82+
id: xml_id,
83+
files: files.map {|file| model_solution_file(file) }
8484
)
8585
end
8686
end
8787

8888
def model_solution_file(file)
89-
[
90-
task_file(file).tap do |ms_file|
91-
ms_file.used_by_grader = false
92-
ms_file.usage_by_lms = 'display'
93-
end,
94-
]
89+
task_file(file).tap do |ms_file|
90+
ms_file.used_by_grader = false
91+
ms_file.usage_by_lms = 'display'
92+
end
9593
end
9694

9795
def tests
98-
@exercise.files.filter(&:teacher_defined_assessment?).map do |file|
96+
@exercise.files.filter(&:teacher_defined_assessment?).group_by {|file| xml_id_from_file(file).first }.map do |xml_id, files|
9997
ProformaXML::Test.new(
100-
id: file.id,
101-
title: file.name,
102-
files: test_file(file),
103-
meta_data: test_meta_data(file)
98+
id: xml_id,
99+
title: files.first.name,
100+
files: files.map {|file| test_file(file) },
101+
meta_data: test_meta_data(files)
104102
)
105103
end
106104
end
107105

108-
def test_meta_data(file)
106+
def xml_id_from_file(file)
107+
xml_id_path = file.xml_id_path || []
108+
return xml_id_path if xml_id_path&.any?
109+
110+
type = if file.teacher_defined_assessment?
111+
'test'
112+
elsif file.reference_implementation?
113+
'ms'
114+
else
115+
'file'
116+
end
117+
118+
xml_id_path << "co-#{type}-#{file.id}" unless type == 'file'
119+
xml_id_path << file.id.to_s
120+
121+
file.update!(xml_id_path: xml_id_from_file(file))
122+
xml_id_path
123+
end
124+
125+
def test_meta_data(files)
109126
{
110127
'@@order' => %w[test-meta-data],
111128
'test-meta-data' => {
112-
'@@order' => %w[CodeOcean:feedback-message CodeOcean:weight CodeOcean:hidden-feedback],
129+
'@@order' => %w[CodeOcean:test-file],
113130
'@xmlns' => {'CodeOcean' => 'codeocean.openhpi.de'},
114-
'CodeOcean:feedback-message' => {
115-
'@@order' => %w[$1],
116-
'$1' => file.feedback_message,
117-
},
118-
'CodeOcean:weight' => {
119-
'@@order' => %w[$1],
120-
'$1' => file.weight,
121-
},
122-
'CodeOcean:hidden-feedback' => {
123-
'@@order' => %w[$1],
124-
'$1' => file.hidden_feedback,
125-
},
131+
'CodeOcean:test-file' => files.map do |file|
132+
{
133+
'@@order' => %w[CodeOcean:feedback-message CodeOcean:weight CodeOcean:hidden-feedback],
134+
'@xmlns' => {'CodeOcean' => 'codeocean.openhpi.de'},
135+
'@id' => xml_id_from_file(file).last,
136+
'@name' => file.name,
137+
'CodeOcean:feedback-message' => {
138+
'@@order' => %w[$1],
139+
'$1' => file.feedback_message,
140+
},
141+
'CodeOcean:weight' => {
142+
'@@order' => %w[$1],
143+
'$1' => file.weight,
144+
},
145+
'CodeOcean:hidden-feedback' => {
146+
'@@order' => %w[$1],
147+
'$1' => file.hidden_feedback,
148+
},
149+
}
150+
end,
126151
},
127152
}
128153
end
129154

130155
def test_file(file)
131-
[
132-
task_file(file).tap do |t_file|
133-
t_file.used_by_grader = true
134-
end,
135-
]
156+
task_file(file).tap do |t_file|
157+
t_file.used_by_grader = true
158+
end
136159
end
137160

138161
def exercise_files
@@ -168,8 +191,9 @@ def task_files
168191
end
169192

170193
def task_file(file)
194+
xml_id = xml_id_from_file(file).last
171195
task_file = ProformaXML::TaskFile.new(
172-
id: file.id,
196+
id: xml_id,
173197
filename: filename(file),
174198
usage_by_lms: file.read_only ? 'display' : 'edit',
175199
visible: file.hidden ? 'no' : 'yes'

app/services/proforma_service/convert_task_to_exercise.rb

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ def initialize(task:, user:, exercise: nil)
1010
end
1111

1212
def execute
13-
import_task
13+
ActiveRecord::Base.transaction do
14+
import_task
15+
end
1416
@exercise
1517
end
1618

1719
private
1820

1921
def import_task
22+
destroy_old_files
2023
@exercise.assign_attributes(
2124
user: @user,
2225
title: @task.title,
@@ -32,9 +35,14 @@ def import_task
3235
)
3336
end
3437

38+
def destroy_old_files
39+
file_ids = (@task.files + @task.tests.flat_map(&:files) + @task.model_solutions.flat_map(&:files)).map(&:id)
40+
@exercise.files.reject {|file| file_ids.include? file.xml_id_path.last }.each(&:destroy)
41+
end
42+
3543
def extract_meta_data(meta_data, *path)
3644
current_level = meta_data
37-
path.each {|attribute| current_level = current_level&.dig("CodeOcean:#{attribute}") }
45+
path.each {|attribute| current_level = current_level.is_a?(Hash) ? current_level&.dig("CodeOcean:#{attribute}") : current_level&.find {|entry| entry['@id'] == attribute } } # || current_level
3846
current_level&.dig('$1')
3947
end
4048

@@ -60,44 +68,55 @@ def string_to_bool(str)
6068
end
6169

6270
def files
63-
model_solution_files + test_files + task_files.values.tap {|array| array.each {|file| file.role ||= 'regular_file' } }
71+
model_solution_files + test_files + task_files
6472
end
6573

6674
def test_files
67-
@task.tests.map do |test_object|
68-
task_files.delete(test_object.files.first.id).tap do |file|
69-
file.weight = extract_meta_data(test_object.meta_data&.dig('test-meta-data'), 'weight').presence || 1.0
70-
file.feedback_message = extract_meta_data(test_object.meta_data&.dig('test-meta-data'), 'feedback-message').presence || 'Feedback'
71-
file.hidden_feedback = extract_meta_data(test_object.meta_data&.dig('test-meta-data'), 'hidden-feedback').presence || false
72-
file.role ||= 'teacher_defined_test'
75+
@task.tests.flat_map do |test|
76+
test.files.map do |task_file|
77+
codeocean_file_from_task_file(task_file, test).tap do |file|
78+
file.weight = extract_meta_data(test.meta_data&.dig('test-meta-data'), 'test-file', task_file.id, 'weight').presence || 1.0
79+
file.feedback_message = extract_meta_data(test.meta_data&.dig('test-meta-data'), 'test-file', task_file.id, 'feedback-message').presence || 'Feedback'
80+
file.hidden_feedback = extract_meta_data(test.meta_data&.dig('test-meta-data'), 'test-file', task_file.id, 'hidden-feedback').presence || false
81+
file.role = 'teacher_defined_test' unless file.teacher_defined_assessment?
82+
end
7383
end
7484
end
7585
end
7686

7787
def model_solution_files
78-
@task.model_solutions.map do |model_solution_object|
79-
task_files.delete(model_solution_object.files.first.id).tap do |file|
80-
file.role ||= 'reference_implementation'
88+
@task.model_solutions.flat_map do |model_solution|
89+
model_solution.files.map do |task_file|
90+
codeocean_file_from_task_file(task_file, model_solution).tap do |file|
91+
file.role ||= 'reference_implementation'
92+
file.feedback_message = nil
93+
end
8194
end
8295
end
8396
end
8497

8598
def task_files
86-
@task_files ||= @task.all_files.reject {|file| file.id == 'ms-placeholder-file' }.to_h do |task_file|
87-
[task_file.id, codeocean_file_from_task_file(task_file)]
99+
@task.files.reject {|file| file.id == 'ms-placeholder-file' }.map do |task_file|
100+
codeocean_file_from_task_file(task_file).tap do |file|
101+
file.role ||= 'regular_file'
102+
file.feedback_message = nil
103+
end
88104
end
89105
end
90106

91-
def codeocean_file_from_task_file(file)
107+
def codeocean_file_from_task_file(file, parent_object = nil)
92108
extension = File.extname(file.filename)
93-
codeocean_file = CodeOcean::File.new(
109+
# checking the last element of xml_id_path array for file.id
110+
codeocean_file = @exercise.files.detect {|f| f.xml_id_path.last == file.id } || @exercise.files.new
111+
codeocean_file.assign_attributes(
94112
context: @exercise,
95113
file_type: file_type(extension),
96114
hidden: file.visible != 'yes', # hides 'delayed' and 'no'
97115
name: File.basename(file.filename, '.*'),
98116
read_only: file.usage_by_lms != 'edit',
99117
role: extract_meta_data(@task.meta_data&.dig('meta-data'), 'files', "CO-#{file.id}", 'role'),
100-
path: File.dirname(file.filename).in?(['.', '']) ? nil : File.dirname(file.filename)
118+
path: File.dirname(file.filename).in?(['.', '']) ? nil : File.dirname(file.filename),
119+
xml_id_path: (parent_object.nil? ? [file.id] : [parent_object.id, file.id]).map(&:to_s)
101120
)
102121
if file.binary
103122
codeocean_file.native_file = FileIO.new(file.content.dup.force_encoding('UTF-8'), File.basename(file.filename))

app/services/proforma_service/import.rb

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,7 @@ def execute
1414
import_result = importer.perform
1515
@task = import_result
1616

17-
exercise = base_exercise
18-
exercise_files = exercise&.files&.to_a
19-
20-
exercise = ConvertTaskToExercise.call(task: @task, user: @user, exercise:)
21-
exercise_files&.each(&:destroy) # feels suboptimal
22-
23-
exercise
17+
ConvertTaskToExercise.call(task: @task, user: @user, exercise: base_exercise)
2418
else
2519
import_multi
2620
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 AddXmlPathToFiles < ActiveRecord::Migration[7.1]
4+
def change
5+
add_column :files, :xml_id_path, :string, array: true, default: [], null: true
6+
end
7+
end

db/schema.rb

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/controllers/exercises_controller_spec.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@
4949
expect_redirect(Exercise.last)
5050
end
5151

52+
context 'when exercise has uuid' do
53+
let(:exercise) { create(:dummy, uuid: SecureRandom.hex) }
54+
55+
it 'clones the exercise' do
56+
expect_any_instance_of(Exercise).to receive(:duplicate).with(hash_including(public: false, user:)).and_call_original
57+
expect { perform_request.call }.to change(Exercise, :count).by(1)
58+
end
59+
end
60+
5261
context 'when saving fails' do
5362
before do
5463
allow_any_instance_of(Exercise).to receive(:save).and_return(false)

spec/models/code_ocean/file_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,46 @@
5757
end
5858
end
5959

60+
context 'with xml_id_path' do
61+
let(:exercise) { create(:dummy) }
62+
let(:file) { build(:file, context: file_context, xml_id_path: xml_id_path) }
63+
let(:file_context) { exercise }
64+
let(:xml_id_path) { ['abcde'] }
65+
66+
before do
67+
create(:file, context: exercise, xml_id_path: ['abcde'])
68+
file.validate
69+
end
70+
71+
it 'has an error for xml_id_path' do
72+
expect(file.errors[:xml_id_path]).to be_present
73+
end
74+
75+
context 'when second file has a different exercise' do
76+
let(:file_context) { create(:dummy) }
77+
78+
it 'has no error for xml_id_path' do
79+
expect(file.errors[:xml_id_path]).not_to be_present
80+
end
81+
end
82+
83+
context 'when second file has a different xml_id_path' do
84+
let(:xml_id_path) { ['foobar'] }
85+
86+
it 'has no error for xml_id_path' do
87+
expect(file.errors[:xml_id_path]).not_to be_present
88+
end
89+
end
90+
91+
context 'when file_context is not Exercise' do
92+
let(:file_context) { create(:submission) }
93+
94+
it 'has an error for xml_id_path' do
95+
expect(file.errors[:xml_id_path]).to be_present
96+
end
97+
end
98+
end
99+
60100
context 'with a native file' do
61101
let(:file) { create(:file, :image) }
62102

0 commit comments

Comments
 (0)