diff --git a/app/commands/git/sync_bootcamp_content.rb b/app/commands/git/sync_bootcamp_content.rb new file mode 100644 index 0000000000..28a55d3934 --- /dev/null +++ b/app/commands/git/sync_bootcamp_content.rb @@ -0,0 +1,53 @@ +class Git::SyncBootcampContent + include Mandate + + queue_as :default + + def call + repo.update! + + repo.levels.each do |data| + level = Bootcamp::Level.find_or_create_by!(idx: data.idx) do |l| + l.title = data.title + l.description = data.description + l.content_markdown = data.content + end + level.update( + title: data.title, + description: data.description, + content_markdown: data.content + ) + rescue StandardError => e + raise if Rails.env.development? + + Bugsnag.notify(e) + end + + repo.concepts.each do |data| + concept = Bootcamp::Concept.find_or_create_by!(uuid: data.uuid) do |c| + c.slug = data.slug + c.title = data.title + c.description = data.description + c.level_idx = data.level + c.apex = data.apex + end + concept.update( + slug: data.slug, + title: data.title, + description: data.description, + level_idx: data.level, + apex: data.apex + ) + rescue StandardError => e + raise if Rails.env.development? + + Bugsnag.notify(e) + end + end + + private + delegate :head_commit, to: :repo + + memoize + def repo = Git::BootcampContent.new +end diff --git a/app/models/git/bootcamp_content.rb b/app/models/git/bootcamp_content.rb new file mode 100644 index 0000000000..cc13b8b016 --- /dev/null +++ b/app/models/git/bootcamp_content.rb @@ -0,0 +1,40 @@ +class Git::BootcampContent + extend Mandate::Memoize + + DEFAULT_REPO_URL = "git@github.com:exercism/bootcamp-content.git".freeze + + attr_reader :repo + + def self.update! = new.update! + + def initialize(repo_url: DEFAULT_REPO_URL, repo: nil) + @repo = repo || Git::Repository.new(repo_url:) + end + + def update! = repo.fetch! + + memoize + def levels + Git::BootcampContent::Levels.new(repo:) + end + + memoize + def projects + project_dir_entries.map do |entry| + Git::BootcampContent::Project.new(entry[:name], repo:) + end + end + + memoize + def concepts + Git::BootcampContent::Concepts.new(repo:) + end + + private + delegate :head_commit, to: :repo + + memoize + def project_dir_entries + repo.fetch_tree(repo.head_commit, "projects").select { |entry| entry[:type] == :tree } + end +end diff --git a/app/models/git/bootcamp_content/concept.rb b/app/models/git/bootcamp_content/concept.rb new file mode 100644 index 0000000000..2c260f28e9 --- /dev/null +++ b/app/models/git/bootcamp_content/concept.rb @@ -0,0 +1,41 @@ +class Git::BootcampContent::Concept + extend Mandate::Memoize + extend Mandate::InitializerInjector + extend Git::HasGitFilepath + + delegate :head_sha, :lookup_commit, :head_commit, to: :repo + + attr_reader :config + + def initialize(config, repo_url: Git::BootcampContent::DEFAULT_REPO_URL, repo: nil) + @repo = repo || Git::Repository.new(repo_url:) + @config = config + end + + memoize + def uuid = config[:uuid] + def slug = config[:slug] + def title = config[:title] + def description = config[:description] + def level = config[:level] + def apex = !!config[:apex] + def content = read_file_blob("#{slug}.md") + + private + attr_reader :repo + + def absolute_filepath(filepath) = "#{dir}/#{filepath}" + + memoize + def commit = repo.lookup_commit(repo.head_sha) + + memoize + def absolute_filepaths = filepaths.map { |filepath| absolute_filepath(filepath) } + def filepaths = file_entries.map { |defn| defn[:full] } + def dir = "concepts" + + def read_file_blob(filepath) + mapped = file_entries.map { |f| [f[:full], f[:oid]] }.to_h + mapped[filepath] ? repo.read_blob(mapped[filepath]) : nil + end +end diff --git a/app/models/git/bootcamp_content/concepts.rb b/app/models/git/bootcamp_content/concepts.rb new file mode 100644 index 0000000000..b1097b9967 --- /dev/null +++ b/app/models/git/bootcamp_content/concepts.rb @@ -0,0 +1,45 @@ +class Git::BootcampContent::Concepts + extend Mandate::Memoize + extend Mandate::InitializerInjector + extend Git::HasGitFilepath + + delegate :head_sha, :lookup_commit, :head_commit, to: :repo + + git_filepath :config, file: "config.json" + + def initialize(repo_url: Git::BootcampContent::DEFAULT_REPO_URL, repo: nil) + @repo = repo || Git::Repository.new(repo_url:) + end + + memoize + def concepts + concept_documents.map do |entry| + concept_config = config.find { |config| config[:slug] == File.basename(entry[:name], ".*") } + Git::BootcampContent::Concept.new( + concept_config, + repo: + ) + end + end + + def each(&block) = concepts.each(&block) + + private + delegate :head_commit, to: :repo + attr_reader :repo + + memoize + def concept_documents + repo.fetch_tree(repo.head_commit, dir).select { |entry| entry[:type] == :blob && File.extname(entry[:name]) == ".md" } + end + + def absolute_filepath(filepath) = "#{dir}/#{filepath}" + + memoize + def commit = repo.lookup_commit(repo.head_sha) + + memoize + def absolute_filepaths = filepaths.map { |filepath| absolute_filepath(filepath) } + def filepaths = file_entries.map { |defn| defn[:full] } + def dir = "concepts" +end diff --git a/app/models/git/bootcamp_content/level.rb b/app/models/git/bootcamp_content/level.rb new file mode 100644 index 0000000000..2f7b56c8fe --- /dev/null +++ b/app/models/git/bootcamp_content/level.rb @@ -0,0 +1,34 @@ +class Git::BootcampContent::Level + extend Mandate::Memoize + extend Mandate::InitializerInjector + extend Git::HasGitFilepath + + delegate :head_sha, :lookup_commit, :head_commit, to: :repo + + git_filepath :content, file: "content.md" + + attr_reader :idx, :config + + def initialize(idx, config, repo_url: Git::BootcampContent::DEFAULT_REPO_URL, repo: nil) + @repo = repo || Git::Repository.new(repo_url:) + @idx = idx + @config = config + end + + memoize + def title = config[:title] + def description = config[:description] + + private + attr_reader :repo + + def absolute_filepath(filepath) = "#{dir}/#{filepath}" + + memoize + def commit = repo.lookup_commit(repo.head_sha) + + memoize + def absolute_filepaths = filepaths.map { |filepath| absolute_filepath(filepath) } + def filepaths = file_entries.map { |defn| defn[:full] } + def dir = "levels/#{idx}" +end diff --git a/app/models/git/bootcamp_content/levels.rb b/app/models/git/bootcamp_content/levels.rb new file mode 100644 index 0000000000..fe447dc31d --- /dev/null +++ b/app/models/git/bootcamp_content/levels.rb @@ -0,0 +1,46 @@ +class Git::BootcampContent::Levels + extend Mandate::Memoize + extend Mandate::InitializerInjector + extend Git::HasGitFilepath + + delegate :head_sha, :lookup_commit, :head_commit, to: :repo + + git_filepath :config, file: "config.json" + + def initialize(repo_url: Git::BootcampContent::DEFAULT_REPO_URL, repo: nil) + @repo = repo || Git::Repository.new(repo_url:) + end + + memoize + def levels + level_dir_entries.map do |entry| + level_config = config.find { |config| config[:idx] == entry[:name].to_i } + Git::BootcampContent::Level.new( + entry[:name], + level_config, + repo: + ) + end + end + + def each(&block) = levels.each(&block) + + private + delegate :head_commit, to: :repo + attr_reader :repo + + memoize + def level_dir_entries + repo.fetch_tree(repo.head_commit, "levels").select { |entry| entry[:type] == :tree } + end + + def absolute_filepath(filepath) = "#{dir}/#{filepath}" + + memoize + def commit = repo.lookup_commit(repo.head_sha) + + memoize + def absolute_filepaths = filepaths.map { |filepath| absolute_filepath(filepath) } + def filepaths = file_entries.map { |defn| defn[:full] } + def dir = "levels" +end diff --git a/app/models/git/problem_specifications/exercise.rb b/app/models/git/problem_specifications/exercise.rb index c9af0001cc..2674b6d72a 100644 --- a/app/models/git/problem_specifications/exercise.rb +++ b/app/models/git/problem_specifications/exercise.rb @@ -62,7 +62,7 @@ def file_entries tree.walk(:preorder).map do |root, entry| next if entry[:type] == :tree - entry[:full] = "#{root}#{entry[:name]}" + entry[:full] = "#{root}#yentry[:name]}" entry end.compact rescue Rugged::TreeError diff --git a/bootcamp_content/levels/1.md b/bootcamp_content/levels/1.md deleted file mode 100644 index 6969ca68ef..0000000000 --- a/bootcamp_content/levels/1.md +++ /dev/null @@ -1,34 +0,0 @@ -# Level 1 - -## About Levels - -Welcome to Level One! - -Levels are the core of your learning experience on this Bootcamp. - -Each week we'll unlock a new level, with new concepts to learn and exercises to solve. Our teaching sessions appear here for you to watch live, and then stay for you to watch back on demand. - -I strongly recommend finishing all of the exercises on a level before moving onto the next one. -However, if you get really stuck on one exercise, you might still like to continue on in the next level while waiting for help. -If you feel like a level is too easy, then it shouldn't take you any time at all to solve the exercises within it, so use that for practice anyway. - -### How to Complete a Level - -To complete a level, you need to solve all the exercises on the right hand side. - -## Your first steps - -This week largely revolves around drawing using code. -The objective is for you to be able to draw fun things without feeling like you've having to think too hard about getting the right syntax (ie what to write where). -Your drawing ability should be what limits you - not your coding ability! - -We have students on this Bootcamp with a wide range of experience-levels, from total beginners to people in full-time dev jobs. - -For those just starting out, this first week will contain lots of new information and thinking for you to absorb. -The most important thing is to take your time and build confidence. - -For those who are experienced, these first few weeks will feel familiar and probably quite easy. -But I strongly advise you to still watch the videos and explore the mental models I'm building, as they will be different from the way you've approached things before. - -Most of all, have fun! -And we'll dig into some more interesting things at Level 2! diff --git a/bootcamp_content/levels/2.md b/bootcamp_content/levels/2.md deleted file mode 100644 index 6a13d568e1..0000000000 --- a/bootcamp_content/levels/2.md +++ /dev/null @@ -1,7 +0,0 @@ -# Level 2 - -Welcome to Level 2! - -Last week we touched on the absolute basics of coding - using functions. Hopefully you had lots of fun testing your drawing abilities, and getting creative. - -In Level 2, we're going to build on what we learned last week, and look at when code moves beyond a linear list of commands, exploring loops, conditionals, variables, and writing our own functions. This is the most full content-rich week of the whole Bootcamp, and we'll cover quite a lot of ground, so take it slowly and make sure you understand each part thoroughly. diff --git a/bootcamp_content/levels/3.md b/bootcamp_content/levels/3.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/bootcamp_content/levels/4.md b/bootcamp_content/levels/4.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/bootcamp_content/levels/config.json b/bootcamp_content/levels/config.json deleted file mode 100644 index 32912b63a9..0000000000 --- a/bootcamp_content/levels/config.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "title": "Writing your first code", - "description": "Learn how to write your first lines of code" - }, - { - "title": "Conditionals and Returns", - "description": "" - }, - { - "title": "Loops", - "description": "" - }, - { - "title": "Arrays", - "description": "" - } -] diff --git a/db/migrate/20250109171959_add_uuids_to_bootcamp.rb b/db/migrate/20250109171959_add_uuids_to_bootcamp.rb new file mode 100644 index 0000000000..9d09978198 --- /dev/null +++ b/db/migrate/20250109171959_add_uuids_to_bootcamp.rb @@ -0,0 +1,14 @@ +class AddUuidsToBootcamp < ActiveRecord::Migration[7.0] + def change + return if Rails.env.production? + + add_column :bootcamp_concepts, :uuid, :string, null: true + add_column :bootcamp_projects, :uuid, :string, null: true + add_column :bootcamp_exercises, :uuid, :string, null: true + + # change_column_null :bootcamp_concepts, :uuid, false + # change_column_null :bootcamp_projects, :uuid, false + # change_column_null :bootcamp_exercises, :uuid, false + + end +end diff --git a/db/schema.rb b/db/schema.rb index bf95f788df..e5f42d6021 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: 2025_01_08_193404) do +ActiveRecord::Schema[7.0].define(version: 2025_01_09_171959) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -39,6 +39,11 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "add_uuid_to_code_tags_samples", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "badges", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "type", null: false t.string "name", null: false @@ -81,6 +86,7 @@ t.text "content_html", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "uuid" t.index ["parent_id"], name: "fk_rails_a7c513f5e1" end @@ -90,6 +96,7 @@ t.text "code", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "title", null: false t.index ["user_id"], name: "index_bootcamp_drawings_on_user_id" end @@ -112,6 +119,7 @@ t.integer "level_idx", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "uuid" t.index ["level_idx"], name: "index_bootcamp_exercises_on_level_idx" t.index ["project_id", "slug"], name: "index_bootcamp_exercises_on_project_id_and_slug", unique: true t.index ["project_id"], name: "index_bootcamp_exercises_on_project_id" @@ -135,6 +143,7 @@ t.text "introduction_html", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "uuid" end create_table "bootcamp_settings", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| @@ -249,6 +258,11 @@ t.index ["watch_id", "exercise_id"], name: "index_community_videos_on_watch_id_and_exercise_id", unique: true end + create_table "conversations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "documents", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "uuid", null: false t.bigint "track_id" @@ -489,9 +503,9 @@ end create_table "exercise_tags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "exercise_id", null: false t.string "tag", null: false t.boolean "filterable", default: true, null: false - t.bigint "exercise_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["exercise_id", "tag"], name: "index_exercise_tags_on_exercise_id_and_tag", unique: true @@ -1015,10 +1029,10 @@ end create_table "solution_tags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.string "tag", null: false t.bigint "solution_id", null: false t.bigint "exercise_id", null: false t.bigint "user_id", null: false + t.string "tag", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "track_id", null: false @@ -1064,11 +1078,9 @@ t.integer "latest_iteration_head_tests_status", limit: 1, default: 0, null: false t.boolean "unlocked_help", default: false, null: false t.bigint "published_exercise_representation_id" - t.bigint "exercise_approach_id" t.index ["approved_by_id"], name: "fk_rails_4cc89d0b11" t.index ["created_at", "exercise_id"], name: "mentor_selection_idx_1" t.index ["created_at", "exercise_id"], name: "mentor_selection_idx_2" - t.index ["exercise_approach_id"], name: "index_solutions_on_exercise_approach_id" t.index ["exercise_id", "approved_by_id", "completed_at", "mentoring_requested_at", "id"], name: "mentor_selection_idx_3" t.index ["exercise_id", "git_important_files_hash"], name: "index_solutions_on_exercise_id_and_git_important_files_hash" t.index ["exercise_id", "status", "num_stars", "updated_at"], name: "solutions_ex_stat_stars_upat" @@ -1159,6 +1171,11 @@ t.index ["track_id"], name: "index_submission_representations_on_track_id" end + create_table "submission_tags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "submission_test_runs", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "uuid", null: false t.bigint "submission_id", null: false @@ -1200,6 +1217,7 @@ t.integer "exercise_representer_version", limit: 2, default: 1, null: false t.bigint "approach_id" t.json "tags" + t.index "`exercise_id`, `approach_id`, (json_value(`tags`, _utf8mb4\\'$[0]\\' returning char(512) null on empty))", name: "index_submissions_exercise_approach_tags" t.index ["approach_id"], name: "index_submissions_on_approach_id" t.index ["exercise_id", "git_important_files_hash"], name: "index_submissions_on_exercise_id_and_git_important_files_hash" t.index ["git_important_files_hash", "solution_id"], name: "submissions-git-optimiser-2" @@ -1423,12 +1441,12 @@ t.string "name" t.string "email" t.string "ppp_country" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "checkout_session_id" t.text "utm" - t.integer "level_idx", null: false, default: 0 + t.integer "level_idx", default: 0, null: false t.string "access_code" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.index ["user_id"], name: "index_user_bootcamp_data_on_user_id", unique: true end @@ -1466,11 +1484,11 @@ t.boolean "email_about_events", default: true, null: false t.boolean "email_about_insiders", default: true, null: false t.boolean "email_on_acquired_trophy_notification", default: true, null: false + t.boolean "receive_onboarding_emails", default: true, null: false t.boolean "email_on_nudge_student_to_reply_in_discussion_notification", default: true, null: false t.boolean "email_on_nudge_mentor_to_reply_in_discussion_notification", default: true, null: false t.boolean "email_on_mentor_timed_out_discussion_notification", default: true, null: false t.boolean "email_on_student_timed_out_discussion_notification", default: true, null: false - t.boolean "receive_onboarding_emails", default: true, null: false t.index ["token"], name: "index_user_communication_preferences_on_token" t.index ["user_id"], name: "fk_rails_65642a5510" end @@ -1724,7 +1742,7 @@ t.string "video_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "context" + t.string "context", default: "0", null: false t.index ["context"], name: "index_user_watched_videos_on_context" t.index ["user_id", "video_provider", "video_id"], name: "user_watched_videos_uniq", unique: true t.index ["user_id"], name: "index_user_watched_videos_on_user_id" @@ -1773,6 +1791,8 @@ add_foreign_key "bootcamp_exercise_concepts", "bootcamp_concepts", column: "concept_id" add_foreign_key "bootcamp_exercise_concepts", "bootcamp_exercises", column: "exercise_id" add_foreign_key "bootcamp_submissions", "bootcamp_solutions", column: "solution_id" + add_foreign_key "bootcamp_user_projects", "bootcamp_projects", column: "project_id" + add_foreign_key "bootcamp_user_projects", "users" add_foreign_key "cohort_memberships", "cohorts" add_foreign_key "cohort_memberships", "users" add_foreign_key "cohorts", "tracks" @@ -1852,7 +1872,6 @@ add_foreign_key "solution_tags", "exercises" add_foreign_key "solution_tags", "solutions" add_foreign_key "solution_tags", "users" - add_foreign_key "solutions", "exercise_approaches" add_foreign_key "solutions", "exercise_representations", column: "published_exercise_representation_id" add_foreign_key "solutions", "exercises" add_foreign_key "solutions", "iterations", column: "published_iteration_id" @@ -1909,4 +1928,4 @@ add_foreign_key "user_track_viewed_exercise_approaches", "users" add_foreign_key "user_tracks", "tracks" add_foreign_key "user_tracks", "users" -end \ No newline at end of file +end