diff --git a/lib/tasks/project_preview_seeds_helper.rb b/lib/tasks/project_preview_seeds_helper.rb new file mode 100644 index 000000000..e09590017 --- /dev/null +++ b/lib/tasks/project_preview_seeds_helper.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module ProjectPreviewSeedsHelper + # Public Blocks template for Experience CS project preview (unowned, loadable without auth). + PROJECT_PREVIEW_IDENTIFIER = 'excs-preview-starter' + PROJECT_PREVIEW_LOCALE = 'en' + PROJECT_PREVIEW_LOCALE_FR = 'fr-FR' + PROJECT_PREVIEW_NAME = 'Experience CS Preview Starter' + PROJECT_PREVIEW_NAME_FR = 'Démo Aperçu Experience CS' + PROJECT_PREVIEW_CONTENT_PATH = Rails.root.join('lib/tasks/seed_data/excs_preview_starter.json') + PROJECT_PREVIEW_INSTRUCTIONS_PATH = Rails.root.join('lib/tasks/seed_data/excs_preview_starter_instructions.json') + PROJECT_PREVIEW_INSTRUCTIONS_FR_PATH = Rails.root.join('lib/tasks/seed_data/excs_preview_starter_instructions_fr.json') + + def create_public_scratch_preview_project + [ + { + locale: PROJECT_PREVIEW_LOCALE, + name: PROJECT_PREVIEW_NAME, + instructions: public_scratch_preview_instructions + }, + { + locale: PROJECT_PREVIEW_LOCALE_FR, + name: PROJECT_PREVIEW_NAME_FR, + instructions: public_scratch_preview_instructions_fr + } + ].map { |attrs| upsert_public_scratch_preview_project(**attrs) } + end + + def upsert_public_scratch_preview_project(locale:, name:, instructions:) + project = Project.find_or_initialize_by( + identifier: PROJECT_PREVIEW_IDENTIFIER, + locale: + ) + raise "Refusing to overwrite non-public project '#{PROJECT_PREVIEW_IDENTIFIER}' (#{locale})" if project.persisted? && (project.user_id.present? || project.school_id.present?) + + Rails.logger.info "Seeding public Scratch preview project '#{PROJECT_PREVIEW_IDENTIFIER}' (#{locale})..." + project.name = name + project.user_id = nil + project.school_id = nil + project.project_type = Project::Types::CODE_EDITOR_SCRATCH + project.instructions = instructions + project.scratch_component ||= ScratchComponent.new + project.scratch_component.content = public_scratch_preview_content + project.save! + project + end + + def destroy_public_scratch_preview_project + Project.where( + identifier: PROJECT_PREVIEW_IDENTIFIER, + user_id: nil, + school_id: nil, + project_type: Project::Types::CODE_EDITOR_SCRATCH + ).destroy_all + end + + def public_scratch_preview_content + JSON.parse(File.read(PROJECT_PREVIEW_CONTENT_PATH)) + end + + def public_scratch_preview_instructions + JSON.parse(File.read(PROJECT_PREVIEW_INSTRUCTIONS_PATH)) + end + + def public_scratch_preview_instructions_fr + JSON.parse(File.read(PROJECT_PREVIEW_INSTRUCTIONS_FR_PATH)) + end +end diff --git a/lib/tasks/seed_data/excs_preview_starter.json b/lib/tasks/seed_data/excs_preview_starter.json new file mode 100644 index 000000000..298a4f11b --- /dev/null +++ b/lib/tasks/seed_data/excs_preview_starter.json @@ -0,0 +1,100 @@ +{ + "targets": [ + { + "isStage": true, + "name": "Stage", + "variables": { + "`jEk@4|i[#Fk?(8x)AV.-my variable": ["my variable", 0] + }, + "lists": {}, + "broadcasts": {}, + "blocks": {}, + "comments": {}, + "currentCostume": 0, + "costumes": [ + { + "name": "backdrop1", + "dataFormat": "svg", + "assetId": "cd21514d0531fdffb22204e0ec5ed84a", + "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg", + "rotationCenterX": 240, + "rotationCenterY": 180 + } + ], + "sounds": [ + { + "name": "pop", + "assetId": "83a9787d4cb6f3b7632b4ddfebf74367", + "dataFormat": "wav", + "format": "", + "rate": 48000, + "sampleCount": 1123, + "md5ext": "83a9787d4cb6f3b7632b4ddfebf74367.wav" + } + ], + "volume": 100, + "layerOrder": 0, + "tempo": 60, + "videoTransparency": 50, + "videoState": "on", + "textToSpeechLanguage": null + }, + { + "isStage": false, + "name": "Sprite1", + "variables": {}, + "lists": {}, + "broadcasts": {}, + "blocks": {}, + "comments": {}, + "currentCostume": 0, + "costumes": [ + { + "name": "costume1", + "bitmapResolution": 1, + "dataFormat": "svg", + "assetId": "bcf454acf82e4504149f7ffe07081dbc", + "md5ext": "bcf454acf82e4504149f7ffe07081dbc.svg", + "rotationCenterX": 48, + "rotationCenterY": 50 + }, + { + "name": "costume2", + "bitmapResolution": 1, + "dataFormat": "svg", + "assetId": "0fb9be3e8397c983338cb71dc84d0b25", + "md5ext": "0fb9be3e8397c983338cb71dc84d0b25.svg", + "rotationCenterX": 46, + "rotationCenterY": 53 + } + ], + "sounds": [ + { + "name": "Meow", + "assetId": "83c36d806dc92327b9e7049a565c6bff", + "dataFormat": "wav", + "format": "", + "rate": 48000, + "sampleCount": 40681, + "md5ext": "83c36d806dc92327b9e7049a565c6bff.wav" + } + ], + "volume": 100, + "layerOrder": 1, + "visible": true, + "x": 0, + "y": 0, + "size": 100, + "direction": 90, + "draggable": false, + "rotationStyle": "all around" + } + ], + "monitors": [], + "extensions": [], + "meta": { + "semver": "3.0.0", + "vm": "12.7.0", + "agent": "default" + } +} diff --git a/lib/tasks/seed_data/excs_preview_starter_instructions.json b/lib/tasks/seed_data/excs_preview_starter_instructions.json new file mode 100644 index 000000000..bf3fc380e --- /dev/null +++ b/lib/tasks/seed_data/excs_preview_starter_instructions.json @@ -0,0 +1,11 @@ +[ + { + "markdown_content": "## Welcome\n\nThis is a sample Experience CS Blocks project you can try without signing in." + }, + { + "markdown_content": "## Try it out\n\n1. Click the green flag to run the project.\n2. Explore the sprites and backdrops.\n3. Change a block and run it again." + }, + { + "markdown_content": "## What's next?\n\nWhen you use this project in a class, teachers can add their own steps here for learners." + } +] diff --git a/lib/tasks/seed_data/excs_preview_starter_instructions_fr.json b/lib/tasks/seed_data/excs_preview_starter_instructions_fr.json new file mode 100644 index 000000000..b4f061236 --- /dev/null +++ b/lib/tasks/seed_data/excs_preview_starter_instructions_fr.json @@ -0,0 +1,11 @@ +[ + { + "markdown_content": "## Bienvenue\n\nVoici un projet Blocks Experience CS d'exemple que vous pouvez essayer sans vous connecter." + }, + { + "markdown_content": "## Essayez\n\n1. Cliquez sur le drapeau vert pour lancer le projet.\n2. Explorez les lutins et les arrière-plans.\n3. Modifiez un bloc et relancez." + }, + { + "markdown_content": "## Et ensuite ?\n\nLorsque vous utilisez ce projet en classe, les enseignants peuvent ajouter ici leurs propres étapes pour les élèves." + } +] diff --git a/lib/tasks/seeds_helper.rb b/lib/tasks/seeds_helper.rb index 5ee229192..0bdb27ae1 100644 --- a/lib/tasks/seeds_helper.rb +++ b/lib/tasks/seeds_helper.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +require_relative 'project_preview_seeds_helper' + module SeedsHelper + include ProjectPreviewSeedsHelper + TEST_USERS = { jane_doe: '583ba872-b16e-46e1-9f7d-df89d267550d', # jane.doe@example.com john_doe: 'bbb9b8fd-f357-4238-983d-6f87b99bdbb2', # john.doe@example.com diff --git a/lib/tasks/test_seeds.rake b/lib/tasks/test_seeds.rake index 9a7bbcc2b..76abef66c 100644 --- a/lib/tasks/test_seeds.rake +++ b/lib/tasks/test_seeds.rake @@ -37,6 +37,8 @@ namespace :test_seeds do school_ids = [school_id, teacher_signup_school_id].compact School.where(id: school_ids).destroy_all + destroy_public_scratch_preview_project + # Clear any feature flags that may have been set Flipper.features.each(&:remove) @@ -47,10 +49,13 @@ namespace :test_seeds do desc 'Create a school with lessons and students' task create: :environment do if School.exists?(id: TEST_SCHOOL) - puts "Test school (#{TEST_SCHOOL}) already exists, run the destroy_seed_data task to start over)." + create_public_scratch_preview_project + puts "Test school (#{TEST_SCHOOL}) already exists, run the destroy_seed_data task to start over." else ActiveRecord::Base.transaction do Rails.logger.info 'Attempting to seed data...' + create_public_scratch_preview_project + creator_id = ENV.fetch('SEEDING_CREATOR_ID', TEST_USERS[:jane_doe]) teacher_id = ENV.fetch('SEEDING_TEACHER_ID', TEST_USERS[:john_doe]) diff --git a/spec/lib/test_seeds_spec.rb b/spec/lib/test_seeds_spec.rb index 8c88d54d1..1dd11cb5e 100644 --- a/spec/lib/test_seeds_spec.rb +++ b/spec/lib/test_seeds_spec.rb @@ -39,6 +39,42 @@ expect(ScratchAsset.where(project_id: scratch_project_id)).not_to exist end + it 'destroys the public Scratch preview project' do + create( + :scratch_project, + identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER, + locale: SeedsHelper::PROJECT_PREVIEW_LOCALE, + user_id: nil, + name: SeedsHelper::PROJECT_PREVIEW_NAME + ) + create( + :scratch_project, + identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER, + locale: SeedsHelper::PROJECT_PREVIEW_LOCALE_FR, + user_id: nil, + name: SeedsHelper::PROJECT_PREVIEW_NAME_FR + ) + + task.invoke + + expect( + Project.where(identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER) + ).not_to exist + end + + it 'does not destroy owned projects that share the preview identifier' do + owned = create( + :scratch_project, + identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER, + locale: SeedsHelper::PROJECT_PREVIEW_LOCALE, + user_id: SecureRandom.uuid + ) + + task.invoke + + expect(Project.find_by(id: owned.id)).to be_present + end + it 'removes all feature flags' do Flipper.enable(:some_feature) task.invoke @@ -60,6 +96,74 @@ expect(School.find_by(creator_id:).verified_at).to be_truthy end + it 'creates a public Scratch preview project' do + project = Project.find_by!( + identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER, + locale: SeedsHelper::PROJECT_PREVIEW_LOCALE + ) + + expect(project).to have_attributes( + name: SeedsHelper::PROJECT_PREVIEW_NAME, + user_id: nil, + school_id: nil, + project_type: Project::Types::CODE_EDITOR_SCRATCH + ) + expect(project.scratch_component).to be_present + expect(project.scratch_component.content.to_h).to include('targets') + expect(project.instructions).to eq( + JSON.parse(File.read(SeedsHelper::PROJECT_PREVIEW_INSTRUCTIONS_PATH)) + ) + end + + it 'creates a French locale public Scratch preview project' do + project = Project.find_by!( + identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER, + locale: SeedsHelper::PROJECT_PREVIEW_LOCALE_FR + ) + + expect(project).to have_attributes( + name: SeedsHelper::PROJECT_PREVIEW_NAME_FR, + user_id: nil, + school_id: nil, + project_type: Project::Types::CODE_EDITOR_SCRATCH + ) + expect(project.scratch_component).to be_present + expect(project.instructions).to eq( + JSON.parse(File.read(SeedsHelper::PROJECT_PREVIEW_INSTRUCTIONS_FR_PATH)) + ) + end + + it 'updates instructions on an existing public Scratch preview project' do + project = Project.find_by!( + identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER, + locale: SeedsHelper::PROJECT_PREVIEW_LOCALE + ) + project.update!(instructions: [{ markdown_content: 'stale step' }]) + + task.reenable + task.invoke + + expect(project.reload.instructions).to eq( + JSON.parse(File.read(SeedsHelper::PROJECT_PREVIEW_INSTRUCTIONS_PATH)) + ) + end + + it 'refuses to overwrite a non-public project with the preview identifier' do + Project.where(identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER).destroy_all + create( + :scratch_project, + identifier: SeedsHelper::PROJECT_PREVIEW_IDENTIFIER, + locale: SeedsHelper::PROJECT_PREVIEW_LOCALE, + user_id: SecureRandom.uuid + ) + + task.reenable + expect { task.invoke }.to raise_error( + RuntimeError, + /Refusing to overwrite non-public project/ + ) + end + it 'creates lessons with projects' do school = School.find_by(creator_id:) expect(SchoolClass.where(school_id: school.id)).to exist