Skip to content

Commit 8300132

Browse files
committed
refactor xml_id_path to use array
1 parent be9ba11 commit 8300132

File tree

9 files changed

+144
-26
lines changed

9 files changed

+144
-26
lines changed

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: 7 additions & 7 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

@@ -105,19 +105,21 @@ def tests
105105
end
106106

107107
def xml_id_from_file(file)
108+
xml_id_path = file.xml_id_path || []
109+
return xml_id_path if xml_id_path&.any?
110+
108111
type = if file.teacher_defined_assessment?
109112
'test'
110113
elsif file.reference_implementation?
111114
'ms'
112115
else
113116
'file'
114117
end
115-
xml_id_path_parts = file.xml_id_path&.split('/')
116-
xml_id_path = []
117118

118-
xml_id_path << (xml_id_path_parts&.first || "co-#{type}-#{file.id}") unless type == 'file'
119-
xml_id_path << (xml_id_path_parts&.last || file.id)
119+
xml_id_path << "co-#{type}-#{file.id}" unless type == 'file'
120+
xml_id_path << file.id.to_s
120121

122+
file.update!(xml_id_path: xml_id_from_file(file))
121123
xml_id_path
122124
end
123125

@@ -190,8 +192,6 @@ def task_files
190192
end
191193

192194
def task_file(file)
193-
file.update(xml_id_path: xml_id_from_file(file).join('/')) if file.xml_id_path.blank?
194-
195195
xml_id = xml_id_from_file(file).last
196196
task_file = ProformaXML::TaskFile.new(
197197
id: xml_id,

app/services/proforma_service/convert_task_to_exercise.rb

Lines changed: 15 additions & 5 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

@@ -81,6 +89,7 @@ def model_solution_files
8189
model_solution.files.map do |task_file|
8290
codeocean_file_from_task_file(task_file, model_solution).tap do |file|
8391
file.role ||= 'reference_implementation'
92+
file.feedback_message = nil
8493
end
8594
end
8695
end
@@ -90,14 +99,15 @@ def task_files
9099
@task.files.reject {|file| file.id == 'ms-placeholder-file' }.map do |task_file|
91100
codeocean_file_from_task_file(task_file).tap do |file|
92101
file.role ||= 'regular_file'
102+
file.feedback_message = nil
93103
end
94104
end
95105
end
96106

97107
def codeocean_file_from_task_file(file, parent_object = nil)
98108
extension = File.extname(file.filename)
99-
100-
codeocean_file = CodeOcean::File.where(context: @exercise).where('xml_id_path = ? OR xml_id_path LIKE ?', file.id, "%/#{file.id}").first_or_initialize(context: @exercise)
109+
# checking the last element of xml_id_path array for file.id
110+
codeocean_file = @exercise.files.where('array_length(xml_id_path, 1) IS NOT NULL AND xml_id_path[array_length(xml_id_path, 1)] = ?', file.id).first_or_initialize
101111
codeocean_file.assign_attributes(
102112
context: @exercise,
103113
file_type: file_type(extension),
@@ -106,7 +116,7 @@ def codeocean_file_from_task_file(file, parent_object = nil)
106116
read_only: file.usage_by_lms != 'edit',
107117
role: extract_meta_data(@task.meta_data&.dig('meta-data'), 'files', "CO-#{file.id}", 'role'),
108118
path: File.dirname(file.filename).in?(['.', '']) ? nil : File.dirname(file.filename),
109-
xml_id_path: (parent_object.nil? ? '' : "#{parent_object.id}/") + file.id.to_s
119+
xml_id_path: (parent_object.nil? ? [file.id] : [parent_object.id, file.id]).map(&:to_s)
110120
)
111121
if file.binary
112122
codeocean_file.native_file = FileIO.new(file.content.dup.force_encoding('UTF-8'), File.basename(file.filename))

db/migrate/20240903204319_add_xml_path_to_files.rb renamed to db/migrate/20241007204319_add_xml_path_to_files.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
class AddXmlPathToFiles < ActiveRecord::Migration[7.1]
44
def change
5-
add_column :files, :xml_id_path, :string, null: true, default: nil
5+
add_column :files, :xml_id_path, :string, array: true, default: [], null: true
66
end
77
end

db/schema.rb

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

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_exercise) { 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

spec/services/proforma_service/convert_exercise_to_task_spec.rb

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
execution_environment:,
2323
instructions: 'instruction',
2424
uuid: SecureRandom.uuid,
25-
files: files + tests)
25+
files: files + tests,
26+
unpublished:)
2627
end
28+
let(:unpublished) { false }
2729
let(:files) { [] }
2830
let(:tests) { [] }
2931
let(:execution_environment) { create(:java) }
@@ -77,6 +79,15 @@
7779
end
7880
end
7981

82+
context 'when exercise has no execution_environment' do
83+
let(:execution_environment) { nil }
84+
let(:unpublished) { true }
85+
86+
it 'creates a task with the correct proglang attribute' do
87+
expect(task).to have_attributes(proglang: nil)
88+
end
89+
end
90+
8091
context 'when exercise has a mainfile' do
8192
let(:files) { [file] }
8293
let(:file) { create(:file) }
@@ -111,13 +122,13 @@
111122
end
112123

113124
it 'sets xml_id_path to default' do
114-
expect { convert_to_task.execute }.to change(file.reload, :xml_id_path).from(nil).to(file.id.to_s)
125+
expect { convert_to_task.execute }.to change(file.reload, :xml_id_path).from([]).to([file.id.to_s])
115126
end
116127

117128
context 'when xml_id_path is set for file' do
118-
let(:file) { create(:file, xml_id_path: 'foobar') }
129+
let(:file) { create(:file, xml_id_path: ['foobar']) }
119130

120-
it 'does not change xml_path_id' do
131+
it 'does not change xml_id_path' do
121132
expect { convert_to_task.execute }.not_to change(file.reload, :xml_id_path)
122133
end
123134
end
@@ -224,7 +235,7 @@
224235
end
225236

226237
context 'when reference_implementations belong to the same proforma model_solution' do
227-
let(:files) { build_list(:file, 2, role: 'reference_implementation') {|file, i| file.xml_id_path = "proforma_ms/xml_id_#{i}" } }
238+
let(:files) { build_list(:file, 2, role: 'reference_implementation') {|file, i| file.xml_id_path = ['proforma_ms', "xml_id_#{i}"] } }
228239

229240
it 'creates a task with one model-solution' do
230241
expect(task.model_solutions).to have(1).items
@@ -294,7 +305,7 @@
294305
end
295306

296307
context 'when test_files belong to the same proforma test' do
297-
let(:tests) { build_list(:test_file, 2) {|file, i| file.xml_id_path = "proforma_test/xml_id_#{i}" } }
308+
let(:tests) { build_list(:test_file, 2) {|file, i| file.xml_id_path = ['proforma_test', "xml_id_#{i}"] } }
298309

299310
it 'creates a test with two file' do
300311
expect(task.tests.first).to have_attributes(

spec/services/proforma_service/convert_task_to_exercise_spec.rb

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -558,21 +558,73 @@
558558
context 'when the files with correct xml_id_paths already exist' do
559559
let(:exercise) do
560560
create(:dummy,
561+
unpublished: true,
561562
files: co_files,
562563
title: 'exercise-title',
563564
description: 'exercise-description')
564565
end
565566
let(:co_files) do
566-
[create(:file, xml_id_path: 'id', role: 'regular_file'),
567-
create(:file, xml_id_path: 'ms-id/ms-file-id', role: 'reference_implementation'),
568-
create(:test_file, xml_id_path: 'test-id/test-file-id')]
567+
[create(:file, xml_id_path: ['id'], role: 'regular_file'),
568+
create(:file, xml_id_path: %w[ms-id ms-file-id], role: 'reference_implementation'),
569+
create(:test_file, xml_id_path: %w[test-id test-file-id])]
569570
end
570571

571572
it 'reuses existing file' do
572573
convert_to_exercise_service
573-
574+
exercise.save!
574575
expect(exercise.reload.files).to match_array(co_files)
575576
end
577+
578+
context 'when files are move around' do
579+
let(:files) { [test_file] }
580+
let(:test_files) { [ms_file] }
581+
let(:ms_files) { [file] }
582+
583+
it 'reuses existing file' do
584+
convert_to_exercise_service
585+
exercise.save!
586+
expect(exercise.reload.files).to match_array(co_files)
587+
end
588+
end
589+
590+
context 'when some files are removed' do
591+
let(:test_files) { [] }
592+
let(:ms_files) { [] }
593+
594+
it 'removes files from exercise' do
595+
convert_to_exercise_service
596+
exercise.save!
597+
expect(exercise.reload.files.count).to be 1
598+
end
599+
600+
it 'destroys removed files' do
601+
expect { convert_to_exercise_service }.to change(CodeOcean::File, :count).by(-2)
602+
end
603+
end
604+
605+
context 'when all files are removed' do
606+
let(:files) { [] }
607+
let(:test_files) { [] }
608+
let(:ms_files) { [] }
609+
610+
it 'removes files from exercise' do
611+
convert_to_exercise_service
612+
exercise.save!
613+
expect(exercise.reload.files).to be_empty
614+
end
615+
616+
it 'destroys removed files' do
617+
expect { convert_to_exercise_service }.to change(CodeOcean::File, :count).by(-3)
618+
end
619+
620+
context 'when the import errors after the file deletion' do
621+
before { allow(exercise).to receive(:assign_attributes).and_raise(StandardError) }
622+
623+
it 'rolls back deletion of files' do
624+
expect { convert_to_exercise_service }.to raise_error(StandardError).and(avoid_change(CodeOcean::File, :count))
625+
end
626+
end
627+
end
576628
end
577629
end
578630
end

spec/services/proforma_service/import_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@
127127
end
128128

129129
context 'when exercise has multiple tests' do
130-
let(:tests) { build_list(:test_file, 2) }
130+
let(:tests) { [build(:test_file, xml_id_path: %w[test file1]), build(:test_file, xml_id_path: %w[test file2])] }
131131

132132
it { is_expected.to be_an_equal_exercise_as exercise }
133133
end

0 commit comments

Comments
 (0)