diff --git a/app/models/component.rb b/app/models/component.rb index eb8eb2dc8..4bbce5ec9 100644 --- a/app/models/component.rb +++ b/app/models/component.rb @@ -4,7 +4,6 @@ class Component < ApplicationRecord belongs_to :project validates :name, presence: true validates :extension, presence: true - validates :index, presence: true, uniqueness: { scope: :project_id } validate :default_component_protected_properties, on: :update private diff --git a/app/models/project.rb b/app/models/project.rb index 5297329cc..d7ab81ebd 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,7 +6,7 @@ class Project < ApplicationRecord before_validation :check_unique_not_null, on: :create validates :identifier, presence: true, uniqueness: true belongs_to :parent, class_name: 'Project', foreign_key: 'remixed_from_id', optional: true, inverse_of: :remixes - has_many :components, -> { order(:index) }, dependent: :destroy, inverse_of: :project + has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project has_many :remixes, class_name: 'Project', foreign_key: 'remixed_from_id', dependent: :nullify, inverse_of: :parent has_many_attached :images accepts_nested_attributes_for :components diff --git a/app/views/api/projects/show.json.jbuilder b/app/views/api/projects/show.json.jbuilder index 75dbff2c3..de8b6fa8c 100644 --- a/app/views/api/projects/show.json.jbuilder +++ b/app/views/api/projects/show.json.jbuilder @@ -4,7 +4,7 @@ json.call(@project, :identifier, :project_type, :name, :user_id) json.parent(@project.parent, :name, :identifier) if @project.parent -json.components @project.components, :id, :name, :extension, :content, :index +json.components @project.components, :id, :name, :extension, :content json.image_list @project.images do |image| json.filename image.filename diff --git a/db/migrate/20230210142728_remove_component_index_column.rb b/db/migrate/20230210142728_remove_component_index_column.rb new file mode 100644 index 000000000..25a2540de --- /dev/null +++ b/db/migrate/20230210142728_remove_component_index_column.rb @@ -0,0 +1,5 @@ +class RemoveComponentIndexColumn < ActiveRecord::Migration[7.0] + def change + remove_column :components, :index + end +end diff --git a/db/schema.rb b/db/schema.rb index 0f2b14b8a..19abe38d3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_02_09_171350) do +ActiveRecord::Schema[7.0].define(version: 2023_02_10_142728) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -50,9 +50,7 @@ t.string "content" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "index" t.boolean "default", default: false, null: false - t.index ["index", "project_id"], name: "index_components_on_index_and_project_id", unique: true t.index ["project_id"], name: "index_components_on_project_id" end diff --git a/lib/concepts/project/operations/create_remix.rb b/lib/concepts/project/operations/create_remix.rb index d8373ab90..38a9cdf2c 100644 --- a/lib/concepts/project/operations/create_remix.rb +++ b/lib/concepts/project/operations/create_remix.rb @@ -42,7 +42,7 @@ def create_remix(original_project, params, user_id) end params[:components].each do |x| - remix.components.build(x.slice(:name, :extension, :content, :index)) + remix.components.build(x.slice(:name, :extension, :content)) end remix diff --git a/lib/project_importer.rb b/lib/project_importer.rb new file mode 100644 index 000000000..9912e5593 --- /dev/null +++ b/lib/project_importer.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class ProjectImporter + attr_reader :name, :identifier, :images, :components, :type + + def initialize(name:, identifier:, type:, components:, images: []) + @name = name + @identifier = identifier + @components = components + @images = images + @type = type + end + + def import! + Project.transaction do + setup_project + delete_components + create_components + delete_removed_images + attach_images_if_needed + + project.save! + end + end + + private + + def project + @project ||= Project.find_or_initialize_by(identifier:) + end + + def setup_project + project.name = name + project.project_type = type + end + + def delete_components + project.components.each(&:destroy) + end + + def create_components + components.each do |component| + project_component = Component.new(**component) + project.components << project_component + end + end + + def delete_removed_images + return if removed_image_names.empty? + + removed_image_names.each do |filename| + img = project.images.find { |i| i.blob.filename == filename } + img.purge + end + end + + def removed_image_names + existing_images = project.images.map { |x| x.blob.filename.to_s } + existing_images - images.pluck(:filename) + end + + def attach_images_if_needed + images.each do |image| + existing_image = find_existing_image(image[:filename]) + if existing_image + next if existing_image.blob.checksum == image_checksum(image[:io]) + + existing_image.purge + end + project.images.attach(**image) + end + end + + def find_existing_image(filename) + project.images.find { |i| i.blob.filename == filename } + end + + def image_checksum(io) + OpenSSL::Digest.new('MD5').tap do |checksum| + while (chunk = io.read(5.megabytes)) + checksum << chunk + end + + io.rewind + end.base64digest + end +end diff --git a/lib/tasks/projects.rake b/lib/tasks/projects.rake index 23307857b..ffc039bba 100644 --- a/lib/tasks/projects.rake +++ b/lib/tasks/projects.rake @@ -1,78 +1,49 @@ # frozen_string_literal: true require 'yaml' +require 'project_importer' + +CODE_FORMATS = ['.py', '.csv', '.txt'].freeze +IMAGE_FORMATS = ['.png', '.jpg', '.jpeg'].freeze namespace :projects do desc 'Import starter projects' task create_starter: :environment do Dir.each_child("#{File.dirname(__FILE__)}/project_components") do |dir| proj_config = YAML.safe_load(File.read("#{File.dirname(__FILE__)}/project_components/#{dir}/project_config.yml")) - project = find_project(proj_config) - components = proj_config['COMPONENTS'] - components.each do |component| - name = component['name'] - extension = component['extension'] - code = File.read(File.dirname(__FILE__) + "/project_components/#{dir}/#{component['location']}") - index = component['index'] - default = component['default'] - project_component = Component.new(name:, extension:, content: code, index:, default:) - project.components << project_component + files = Dir.children("#{File.dirname(__FILE__)}/project_components/#{dir}") + code_files = files.filter { |file| CODE_FORMATS.include? File.extname(file) } + image_files = files.filter { |file| IMAGE_FORMATS.include? File.extname(file) } + + components = [] + code_files.each do |file| + components << component(file, dir) end - project_images = proj_config['IMAGES'] || [] - delete_removed_images(project, project_images) - project_images.each do |image_name| - attach_image_if_needed(project, image_name, dir) + images = [] + image_files.each do |file| + images << image(file, dir) end - project.save + project_importer = ProjectImporter.new(name: proj_config['NAME'], identifier: proj_config['IDENTIFIER'], + type: proj_config['TYPE'] ||= 'python', components:, images:) + project_importer.import! end end end -def find_project(proj_config) - if Project.find_by(identifier: proj_config['IDENTIFIER']).nil? - project = Project.new(identifier: proj_config['IDENTIFIER'], name: proj_config['NAME'], - project_type: proj_config['TYPE'] ||= 'python') - else - project = Project.find_by(identifier: proj_config['IDENTIFIER']) - project.name = proj_config['NAME'] - project.components.each(&:destroy) - end - - project -end - -def delete_removed_images(project, images_to_attach) - existing_images = project.images.map { |x| x.blob.filename.to_s } - diff = existing_images - images_to_attach - return if diff.empty? - - diff.each do |filename| - img = project.images.find { |i| i.blob.filename == filename } - img.purge - end -end - -def attach_image_if_needed(project, image_name, dir) - existing_image = project.images.find { |i| i.blob.filename == image_name } - - if existing_image - return if existing_image.blob.checksum == image_checksum(image_name, dir) +private - existing_image.purge - end - project.images.attach(io: File.open(File.dirname(__FILE__) + "/project_components/#{dir}/#{image_name}"), - filename: image_name) +def component(file, dir) + name = File.basename(file, '.*') + extension = File.extname(file).delete('.') + code = File.read(File.dirname(__FILE__) + "/project_components/#{dir}/#{File.basename(file)}") + default = (File.basename(file) == 'main.py') + component = { name:, extension:, content: code, default: } end -def image_checksum(image_name, dir) - io = File.open(File.dirname(__FILE__) + "/project_components/#{dir}/#{image_name}") - OpenSSL::Digest.new('MD5').tap do |checksum| - while (chunk = io.read(5.megabytes)) - checksum << chunk - end - - io.rewind - end.base64digest +def image(file, dir) + filename = File.basename(file) + io = File.open(File.dirname(__FILE__) + "/project_components/#{dir}/#{filename}") + image = { filename:, io: } end diff --git a/spec/concepts/project/create_remix_spec.rb b/spec/concepts/project/create_remix_spec.rb index 74099800d..3c1307e16 100644 --- a/spec/concepts/project/create_remix_spec.rb +++ b/spec/concepts/project/create_remix_spec.rb @@ -17,8 +17,7 @@ id: component.id, name: component.name, extension: component.extension, - content: 'some updated component content', - index: component.index + content: 'some updated component content' } ] } @@ -82,7 +81,7 @@ end context 'when a new component has been added before remixing' do - let(:new_component_params) { { name: 'added_component', extension: 'py', content: 'some added component content', index: 9999 } } + let(:new_component_params) { { name: 'added_component', extension: 'py', content: 'some added component content' } } before do remix_params[:components] << new_component_params @@ -94,7 +93,7 @@ it 'persists the new component' do remixed_project = create_remix[:project] - expect(remixed_project.components.last.attributes.symbolize_keys).to include(new_component_params) + expect(remixed_project.components.first.attributes.symbolize_keys).to include(new_component_params) end end @@ -136,7 +135,7 @@ end context 'when project components are invalid' do - let(:invalid_component_params) { { name: 'added_component', extension: 'py', content: '' } } + let(:invalid_component_params) { { name: 'added_component', content: '' } } before do remix_params[:components] << invalid_component_params @@ -156,8 +155,7 @@ def component_props(component) { name: component.name, content: component.content, - extension: component.extension, - index: component.index + extension: component.extension } end end diff --git a/spec/concepts/project/create_spec.rb b/spec/concepts/project/create_spec.rb index d1f4d92d1..aa7d35019 100644 --- a/spec/concepts/project/create_spec.rb +++ b/spec/concepts/project/create_spec.rb @@ -25,7 +25,6 @@ name: 'main', extension: 'py', content: 'print("hello world")', - index: 0, default: true }], image_list: [], diff --git a/spec/concepts/project/update_default_component_spec.rb b/spec/concepts/project/update_default_component_spec.rb index 415202355..5960fcc61 100644 --- a/spec/concepts/project/update_default_component_spec.rb +++ b/spec/concepts/project/update_default_component_spec.rb @@ -40,8 +40,7 @@ :id, :name, :content, - :extension, - :index + :extension ) end diff --git a/spec/concepts/project/update_delete_components_spec.rb b/spec/concepts/project/update_delete_components_spec.rb index 4b1a9b998..18a1aadb0 100644 --- a/spec/concepts/project/update_delete_components_spec.rb +++ b/spec/concepts/project/update_delete_components_spec.rb @@ -14,8 +14,7 @@ :id, :name, :content, - :extension, - :index + :extension ) end diff --git a/spec/concepts/project/update_invalid_spec.rb b/spec/concepts/project/update_invalid_spec.rb index 8b60be486..7c2560f56 100644 --- a/spec/concepts/project/update_invalid_spec.rb +++ b/spec/concepts/project/update_invalid_spec.rb @@ -20,8 +20,7 @@ id: editable_component.id, name: nil, content: 'updated content', - extension: 'py', - index: 5 + extension: 'py' } end @@ -46,7 +45,7 @@ end def component_properties_hash(component) - component.attributes.symbolize_keys.slice(:name, :content, :extension, :index) + component.attributes.symbolize_keys.slice(:name, :content, :extension) end def default_component_hash @@ -54,8 +53,7 @@ def default_component_hash :id, :name, :content, - :extension, - :index + :extension ) end @@ -63,8 +61,7 @@ def new_component_hash { name: 'new component', content: 'new component content', - extension: 'py', - index: 99 + extension: 'py' } end end diff --git a/spec/concepts/project/update_spec.rb b/spec/concepts/project/update_spec.rb index f28347f75..4598e578e 100644 --- a/spec/concepts/project/update_spec.rb +++ b/spec/concepts/project/update_spec.rb @@ -21,8 +21,7 @@ id: editable_component.id, name: 'updated component name', content: 'updated content', - extension: 'py', - index: 5 + extension: 'py' } end @@ -40,7 +39,7 @@ it 'updates component properties' do expect { update } .to change { component_properties_hash(editable_component.reload) } - .to(edited_component_hash.slice(:name, :content, :extension, :index)) + .to(edited_component_hash.slice(:name, :content, :extension)) end end @@ -83,8 +82,7 @@ def component_properties_hash(component) component.attributes.symbolize_keys.slice( :name, :content, - :extension, - :index + :extension ) end @@ -93,8 +91,7 @@ def default_component_hash :id, :name, :content, - :extension, - :index + :extension ) end @@ -102,8 +99,7 @@ def new_component_hash { name: 'new component', content: 'new component content', - extension: 'py', - index: 99 + extension: 'py' } end end diff --git a/spec/factories/component.rb b/spec/factories/component.rb index debe18f42..f0d2c06ad 100644 --- a/spec/factories/component.rb +++ b/spec/factories/component.rb @@ -4,14 +4,12 @@ factory :component do name { Faker::Lorem.word } extension { 'py' } - sequence(:index) { |n| n } default { false } content { Faker::Lorem.paragraph } project factory :default_python_component do name { 'main' } - index { 0 } default { true } end end diff --git a/spec/models/component_spec.rb b/spec/models/component_spec.rb index 308465152..506ab8f73 100644 --- a/spec/models/component_spec.rb +++ b/spec/models/component_spec.rb @@ -8,8 +8,6 @@ it { is_expected.to belong_to(:project) } it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:extension) } - it { is_expected.to validate_presence_of(:index) } - it { is_expected.to validate_uniqueness_of(:index).scoped_to(:project_id) } context 'when default component' do let(:component) { create(:default_python_component) } diff --git a/spec/project_importer_spec.rb b/spec/project_importer_spec.rb new file mode 100644 index 000000000..b6b8572e0 --- /dev/null +++ b/spec/project_importer_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'project_importer' + +RSpec.describe ProjectImporter do + let(:importer) do + described_class.new( + name: 'My amazing project', + identifier: 'my-amazing-project', + type: 'python', + components: [ + { name: 'main', extension: 'py', content: 'print(\'hello\')', default: true }, + { name: 'amazing', extension: 'py', content: 'print(\'this is amazing\')' } + ], + images: [ + { filename: 'my-amazing-image.png', io: File.open('spec/fixtures/files/test_image_1.png') } + ] + ) + end + + context 'when the project does not already exist in the database' do + let(:project) { Project.find_by(identifier: importer.identifier) } + + it 'saves the project to the database' do + expect { importer.import! }.to change(Project, :count).by(1) + end + + it 'names the project correctly' do + importer.import! + expect(project.name).to eq(importer.name) + end + + it 'gives the project the correct type' do + importer.import! + expect(project.project_type).to eq(importer.type) + end + + it 'creates the project components' do + importer.import! + expect(project.components.count).to eq(2) + end + + it 'creates the project images' do + importer.import! + expect(project.images.count).to eq(1) + end + end + + context 'when the project already exists in the database' do + let!(:project) do + create( + :project, + :with_default_component, + :with_components, + :with_attached_image, + component_count: 2, + identifier: 'my-amazing-project' + ) + end + + it 'does not change number of saved projects' do + expect { importer.import! }.not_to change(Project, :count) + end + + it 'renames project' do + expect { importer.import! }.to change { project.reload.name }.to(importer.name) + end + + it 'deletes removed components' do + expect { importer.import! }.to change { project.components.count }.from(3).to(2) + end + + it 'updates existing components' do + expect { importer.import! }.to change { project.reload.components[0].content }.to('print(\'hello\')') + end + + it 'creates new components' do + expect { importer.import! }.to change { project.reload.components[1].name }.to('amazing') + end + + it 'updates images' do + expect { importer.import! }.to change { project.reload.images[0].filename.to_s }.to('my-amazing-image.png') + end + end +end diff --git a/spec/requests/projects/update_spec.rb b/spec/requests/projects/update_spec.rb index 69fc72f04..835ab070e 100644 --- a/spec/requests/projects/update_spec.rb +++ b/spec/requests/projects/update_spec.rb @@ -14,8 +14,7 @@ :id, :name, :content, - :extension, - :index + :extension ) end diff --git a/spec/tasks/create_starter_spec.rb b/spec/tasks/create_starter_spec.rb new file mode 100644 index 000000000..02614d5bc --- /dev/null +++ b/spec/tasks/create_starter_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'project_importer' + +Rails.application.load_tasks + +describe 'projects:create_starter', type: :task do + subject { task.execute } + + let(:project_config) { { 'NAME' => 'My amazing project', 'IDENTIFIER' => 'my-amazing-project', 'TYPE' => 'python' } } + + it 'runs' do + allow(YAML).to receive(:safe_load).and_return(project_config) + allow(File).to receive(:read).and_return('print("hello")') + expect { Rake::Task['projects:create_starter'].invoke }.not_to raise_error + end +end