diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb new file mode 100644 index 0000000000..e7a7972085 --- /dev/null +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module Course::AssessmentMarketplaceAbilityComponent + include AbilityHost::Component + + def define_permissions + allow_admins_publish_to_marketplace if user&.administrator? + allow_managers_access_marketplace if course_user&.manager_or_owner? + super + end + + private + + def allow_admins_publish_to_marketplace + can :publish_to_marketplace, Course::Assessment + end + + def allow_managers_access_marketplace + can :access_marketplace, Course, id: course.id + can :duplicate_from_marketplace, Course::Assessment do |assessment| + assessment.marketplace_listing&.published? || false + end + can :preview_in_marketplace, Course::Assessment do |assessment| + assessment.marketplace_listing&.published? || false + end + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 7bde6a0d4b..e489dce65d 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -82,6 +82,8 @@ class Course::Assessment < ApplicationRecord has_one :gradebook_assessment_contribution, class_name: 'Course::Gradebook::AssessmentContribution', dependent: :destroy, inverse_of: :assessment + has_one :marketplace_listing, class_name: 'Course::Assessment::Marketplace::Listing', + inverse_of: :assessment, dependent: :destroy has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', inverse_of: :assessment, dependent: :destroy has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy diff --git a/app/models/course/assessment/marketplace.rb b/app/models/course/assessment/marketplace.rb new file mode 100644 index 0000000000..235cbc69e9 --- /dev/null +++ b/app/models/course/assessment/marketplace.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Course::Assessment::Marketplace + def self.table_name_prefix + 'course_assessment_marketplace_' + end +end diff --git a/app/models/course/assessment/marketplace/adoption.rb b/app/models/course/assessment/marketplace/adoption.rb new file mode 100644 index 0000000000..dac5a61629 --- /dev/null +++ b/app/models/course/assessment/marketplace/adoption.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::Adoption < ApplicationRecord + belongs_to :listing, class_name: 'Course::Assessment::Marketplace::Listing', inverse_of: :adoptions + belongs_to :destination_course, class_name: 'Course', inverse_of: false + belongs_to :duplicated_assessment, class_name: 'Course::Assessment', inverse_of: false + + validates :duplicated_assessment_id, uniqueness: true + validates :creator, presence: true + validates :updater, presence: true +end diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb new file mode 100644 index 0000000000..0722ac6492 --- /dev/null +++ b/app/models/course/assessment/marketplace/listing.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +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 + has_many :adoptions, class_name: 'Course::Assessment::Marketplace::Adoption', + inverse_of: :listing, dependent: :destroy + + validates :assessment_id, uniqueness: true + validates :publisher, presence: true + validates :creator, presence: true + validates :updater, presence: true + + scope :published, -> { where(published: true) } + + def adoption_count + adoptions.distinct.count(:destination_course_id) + end +end diff --git a/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb new file mode 100644 index 0000000000..e1b094eb9a --- /dev/null +++ b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb @@ -0,0 +1,30 @@ +class CreateCourseAssessmentMarketplaceListings < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_listings do |t| + t.references :assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_course_assessment_marketplace_listings_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_listings_assessment_id', + unique: true } + t.boolean :published, null: false, default: false + t.datetime :first_published_at + t.datetime :last_published_at + t.references :publisher, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_publisher_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_publisher_id' } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_creator_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_updater_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_listings, :published, + name: 'index_course_assessment_marketplace_listings_on_published' + end +end diff --git a/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb new file mode 100644 index 0000000000..1f7eee7c3d --- /dev/null +++ b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb @@ -0,0 +1,33 @@ +class CreateCourseAssessmentMarketplaceAdoptions < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_adoptions do |t| + t.references :listing, null: false, + foreign_key: { to_table: :course_assessment_marketplace_listings, + name: 'fk_course_assessment_marketplace_adoptions_listing_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_adoptions_listing_id' } + t.references :destination_course, null: false, + foreign_key: { to_table: :courses, + name: 'fk_cama_destination_course_id', + on_delete: :cascade }, + index: { name: 'fk__cama_destination_course_id' } + t.references :duplicated_assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_cama_duplicated_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__cama_duplicated_assessment_id', + unique: true } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_creator_id' }, + index: { name: 'fk__cama_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_updater_id' }, + index: { name: 'fk__cama_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_adoptions, [:listing_id, :destination_course_id], + name: 'index_cama_on_listing_id_and_destination_course_id' + end +end diff --git a/db/schema.rb b/db/schema.rb index c61308e6fb..7eeadc353c 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.2].define(version: 2026_06_25_000000) do +ActiveRecord::Schema[7.2].define(version: 2026_07_07_000002) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -270,6 +270,39 @@ t.index ["question_id"], name: "index_course_assessment_live_feedbacks_on_question_id" end + create_table "course_assessment_marketplace_adoptions", force: :cascade do |t| + t.bigint "listing_id", null: false + t.bigint "destination_course_id", null: false + t.bigint "duplicated_assessment_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 ["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 + t.index ["listing_id", "destination_course_id"], name: "index_cama_on_listing_id_and_destination_course_id" + t.index ["listing_id"], name: "fk__course_assessment_marketplace_adoptions_listing_id" + t.index ["updater_id"], name: "fk__cama_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 + t.datetime "first_published_at" + t.datetime "last_published_at" + t.bigint "publisher_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__course_assessment_marketplace_listings_assessment_id", unique: true + t.index ["creator_id"], name: "fk__course_assessment_marketplace_listings_creator_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 ["updater_id"], name: "fk__course_assessment_marketplace_listings_updater_id" + end + create_table "course_assessment_plagiarism_checks", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false @@ -1945,6 +1978,15 @@ add_foreign_key "course_assessment_live_feedbacks", "course_assessment_questions", column: "question_id" add_foreign_key "course_assessment_live_feedbacks", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_live_feedbacks", "users", column: "creator_id" + add_foreign_key "course_assessment_marketplace_adoptions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_course_assessment_marketplace_adoptions_listing_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "course_assessments", column: "duplicated_assessment_id", name: "fk_cama_duplicated_assessment_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "courses", column: "destination_course_id", name: "fk_cama_destination_course_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "creator_id", name: "fk_course_assessment_marketplace_adoptions_creator_id" + 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_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade + 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: "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" add_foreign_key "course_assessment_plagiarism_checks", "jobs", name: "fk_course_assessment_plagiarism_checks_job_id", on_delete: :nullify add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_question_bundles", column: "bundle_id" diff --git a/spec/factories/course_assessment_marketplace_adoptions.rb b/spec/factories/course_assessment_marketplace_adoptions.rb new file mode 100644 index 0000000000..912723e5b6 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_adoptions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_adoption, + class: Course::Assessment::Marketplace::Adoption do + listing { association :course_assessment_marketplace_listing } + destination_course { association :course } + duplicated_assessment { association :assessment, course: destination_course } + end +end diff --git a/spec/factories/course_assessment_marketplace_listings.rb b/spec/factories/course_assessment_marketplace_listings.rb new file mode 100644 index 0000000000..6ea0a819ff --- /dev/null +++ b/spec/factories/course_assessment_marketplace_listings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_listing, + class: Course::Assessment::Marketplace::Listing do + transient do + course { nil } + end + assessment { association :assessment, course: course || create(:course) } + publisher { assessment.course.creator } + published { true } + first_published_at { Time.zone.now } + last_published_at { Time.zone.now } + end +end diff --git a/spec/models/course/assessment/marketplace/adoption_spec.rb b/spec/models/course/assessment/marketplace/adoption_spec.rb new file mode 100644 index 0000000000..7b0e9c3848 --- /dev/null +++ b/spec/models/course/assessment/marketplace/adoption_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Adoption, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:listing).class_name('Course::Assessment::Marketplace::Listing') } + it { is_expected.to belong_to(:destination_course).class_name('Course') } + it { is_expected.to belong_to(:duplicated_assessment).class_name('Course::Assessment') } + + it 'validates uniqueness of duplicated_assessment_id' do + existing = create(:course_assessment_marketplace_adoption) + dup = build(:course_assessment_marketplace_adoption, + duplicated_assessment: existing.duplicated_assessment) + expect(dup).not_to be_valid + end + + it 'is destroyed when its duplicated assessment is destroyed (DB cascade)' do + adoption = create(:course_assessment_marketplace_adoption) + adoption.duplicated_assessment.destroy + expect(described_class.exists?(adoption.id)).to be(false) + end + end +end diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb new file mode 100644 index 0000000000..7eeb9a26f3 --- /dev/null +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Listing, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:assessment).class_name('Course::Assessment') } + it { is_expected.to belong_to(:publisher).class_name('User') } + it do + is_expected.to have_many(:adoptions). + class_name('Course::Assessment::Marketplace::Adoption').dependent(:destroy) + end + + describe 'validations' do + subject { build(:course_assessment_marketplace_listing) } + + it { is_expected.to validate_presence_of(:publisher) } + + it 'validates uniqueness of assessment_id' do + existing = create(:course_assessment_marketplace_listing) + dup = build(:course_assessment_marketplace_listing, assessment: existing.assessment) + expect(dup).not_to be_valid + end + end + + describe '.published' do + it 'includes published listings and excludes unpublished ones' do + published = create(:course_assessment_marketplace_listing, published: true) + unpublished = create(:course_assessment_marketplace_listing, published: false) + expect(described_class.published).to include(published) + expect(described_class.published).not_to include(unpublished) + end + end + + describe '#adoption_count' do + subject { create(:course_assessment_marketplace_listing) } + + it 'counts distinct destination courses' do + course_a = create(:course) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: create(:course)) + expect(subject.adoption_count).to eq(2) + end + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb new file mode 100644 index 0000000000..1dabaf126c --- /dev/null +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:listing) { create(:course_assessment_marketplace_listing, published: true) } + let(:published_assessment) { listing.assessment } + + subject { Ability.new(user, course, course_user) } + + context 'when the user is a system administrator' do + let(:user) { create(:administrator) } + let(:course_user) { nil } + it { is_expected.to be_able_to(:publish_to_marketplace, build(:assessment)) } + end + + context 'when the user is a course manager' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + it { is_expected.to be_able_to(:access_marketplace, course) } + it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } + it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + + it 'cannot duplicate/preview an unpublished listing' do + unpublished = create(:course_assessment_marketplace_listing, published: false).assessment + expect(subject).not_to be_able_to(:duplicate_from_marketplace, unpublished) + expect(subject).not_to be_able_to(:preview_in_marketplace, unpublished) + end + end + + context 'when the user is a course student' do + let(:course_user) { create(:course_student, course: course) } + let(:user) { course_user.user } + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + end +end