Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/models/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/views/api/projects/show.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20230210142728_remove_component_index_column.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class RemoveComponentIndexColumn < ActiveRecord::Migration[7.0]
def change
remove_column :components, :index
end
end
4 changes: 1 addition & 3 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/concepts/project/operations/create_remix.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions lib/project_importer.rb
Original file line number Diff line number Diff line change
@@ -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
85 changes: 28 additions & 57 deletions lib/tasks/projects.rake
Original file line number Diff line number Diff line change
@@ -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
12 changes: 5 additions & 7 deletions spec/concepts/project/create_remix_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
]
}
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
1 change: 0 additions & 1 deletion spec/concepts/project/create_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
name: 'main',
extension: 'py',
content: 'print("hello world")',
index: 0,
default: true
}],
image_list: [],
Expand Down
3 changes: 1 addition & 2 deletions spec/concepts/project/update_default_component_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@
:id,
:name,
:content,
:extension,
:index
:extension
)
end

Expand Down
3 changes: 1 addition & 2 deletions spec/concepts/project/update_delete_components_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
:id,
:name,
:content,
:extension,
:index
:extension
)
end

Expand Down
11 changes: 4 additions & 7 deletions spec/concepts/project/update_invalid_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
id: editable_component.id,
name: nil,
content: 'updated content',
extension: 'py',
index: 5
extension: 'py'
}
end

Expand All @@ -46,25 +45,23 @@
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
default_component.attributes.symbolize_keys.slice(
:id,
:name,
:content,
:extension,
:index
:extension
)
end

def new_component_hash
{
name: 'new component',
content: 'new component content',
extension: 'py',
index: 99
extension: 'py'
}
end
end
Loading