From 5e1b108c8a6f7c0c598e5577e8986b6cfe261889 Mon Sep 17 00:00:00 2001 From: A-Mozeak Date: Sat, 29 Aug 2026 15:46:26 -0400 Subject: [PATCH 1/6] Generate delegatable model. --- app/models/content.rb | 4 ++++ db/migrate/20260829190944_create_contents.rb | 11 +++++++++++ test/fixtures/contents.yml | 11 +++++++++++ test/models/content_test.rb | 7 +++++++ 4 files changed, 33 insertions(+) create mode 100644 app/models/content.rb create mode 100644 db/migrate/20260829190944_create_contents.rb create mode 100644 test/fixtures/contents.yml create mode 100644 test/models/content_test.rb diff --git a/app/models/content.rb b/app/models/content.rb new file mode 100644 index 0000000..73a2b92 --- /dev/null +++ b/app/models/content.rb @@ -0,0 +1,4 @@ +class Content < ApplicationRecord + belongs_to :link + belongs_to :game +end diff --git a/db/migrate/20260829190944_create_contents.rb b/db/migrate/20260829190944_create_contents.rb new file mode 100644 index 0000000..485dbd6 --- /dev/null +++ b/db/migrate/20260829190944_create_contents.rb @@ -0,0 +1,11 @@ +class CreateContents < ActiveRecord::Migration[8.1] + def change + create_table :contents do |t| + t.references :link, foreign_key: true + t.references :game, foreign_key: true + t.references :contentable, polymorphic: true, null: false + + t.timestamps + end + end +end diff --git a/test/fixtures/contents.yml b/test/fixtures/contents.yml new file mode 100644 index 0000000..0f47a3e --- /dev/null +++ b/test/fixtures/contents.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + link: one + game: one + contentable_type: MyString + +two: + link: two + game: two + contentable_type: MyString diff --git a/test/models/content_test.rb b/test/models/content_test.rb new file mode 100644 index 0000000..b9d33a2 --- /dev/null +++ b/test/models/content_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ContentTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 69d64eacf6b2145f405c818635e3d02a7e88f8fe Mon Sep 17 00:00:00 2001 From: A-Mozeak Date: Sat, 29 Aug 2026 16:27:58 -0400 Subject: [PATCH 2/6] Rework contentable model. --- app/models/content.rb | 5 +-- db/migrate/20260829190944_create_contents.rb | 5 ++- db/schema.rb | 36 ++++++++++++++++---- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/app/models/content.rb b/app/models/content.rb index 73a2b92..a7a67d5 100644 --- a/app/models/content.rb +++ b/app/models/content.rb @@ -1,4 +1,5 @@ class Content < ApplicationRecord - belongs_to :link - belongs_to :game + belongs_to :content_module, optional: true + + delegated_type :contentable, types: %w[Link Game] end diff --git a/db/migrate/20260829190944_create_contents.rb b/db/migrate/20260829190944_create_contents.rb index 485dbd6..5bc4216 100644 --- a/db/migrate/20260829190944_create_contents.rb +++ b/db/migrate/20260829190944_create_contents.rb @@ -1,9 +1,8 @@ class CreateContents < ActiveRecord::Migration[8.1] def change create_table :contents do |t| - t.references :link, foreign_key: true - t.references :game, foreign_key: true - t.references :contentable, polymorphic: true, null: false + t.references :content_module, foreign_key: true + t.references :contentable, polymorphic: true, null: false, index: true t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index dfe20b6..2d58550 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[8.1].define(version: 2026_08_29_144558) do +ActiveRecord::Schema[8.1].define(version: 2026_08_29_195031) do create_table "classroom_modules", force: :cascade do |t| t.integer "classroom_program_id", null: false t.integer "content_module_id", null: false @@ -55,26 +55,47 @@ t.index ["program_id"], name: "index_content_modules_on_program_id" end - create_table "games", force: :cascade do |t| + create_table "contents", force: :cascade do |t| t.integer "content_module_id" + t.integer "contentable_id", null: false + t.string "contentable_type", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["content_module_id"], name: "index_contents_on_content_module_id" + t.index ["contentable_type", "contentable_id"], name: "index_contents_on_contentable" + end + + create_table "game_attempts", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "game_id", null: false + t.string "outcome" + t.integer "score" + t.datetime "started_at" + t.integer "student_id", null: false + t.string "token", null: false + t.datetime "updated_at", null: false + t.index ["game_id"], name: "index_game_attempts_on_game_id" + t.index ["student_id"], name: "index_game_attempts_on_student_id" + t.index ["token"], name: "index_game_attempts_on_token", unique: true + end + + create_table "games", force: :cascade do |t| t.datetime "created_at", null: false t.text "description" t.string "slug", null: false t.string "title", null: false t.datetime "updated_at", null: false - t.index ["content_module_id"], name: "index_games_on_content_module_id" t.index ["slug"], name: "index_games_on_slug", unique: true end create_table "links", force: :cascade do |t| - t.integer "content_module_id", null: false t.datetime "created_at", null: false t.string "link_type", null: false t.integer "position" t.string "title", null: false t.datetime "updated_at", null: false t.string "url", null: false - t.index ["content_module_id"], name: "index_links_on_content_module_id" end create_table "programs", force: :cascade do |t| @@ -144,8 +165,9 @@ add_foreign_key "classrooms", "schools" add_foreign_key "classrooms", "teachers" add_foreign_key "content_modules", "programs" - add_foreign_key "games", "content_modules" - add_foreign_key "links", "content_modules" + add_foreign_key "contents", "content_modules" + add_foreign_key "game_attempts", "games" + add_foreign_key "game_attempts", "students" add_foreign_key "sessions", "users" add_foreign_key "student_sessions", "students" add_foreign_key "students", "classrooms" From 6cc84999e0dba44150566778308f23c3183e232f Mon Sep 17 00:00:00 2001 From: A-Mozeak Date: Sat, 29 Aug 2026 17:07:53 -0400 Subject: [PATCH 3/6] Add contentable concern to Link and Game. --- app/models/concerns/contentable.rb | 7 +++++++ app/models/content.rb | 10 ++++++++++ app/models/content_module.rb | 1 + app/models/game.rb | 8 +++++++- app/models/link.rb | 5 +++++ 5 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 app/models/concerns/contentable.rb diff --git a/app/models/concerns/contentable.rb b/app/models/concerns/contentable.rb new file mode 100644 index 0000000..c5c9a5d --- /dev/null +++ b/app/models/concerns/contentable.rb @@ -0,0 +1,7 @@ +module Contentable + extend ActiveSupport::Concern + + included do + has_one :content, as: :contentable + end +end \ No newline at end of file diff --git a/app/models/content.rb b/app/models/content.rb index a7a67d5..7e3d3c4 100644 --- a/app/models/content.rb +++ b/app/models/content.rb @@ -2,4 +2,14 @@ class Content < ApplicationRecord belongs_to :content_module, optional: true delegated_type :contentable, types: %w[Link Game] + + # Ensure the associated contentable record is destroyed if allowed before destroying this content. + # This allows us to have dependent: :destroy behavior for contentable records. + before_destroy :destroy_owned_record + + private + + def destroy_owned_record + contentable.destroy if contentable.destroy_with_attachable? + end end diff --git a/app/models/content_module.rb b/app/models/content_module.rb index 96c0527..8c5da09 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -5,6 +5,7 @@ class ContentModule < ApplicationRecord has_many :links, -> { ordered }, dependent: :destroy has_many :games, dependent: :nullify has_many :classroom_modules, dependent: :restrict_with_error + has_many :contentables, dependent: :destroy enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true diff --git a/app/models/game.rb b/app/models/game.rb index a62b6aa..9f6b457 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -1,6 +1,12 @@ class Game < ApplicationRecord - belongs_to :content_module, optional: true + include Contentable + + has_many :game_attempts, dependent: :destroy validates :title, :slug, presence: true validates :title, :slug, uniqueness: true + + def destroy_with_attachable? + false + end end diff --git a/app/models/link.rb b/app/models/link.rb index c3ef621..48ab603 100644 --- a/app/models/link.rb +++ b/app/models/link.rb @@ -1,5 +1,6 @@ class Link < ApplicationRecord include Ordered + include Contentable belongs_to :content_module @@ -7,4 +8,8 @@ class Link < ApplicationRecord validates :title, :url, :link_type, presence: true validates :url, format: { with: /\Ahttps?:\/\/.+\z/i, message: "must start with http:// or https://" }, allow_blank: true + + def destroy_with_attachable? + true + end end From 004b03d6de431eeb060b8a6b5fb0ea9638e565cd Mon Sep 17 00:00:00 2001 From: A-Mozeak Date: Sat, 29 Aug 2026 17:18:21 -0400 Subject: [PATCH 4/6] Preserve Link ordering on ContentModule. --- app/models/content_module.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models/content_module.rb b/app/models/content_module.rb index 8c5da09..8c31b19 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -2,10 +2,14 @@ class ContentModule < ApplicationRecord include Ordered belongs_to :program - has_many :links, -> { ordered }, dependent: :destroy + has_many :links, -> { ordered }, + through: :contents, + source: :contentable, + source_type: 'Link' + has_many :games, dependent: :nullify has_many :classroom_modules, dependent: :restrict_with_error - has_many :contentables, dependent: :destroy + has_many :contents, dependent: :destroy enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true From d3645690f9e793027d1aa8522a7e725ba0c76f79 Mon Sep 17 00:00:00 2001 From: A-Mozeak Date: Sun, 30 Aug 2026 08:00:30 -0400 Subject: [PATCH 5/6] Change contents fixture to fit standard implementation. --- test/fixtures/contents.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/fixtures/contents.yml b/test/fixtures/contents.yml index 0f47a3e..15084c9 100644 --- a/test/fixtures/contents.yml +++ b/test/fixtures/contents.yml @@ -1,11 +1,9 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - link: one - game: one - contentable_type: MyString + contentable_id: <%= ActiveRecord::FixtureSet.identify(:one) %> + contentable_type: Game two: - link: two - game: two - contentable_type: MyString + contentable_id: <%= ActiveRecord::FixtureSet.identify(:survey_one) %> + contentable_type: Link From 82b24b6422aafbf9e8a775c12c0b9d9b6a51f042 Mon Sep 17 00:00:00 2001 From: A-Mozeak Date: Sun, 30 Aug 2026 08:38:02 -0400 Subject: [PATCH 6/6] Tighten scope of changes and pass CI. --- app/controllers/games_controller.rb | 1 + app/controllers/links_controller.rb | 5 +++-- app/models/concerns/contentable.rb | 2 +- app/models/content.rb | 2 +- app/models/content_module.rb | 10 ++++++---- app/models/game.rb | 1 + db/migrate/20260829190944_create_contents.rb | 2 +- db/schema.rb | 10 ++++++++-- .../content_modules_controller_test.rb | 6 ++++-- test/controllers/games_controller_test.rb | 2 +- test/fixtures/contents.yml | 2 ++ test/models/content_module_test.rb | 8 +++++++- test/models/content_test.rb | 15 ++++++++++++--- 13 files changed, 48 insertions(+), 18 deletions(-) diff --git a/app/controllers/games_controller.rb b/app/controllers/games_controller.rb index 474e157..101a9d4 100644 --- a/app/controllers/games_controller.rb +++ b/app/controllers/games_controller.rb @@ -18,6 +18,7 @@ def create respond_to do |format| if @game.save + Content.create!(content_module: @game.content_module, contentable: @game) if @game.content_module format.html { redirect_back(fallback_location: games_path, notice: "Game was successfully created.") } else format.html { render :new, status: :unprocessable_content } diff --git a/app/controllers/links_controller.rb b/app/controllers/links_controller.rb index 4f9ca7c..efe6c05 100644 --- a/app/controllers/links_controller.rb +++ b/app/controllers/links_controller.rb @@ -3,13 +3,14 @@ class LinksController < AdminController before_action :set_link, only: %i[edit update destroy] def new - @link = @content_module.links.build + @link = Link.new(content_module: @content_module) end def create - @link = @content_module.links.build(link_params) + @link = Link.new(link_params.merge(content_module: @content_module)) if @link.save + @content_module.contents.create!(contentable: @link) redirect_to edit_content_module_path(@content_module), notice: "Link was successfully created." else render :new, status: :unprocessable_entity diff --git a/app/models/concerns/contentable.rb b/app/models/concerns/contentable.rb index c5c9a5d..9ce103c 100644 --- a/app/models/concerns/contentable.rb +++ b/app/models/concerns/contentable.rb @@ -4,4 +4,4 @@ module Contentable included do has_one :content, as: :contentable end -end \ No newline at end of file +end diff --git a/app/models/content.rb b/app/models/content.rb index 7e3d3c4..f1dc551 100644 --- a/app/models/content.rb +++ b/app/models/content.rb @@ -1,5 +1,5 @@ class Content < ApplicationRecord - belongs_to :content_module, optional: true + belongs_to :content_module delegated_type :contentable, types: %w[Link Game] diff --git a/app/models/content_module.rb b/app/models/content_module.rb index 8c31b19..9013471 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -2,14 +2,16 @@ class ContentModule < ApplicationRecord include Ordered belongs_to :program + has_many :contents, dependent: :destroy has_many :links, -> { ordered }, through: :contents, source: :contentable, - source_type: 'Link' - - has_many :games, dependent: :nullify + source_type: "Link" + has_many :games, + through: :contents, + source: :contentable, + source_type: "Game" has_many :classroom_modules, dependent: :restrict_with_error - has_many :contents, dependent: :destroy enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true diff --git a/app/models/game.rb b/app/models/game.rb index 9f6b457..48d9e46 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -1,6 +1,7 @@ class Game < ApplicationRecord include Contentable + belongs_to :content_module, optional: true has_many :game_attempts, dependent: :destroy validates :title, :slug, presence: true diff --git a/db/migrate/20260829190944_create_contents.rb b/db/migrate/20260829190944_create_contents.rb index 5bc4216..7a0656f 100644 --- a/db/migrate/20260829190944_create_contents.rb +++ b/db/migrate/20260829190944_create_contents.rb @@ -1,7 +1,7 @@ class CreateContents < ActiveRecord::Migration[8.1] def change create_table :contents do |t| - t.references :content_module, foreign_key: true + t.references :content_module, null: false, foreign_key: true t.references :contentable, polymorphic: true, null: false, index: true t.timestamps diff --git a/db/schema.rb b/db/schema.rb index 2d58550..a58913e 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[8.1].define(version: 2026_08_29_195031) do +ActiveRecord::Schema[8.1].define(version: 2026_08_29_190944) do create_table "classroom_modules", force: :cascade do |t| t.integer "classroom_program_id", null: false t.integer "content_module_id", null: false @@ -56,7 +56,7 @@ end create_table "contents", force: :cascade do |t| - t.integer "content_module_id" + t.integer "content_module_id", null: false t.integer "contentable_id", null: false t.string "contentable_type", null: false t.datetime "created_at", null: false @@ -81,21 +81,25 @@ end create_table "games", force: :cascade do |t| + t.integer "content_module_id" t.datetime "created_at", null: false t.text "description" t.string "slug", null: false t.string "title", null: false t.datetime "updated_at", null: false + t.index ["content_module_id"], name: "index_games_on_content_module_id" t.index ["slug"], name: "index_games_on_slug", unique: true end create_table "links", force: :cascade do |t| + t.integer "content_module_id", null: false t.datetime "created_at", null: false t.string "link_type", null: false t.integer "position" t.string "title", null: false t.datetime "updated_at", null: false t.string "url", null: false + t.index ["content_module_id"], name: "index_links_on_content_module_id" end create_table "programs", force: :cascade do |t| @@ -168,6 +172,8 @@ add_foreign_key "contents", "content_modules" add_foreign_key "game_attempts", "games" add_foreign_key "game_attempts", "students" + add_foreign_key "games", "content_modules" + add_foreign_key "links", "content_modules" add_foreign_key "sessions", "users" add_foreign_key "student_sessions", "students" add_foreign_key "students", "classrooms" diff --git a/test/controllers/content_modules_controller_test.rb b/test/controllers/content_modules_controller_test.rb index d50d3dd..9c5c845 100644 --- a/test/controllers/content_modules_controller_test.rb +++ b/test/controllers/content_modules_controller_test.rb @@ -47,8 +47,10 @@ class ContentModulesControllerTest < ActionDispatch::IntegrationTest test "edit lists links ordered by position" do @content_module.links.destroy_all - @content_module.links.create!(title: "Last Link", url: "https://example.com/2", link_type: "survey", position: 2) - @content_module.links.create!(title: "First Link", url: "https://example.com/1", link_type: "survey", position: 1) + last_link = Link.create!(content_module: @content_module, title: "Last Link", url: "https://example.com/2", link_type: "survey", position: 2) + first_link = Link.create!(content_module: @content_module, title: "First Link", url: "https://example.com/1", link_type: "survey", position: 1) + Content.create!(content_module: @content_module, contentable: last_link) + Content.create!(content_module: @content_module, contentable: first_link) get edit_content_module_url(@content_module) diff --git a/test/controllers/games_controller_test.rb b/test/controllers/games_controller_test.rb index cbd104a..05020c9 100644 --- a/test/controllers/games_controller_test.rb +++ b/test/controllers/games_controller_test.rb @@ -18,7 +18,7 @@ class GamesControllerTest < ActionDispatch::IntegrationTest end test "should create game" do - assert_difference("Game.count") do + assert_difference [ "Game.count", "Content.count" ] do post games_url, params: { game: { slug: "new-game", title: "New Game", content_module_id: @content_module.id } } end diff --git a/test/fixtures/contents.yml b/test/fixtures/contents.yml index 15084c9..e1665e3 100644 --- a/test/fixtures/contents.yml +++ b/test/fixtures/contents.yml @@ -1,9 +1,11 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: + content_module: intro contentable_id: <%= ActiveRecord::FixtureSet.identify(:one) %> contentable_type: Game two: + content_module: intro contentable_id: <%= ActiveRecord::FixtureSet.identify(:survey_one) %> contentable_type: Link diff --git a/test/models/content_module_test.rb b/test/models/content_module_test.rb index 95a4039..86f5336 100644 --- a/test/models/content_module_test.rb +++ b/test/models/content_module_test.rb @@ -28,13 +28,19 @@ class ContentModuleTest < ActiveSupport::TestCase test "destroys associated links" do content_module = ContentModule.create!(program: @program, level: "basic", name: "With Links") - content_module.links.create!(title: "A Link", url: "https://example.com", link_type: "survey") + link = Link.create!(content_module: content_module, title: "A Link", url: "https://example.com", link_type: "survey") + Content.create!(content_module: content_module, contentable: link) assert_difference "Link.count", -1 do content_module.destroy! end end + test "exposes delegated contentables" do + assert_equal [ links(:survey_one) ], content_modules(:intro).links.to_a + assert_equal [ games(:one) ], content_modules(:intro).games.to_a + end + test "cannot be destroyed when classroom modules exist" do content_module = ContentModule.create!(program: @program, level: "basic", name: "Assigned Module") classroom_program = classroom_programs(:one) diff --git a/test/models/content_test.rb b/test/models/content_test.rb index b9d33a2..11d2408 100644 --- a/test/models/content_test.rb +++ b/test/models/content_test.rb @@ -1,7 +1,16 @@ require "test_helper" class ContentTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "delegates to its contentable" do + content = contents(:one) + + assert_equal games(:one), content.contentable + assert_equal "Game", content.contentable_type + end + + test "belongs to a content module" do + content = contents(:two) + + assert_equal content_modules(:intro), content.content_module + end end