Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,10 @@ class Course::Assessment::MarketplaceListingsController < Course::Assessment::Co
before_action :authorize_publish_to_marketplace!

def create
listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment)
now = Time.zone.now
listing.published = true
listing.first_published_at ||= now
listing.last_published_at = now
listing.publisher ||= current_user
if listing.save
render json: { published: true }, status: :ok
else
render json: { errors: listing.errors.full_messages }, status: :unprocessable_content
end
listing = Course::Assessment::Marketplace::PublishService.publish(@assessment, current_user)
render json: { published: listing.published }, status: :ok
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content
end

def destroy
Expand Down
33 changes: 33 additions & 0 deletions app/models/course/assessment/marketplace.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
# frozen_string_literal: true
module Course::Assessment::Marketplace
CONTAINER_TITLE = 'Marketplace Version Store'

def self.table_name_prefix
'course_assessment_marketplace_'
end

# The single, hidden, system-wide container Course that stores every published
# version snapshot. Located via a settings pointer on the default instance (no
# `courses` column, per design V4). Find-or-heals: (re)creates and re-persists
# the pointer if it is unset or references a missing course. Runs without a
# tenant because callers may be scoped to any instance.
#
# @return [Course]
def self.container
ActsAsTenant.without_tenant do
instance = Instance.default
id = instance.settings(:marketplace).container_course_id
(id && Course.find_by(id: id)) || create_container!(instance)
end
end

# @return [Course]
def self.create_container!(instance)
ActsAsTenant.with_tenant(instance) do
User.with_stamper(User.system) do
course = Course.create!(instance: instance, title: CONTAINER_TITLE,
description: 'System store for marketplace version snapshots.',
published: false, gamified: false, enrollable: false,
creator: User.system, updater: User.system)
instance.settings(:marketplace).container_course_id = course.id
instance.save!
course
end
end
end
private_class_method :create_container!
end
6 changes: 6 additions & 0 deletions app/models/course/assessment/marketplace/listing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
class Course::Assessment::Marketplace::Listing < ApplicationRecord
belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing
belongs_to :publisher, class_name: 'User', inverse_of: false
belongs_to :current_version, class_name: 'Course::Assessment::Marketplace::ListingVersion',
inverse_of: false, optional: true
belongs_to :source_course, class_name: 'Course', inverse_of: false, optional: true
belongs_to :fallback_maintainer, class_name: 'User', inverse_of: false, optional: true
has_many :adoptions, class_name: 'Course::Assessment::Marketplace::Adoption',
inverse_of: :listing, dependent: :destroy
has_many :versions, class_name: 'Course::Assessment::Marketplace::ListingVersion',
inverse_of: :listing, dependent: :destroy

validates :assessment_id, uniqueness: true
validates :publisher, presence: true
Expand Down
17 changes: 17 additions & 0 deletions app/models/course/assessment/marketplace/listing_version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::ListingVersion < ApplicationRecord
belongs_to :listing, class_name: 'Course::Assessment::Marketplace::Listing',
inverse_of: :versions
belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: false
belongs_to :published_by, class_name: 'User', inverse_of: false

validates :version, presence: true,
numericality: { only_integer: true, greater_than: 0 },
uniqueness: { scope: :listing_id }
validates :assessment, presence: true
validates :published_by, presence: true
validates :creator, presence: true
validates :updater, presence: true

scope :ordered, -> { order(version: :asc) }
end
130 changes: 130 additions & 0 deletions app/services/course/assessment/marketplace/publish_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

# Publishes an assessment to the marketplace and cuts version 1 (copy-on-publish,
# design V2/§5.1): (re)activate the listing, capture provenance, snapshot the source
# assessment into the hidden container course, and point `current_version` at the
# snapshot. This slice cuts ONLY the first version — deliberate "publish new version"
# is Slice 4. No rename, no read repoint here.
class Course::Assessment::Marketplace::PublishService
# @param [Course::Assessment] assessment the source assessment being published
# @param [User] publisher the user triggering the publish
# @return [Course::Assessment::Marketplace::Listing]
def self.publish(assessment, publisher)
new(assessment, publisher).publish
end

# Idempotent single-listing version cut, reused by the backfill. No-op (returns the
# existing version) if the listing already has one.
# @param [Course::Assessment::Marketplace::Listing] listing an already-published listing
# @param [User] publisher
# @return [Course::Assessment::Marketplace::ListingVersion]
def self.ensure_first_version!(listing, publisher)
new(listing.assessment, publisher).ensure_first_version!(listing)
end

# One-time backfill: version every published, version-less listing and stamp
# `adopted_version = 1` on its version-less adoptions. Idempotent.
# @return [void]
def self.backfill_all!
ActsAsTenant.without_tenant do
# Never-versioned published listings only. Keying idempotency on the absence of
# any version (not just a nil `current_version_id`) makes reruns safe and avoids
# re-cutting v1 for a listing that already has one — in production the two are
# equivalent, since a version is only ever created together with `current_version`.
Course::Assessment::Marketplace::Listing.published.where.missing(:versions).find_each do |listing|
ensure_first_version!(listing, listing.publisher)
listing.adoptions.where(adopted_version: nil).update_all(adopted_version: 1)
end
end
nil
end

def initialize(assessment, publisher)
@assessment = assessment
@publisher = publisher
end

# @return [Course::Assessment::Marketplace::Listing]
def publish
with_publish_context do
listing = activate_listing
cut_first_version!(listing) if listing.current_version_id.nil?
listing
end
end

# @return [Course::Assessment::Marketplace::ListingVersion]
def ensure_first_version!(listing)
return listing.current_version if listing.current_version_id

with_publish_context do
capture_provenance(listing)
listing.save!
cut_first_version!(listing)
end
listing.current_version
end

private

# Runs the publish body without a tenant (the container lives in the default
# instance; callers may be scoped to any instance) and with the stamper set so
# nested creator/updater resolve on the listing, version, and snapshot copy.
def with_publish_context(&block)
ActsAsTenant.without_tenant do
User.with_stamper(@publisher) do
Course::Assessment::Marketplace::Listing.transaction(&block)
end
end
end

# @return [Course::Assessment::Marketplace::Listing]
def activate_listing
listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment)
now = Time.zone.now
listing.published = true
listing.first_published_at ||= now
listing.last_published_at = now
listing.publisher ||= @publisher
capture_provenance(listing)
listing.save!
listing
end

# Denormalized so the identity survives origin-course deletion (design §3.2).
# Coursemology's Course models only a title (no course-code / academic-period
# concept), so `source_course_code` / `source_academic_period` are reserved-nil.
def capture_provenance(listing)
course = @assessment.course
listing.source_course ||= course
listing.source_course_name ||= course.title
listing.fallback_maintainer ||= course.course_users.find_by(role: :owner)&.user
end

def cut_first_version!(listing)
snapshot = snapshot_into_container(listing.assessment)
version = listing.versions.create!(version: 1, assessment: snapshot, published_by: @publisher,
creator: @publisher, updater: @publisher)
listing.update!(current_version: version)
version
end

# @return [Course::Assessment] the immutable snapshot living in the container course
def snapshot_into_container(assessment)
container = Course::Assessment::Marketplace.container
copy = Course::Duplication::ObjectDuplicationService.duplicate_objects(
assessment.course, container, assessment, current_user: @publisher
)
reparent_into_container_tab(copy, container)
copy
end

def reparent_into_container_tab(copy, container)
tab = container.assessment_categories.first.tabs.first
return if copy.tab_id == tab.id

copy.tab = tab
copy.folder.parent = tab.category.folder
copy.save!
end
end
55 changes: 55 additions & 0 deletions db/migrate/20260722000001_add_marketplace_versioning.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true
class AddMarketplaceVersioning < ActiveRecord::Migration[7.2]
def change
create_table :course_assessment_marketplace_listing_versions do |t|
t.references :listing, null: false,
foreign_key: { to_table: :course_assessment_marketplace_listings,
name: 'fk_camlv_listing_id',
on_delete: :cascade },
index: { name: 'fk__camlv_listing_id' }
t.integer :version, null: false
t.references :assessment, null: false,
foreign_key: { to_table: :course_assessments,
name: 'fk_camlv_assessment_id' },
index: { name: 'fk__camlv_assessment_id' }
t.references :published_by, null: false,
foreign_key: { to_table: :users, name: 'fk_camlv_published_by' },
index: { name: 'fk__camlv_published_by' }
t.references :creator, null: false,
foreign_key: { to_table: :users, name: 'fk_camlv_creator_id' },
index: { name: 'fk__camlv_creator_id' }
t.references :updater, null: false,
foreign_key: { to_table: :users, name: 'fk_camlv_updater_id' },
index: { name: 'fk__camlv_updater_id' }
t.timestamps null: false
end
add_index :course_assessment_marketplace_listing_versions, [:listing_id, :version],
unique: true, name: 'index_camlv_on_listing_id_and_version'

change_table :course_assessment_marketplace_listings, bulk: true do |t|
t.references :current_version, null: true,
foreign_key: { to_table: :course_assessment_marketplace_listing_versions,
name: 'fk_caml_current_version_id',
on_delete: :nullify },
index: { name: 'fk__caml_current_version_id' }
t.references :source_course, null: true,
foreign_key: { to_table: :courses,
name: 'fk_caml_source_course_id',
on_delete: :nullify },
index: { name: 'fk__caml_source_course_id' }
t.string :source_course_name
t.string :source_course_code
t.string :source_academic_period
t.references :fallback_maintainer, null: true,
foreign_key: { to_table: :users,
name: 'fk_caml_fallback_maintainer_id' },
index: { name: 'fk__caml_fallback_maintainer_id' }
end

change_table :course_assessment_marketplace_adoptions, bulk: true do |t|
t.integer :adopted_version
t.integer :dismissed_version
t.boolean :reminders_muted, null: false, default: false
end
end
end
13 changes: 13 additions & 0 deletions db/migrate/20260722000002_backfill_marketplace_versions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class BackfillMarketplaceVersions < ActiveRecord::Migration[7.2]
# Data-only. Versions every existing published listing as v1 (snapshotting into
# the container via the publish service) and stamps adopted_version = 1 on its
# adoptions. Idempotent — reruns skip already-versioned listings.
def up
Course::Assessment::Marketplace::PublishService.backfill_all!
end

def down
# Irreversible: version snapshots are retained.
end
end
39 changes: 38 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2026_07_20_154800) do
ActiveRecord::Schema[7.2].define(version: 2026_07_22_000002) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "uuid-ossp"
Expand Down Expand Up @@ -287,6 +287,9 @@
t.bigint "updater_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "adopted_version"
t.integer "dismissed_version"
t.boolean "reminders_muted", default: false, null: false
t.index ["creator_id"], name: "fk__cama_creator_id"
t.index ["destination_course_id"], name: "fk__cama_destination_course_id"
t.index ["duplicated_assessment_id"], name: "fk__cama_duplicated_assessment_id", unique: true
Expand All @@ -311,6 +314,23 @@
t.index ["user_id"], name: "index_marketplace_allowlist_rules_one_per_user", unique: true, where: "(rule_type = 0)"
end

create_table "course_assessment_marketplace_listing_versions", force: :cascade do |t|
t.bigint "listing_id", null: false
t.integer "version", null: false
t.bigint "assessment_id", null: false
t.bigint "published_by_id", null: false
t.bigint "creator_id", null: false
t.bigint "updater_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["assessment_id"], name: "fk__camlv_assessment_id"
t.index ["creator_id"], name: "fk__camlv_creator_id"
t.index ["listing_id", "version"], name: "index_camlv_on_listing_id_and_version", unique: true
t.index ["listing_id"], name: "fk__camlv_listing_id"
t.index ["published_by_id"], name: "fk__camlv_published_by"
t.index ["updater_id"], name: "fk__camlv_updater_id"
end

create_table "course_assessment_marketplace_listings", force: :cascade do |t|
t.bigint "assessment_id", null: false
t.boolean "published", default: false, null: false
Expand All @@ -321,10 +341,19 @@
t.bigint "updater_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "current_version_id"
t.bigint "source_course_id"
t.string "source_course_name"
t.string "source_course_code"
t.string "source_academic_period"
t.bigint "fallback_maintainer_id"
t.index ["assessment_id"], name: "fk__course_assessment_marketplace_listings_assessment_id", unique: true
t.index ["creator_id"], name: "fk__course_assessment_marketplace_listings_creator_id"
t.index ["current_version_id"], name: "fk__caml_current_version_id"
t.index ["fallback_maintainer_id"], name: "fk__caml_fallback_maintainer_id"
t.index ["published"], name: "index_course_assessment_marketplace_listings_on_published"
t.index ["publisher_id"], name: "fk__course_assessment_marketplace_listings_publisher_id"
t.index ["source_course_id"], name: "fk__caml_source_course_id"
t.index ["updater_id"], name: "fk__course_assessment_marketplace_listings_updater_id"
end

Expand Down Expand Up @@ -2012,8 +2041,16 @@
add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id"
add_foreign_key "course_assessment_marketplace_allowlist_rules", "instances"
add_foreign_key "course_assessment_marketplace_allowlist_rules", "users"
add_foreign_key "course_assessment_marketplace_listing_versions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_camlv_listing_id", on_delete: :cascade
add_foreign_key "course_assessment_marketplace_listing_versions", "course_assessments", column: "assessment_id", name: "fk_camlv_assessment_id"
add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "creator_id", name: "fk_camlv_creator_id"
add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "published_by_id", name: "fk_camlv_published_by"
add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "updater_id", name: "fk_camlv_updater_id"
add_foreign_key "course_assessment_marketplace_listings", "course_assessment_marketplace_listing_versions", column: "current_version_id", name: "fk_caml_current_version_id", on_delete: :nullify
add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade
add_foreign_key "course_assessment_marketplace_listings", "courses", column: "source_course_id", name: "fk_caml_source_course_id", on_delete: :nullify
add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id"
add_foreign_key "course_assessment_marketplace_listings", "users", column: "fallback_maintainer_id", name: "fk_caml_fallback_maintainer_id"
add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id"
add_foreign_key "course_assessment_marketplace_listings", "users", column: "updater_id", name: "fk_course_assessment_marketplace_listings_updater_id"
add_foreign_key "course_assessment_plagiarism_checks", "course_assessments", column: "assessment_id", name: "fk_course_assessment_plagiarism_checks_assessment_id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
expect(listing.publisher).to eq(admin)
end

it 'cuts version 1 through the publish seam' do
expect { subject }.to change { Course::Assessment::Marketplace::ListingVersion.count }.by(1)
listing = assessment.reload.marketplace_listing
expect(listing.current_version&.version).to eq(1)
expect(listing.current_version.published_by).to eq(admin)
end

context 'when the assessment was previously published then removed (re-publish)' do
let!(:listing) do
create(:course_assessment_marketplace_listing, assessment: assessment, published: false,
Expand Down
Loading