Skip to content
Open
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
@@ -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
2 changes: 2 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/models/course/assessment/marketplace.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Course::Assessment::Marketplace
def self.table_name_prefix
'course_assessment_marketplace_'
end
end
10 changes: 10 additions & 0 deletions app/models/course/assessment/marketplace/adoption.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions app/models/course/assessment/marketplace/listing.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
44 changes: 43 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_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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions spec/factories/course_assessment_marketplace_adoptions.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions spec/factories/course_assessment_marketplace_listings.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions spec/models/course/assessment/marketplace/adoption_spec.rb
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions spec/models/course/assessment/marketplace/listing_spec.rb
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions spec/models/course/assessment_marketplace_ability_spec.rb
Original file line number Diff line number Diff line change
@@ -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