Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f60570c
feat(marketplace): add data model, migrations, and permissions
LWS49 Jul 7, 2026
a805dc1
feat(marketplace): admin publish control for assessments
LWS49 Jul 8, 2026
6c42381
feat(marketplace): cross-instance browse page + entry points
LWS49 Jul 8, 2026
88940e3
feat(marketplace): duplicate listings into current course
LWS49 Jul 8, 2026
87aafc1
feat(marketplace): preview endpoints and question serializers
LWS49 Jul 9, 2026
2dd5c9f
refactor(duplication): shared assessment tree + table empty-state sup…
LWS49 Jul 9, 2026
d9ae5c5
feat(marketplace): listing + question preview UI and duplicate dialog
LWS49 Jul 9, 2026
2a5e019
feat(marketplace): carry from_tab through the browse flow + breadcrumbs
LWS49 Jul 9, 2026
16b9186
feat(marketplace): rework the duplicate confirmation dialog
LWS49 Jul 16, 2026
3c63094
fix(marketplace): redirect bare /listings to the marketplace index
LWS49 Jul 16, 2026
c525319
feat(marketplace): badge the listing detail page as a preview
LWS49 Jul 16, 2026
87d762e
feat(marketplace): per-person marketplace access control backend
LWS49 Jul 20, 2026
aa8b1a4
feat(marketplace): system-admin allow-list management UI
LWS49 Jul 20, 2026
19c9327
feat(marketplace): access audit list and per-user block controls
LWS49 Jul 20, 2026
cc1a85e
fix(cikgo): skip the destroy push when the course is gone
LWS49 Jul 29, 2026
020dab6
fix(toast): let an updated toast render a React node
LWS49 Jul 29, 2026
d3d3eaa
fix(admin): keep the nav tab selected on nested admin routes
LWS49 Jul 29, 2026
d355567
feat(marketplace): record an immutable snapshot per published version
LWS49 Jul 29, 2026
c8f5be9
feat(marketplace): rebuild or purge a listing that lost its source
LWS49 Jul 29, 2026
fe709ca
fix(spec): stop the marketplace specs polluting the shared test DB
LWS49 Jul 30, 2026
cabf962
fix(duplication): stop assessment links propagating across instances
LWS49 Aug 1, 2026
9b26a0d
fix(marketplace): re-point a losing listing inside the destroy transa…
LWS49 Aug 1, 2026
5491589
feat(marketplace): make the preview container a per-instance singleton
LWS49 Aug 2, 2026
d2afbfd
fix(spec): make factory sequences unique per process
LWS49 Jul 20, 2026
88ad1c6
feat(marketplace): browse, preview and duplicate the served snapshot
LWS49 Jul 29, 2026
5b4f391
feat(marketplace): tell an adopter their copy is behind, and update it
LWS49 Jul 29, 2026
13d823d
feat(marketplace): badge container snapshots in the assessment index
LWS49 Jul 29, 2026
ab79266
feat(marketplace): warn against editing a published snapshot
LWS49 Jul 29, 2026
372207d
feat(marketplace): admin API for listing and version management
LWS49 Jul 29, 2026
6eac0c4
feat(marketplace): admin UI for listing and version management
LWS49 Jul 29, 2026
bbd43f0
fix(marketplace): track adoptions through a course roll-forward
LWS49 Aug 2, 2026
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,18 @@
# frozen_string_literal: true
class Course::AssessmentMarketplaceComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def sidebar_items
return [] unless can?(:access_marketplace, current_course)

[
{
key: :admin_marketplace,
icon: :marketplace,
type: :admin,
weight: 6,
path: course_marketplace_path(current_course)
}
]
end
end
4 changes: 0 additions & 4 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
class Course::GradebookComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def self.display_name
'Gradebook'
end

def sidebar_items
main_sidebar_items + settings_sidebar_items
end
Expand Down
51 changes: 51 additions & 0 deletions app/controllers/course/assessment/assessments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def index
end

@conditional_service = Course::Assessment::AchievementPreloadService.new(@assessments)
@marketplace_container = current_course.preview? && can?(:manage, :all)
@marketplace_versions = marketplace_version_labels if @marketplace_container
end

def show
Expand All @@ -39,6 +41,10 @@ def show
@question_assessments = @assessment.question_assessments.with_question_actables
@assessment_conditions = @assessment.assessment_conditions.includes({ conditional: :actable })
@questions = @assessment.questions.includes({ actable: :test_cases })
@marketplace_update = Course::Assessment::Marketplace::Adoption.update_notice_for(@assessment.id)
# Same gate and same labels as the index: opening a container row must not lose the identity the
# row carried, since every snapshot and working copy there shares one title and one tab.
@marketplace_version = marketplace_version_label if current_course.preview? && can?(:manage, :all)

@requirements = @assessment.specific_conditions.map do |condition|
{
Expand Down Expand Up @@ -257,6 +263,51 @@ def load_assessment_options

private

# Drives the view-only version badge on the container course's assessment index. Every published
# snapshot keeps its original title and shares one tab, so without it an admin sees an
# undifferentiated pile of identically-named assessments. Skipped everywhere else.
#
# @return [Hash{Integer => Hash}]
def marketplace_version_labels
Course::Assessment::Marketplace::ListingVersion.labels_for_assessments(@assessments.pluck(:id))
end

# The single-assessment reading of the same labels, for `show`. Nil for a container assessment that
# is neither a snapshot nor a listing's working copy — one authored in the container directly.
#
# A snapshot additionally carries where to edit the content it froze. Merged here rather than in
# `labels_for_assessments`, which the index shares and has no use for the field.
#
# @return [Hash, nil]
def marketplace_version_label
label = Course::Assessment::Marketplace::ListingVersion.
labels_for_assessments([@assessment.id])[@assessment.id]
return nil if label.nil?
# Skipped for the working copy: the source assessment is this page.
return label if label[:published_at].nil?

label.merge(source_assessment_url: source_assessment_url(label[:listing_id]))
end

# Absolute, and carrying the source assessment's own host: a course id only resolves on its
# instance's host, and a listing's source lives on whichever instance published it. Nil for an
# orphaned listing, whose source was deleted and whose rebuild has not landed.
#
# @param [Integer] listing_id
# @return [String, nil]
def source_assessment_url(listing_id)
ActsAsTenant.without_tenant do
listing = Course::Assessment::Marketplace::Listing.
includes(authoring_assessment: { lesson_plan_item: { course: :instance } }).
find_by(id: listing_id)
assessment = listing&.authoring_assessment
next nil if assessment.nil?

course_assessment_url(assessment.course_id, assessment,
**assessment.course.instance.host_options)
end
end

def load_assessment_submission_counts
@all_students = current_course.course_users.students.without_phantom_users
@assessment_counts = num_submitted_students_hash
Expand Down
12 changes: 12 additions & 0 deletions app/controllers/course/assessment/marketplace/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::Controller < Course::ComponentController
# display_graded_test_types is defined in Course::Assessment::AssessmentsHelper; the marketplace
# preview views reuse it, but Rails only auto-includes a controller's own matching helper.
helper Course::Assessment::AssessmentsHelper

private

def component
current_component_host[:course_assessment_marketplace_component]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::ListingsController < Course::Assessment::Marketplace::Controller
before_action :authorize_access!

def index
ActsAsTenant.without_tenant do
# Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on the
# acting-as record. Reads go through the current version snapshot, never the authoring copy: the
# marketplace serves what a duplicate would give you. `where.not(current_version_id:
# nil)` guards a published listing with no snapshot, whose nil `current_version` would 500 browse.
@listings = Course::Assessment::Marketplace::Listing.published.
where.not(current_version_id: nil).
includes(current_version: { assessment: :lesson_plan_item }).to_a
@adoption_counts = adoption_counts(@listings.map(&:id))
@question_counts = question_counts(@listings.map { |listing| listing.current_version.assessment_id })
@destination_tabs = destination_tabs
end
end

def duplicate
listings = authorized_listings
job = Course::Assessment::Marketplace::DuplicationJob.perform_later(
# `presence` first: an omitted tab (the sidebar entry point) must stay nil so the job lets the
# duplication fall back to the destination course's first tab, rather than looking for tab 0.
listings.map(&:id), current_course, duplicate_params[:destination_tab_id].presence&.to_i,
current_user: current_user
).job
render partial: 'jobs/submitted', locals: { job: job }
end

def show
ActsAsTenant.without_tenant do
@listing = Course::Assessment::Marketplace::Listing.published.
includes(current_version: :assessment).find_by(id: params[:id])
raise CanCan::AccessDenied unless @listing

# The SNAPSHOT, never the authoring copy (design §4.2).
@assessment = @listing.current_version&.assessment
raise CanCan::AccessDenied unless @assessment

authorize!(:preview_in_marketplace, @listing)
@destination_tabs = destination_tabs
render 'show'
end
end

private

def authorize_access!
authorize!(:access_marketplace, current_course)
end

def adoption_counts(listing_ids)
Course::Assessment::Marketplace::Adoption.
where(listing_id: listing_ids).group(:listing_id).
distinct.count(:destination_course_id)
end

def question_counts(assessment_ids)
# reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it
# the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is
# neither grouped nor aggregated).
Course::QuestionAssessment.
where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id).
distinct.count(:question_id)
end

def destination_tabs
current_course.assessment_categories.includes(:tabs).flat_map do |category|
category.tabs.map do |tab|
{ id: tab.id, title: tab.title, category_id: category.id, category_title: category.title }
end
end
end

def authorized_listings
listings = ActsAsTenant.without_tenant do
Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).
includes(current_version: :assessment)
end
raise CanCan::AccessDenied if listings.empty?

listings.each { |listing| authorize!(:duplicate_from_marketplace, listing) }
authorize!(:duplicate_to, current_course)
listings
end

def duplicate_params
params.permit(:destination_tab_id, listing_ids: [])
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::QuestionsController < Course::Assessment::Marketplace::Controller
before_action :authorize_access!

def show
ActsAsTenant.without_tenant do
listing = Course::Assessment::Marketplace::Listing.published.
includes(current_version: :assessment).find_by(id: params[:listing_id])
raise CanCan::AccessDenied unless listing

# The SNAPSHOT, never the authoring copy.
@assessment = listing.current_version&.assessment
raise CanCan::AccessDenied unless @assessment

authorize!(:preview_in_marketplace, listing)

@question = @assessment.questions.includes(:actable).find(params[:id])
@question_assessment = @question.question_assessments.find_by!(assessment: @assessment)
render 'show' # rendered inside without_tenant so actable associations resolve cross-instance
end
end

private

def authorize_access!
authorize!(:access_marketplace, current_course)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true
# Adopter-side actions on a duplicated marketplace assessment.
class Course::Assessment::MarketplaceAdoptionsController < Course::Assessment::Controller
before_action :authorize_manage_assessment!

def apply_latest_version
adoption = Course::Assessment::Marketplace::Adoption.find_by(duplicated_assessment_id: @assessment.id)
return head :not_found if adoption.nil?

if @assessment.submission_counts_by_author[:student] > 0
return render json: { errors: [t('.student_submissions_exist')] },
status: :unprocessable_content
end

job = Course::Assessment::Marketplace::ApplyVersionJob.
perform_later(@assessment, current_user: current_user).job
render partial: 'jobs/submitted', locals: { job: job }, status: :ok
end

private

def authorize_manage_assessment!
authorize!(:manage, @assessment)
end

def component
current_component_host[:course_assessments_component]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true
class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller
before_action :authorize_publish_to_marketplace!

# A published version of an existing listing is not a source assessment. Refused server-side and
# not only by withholding the button: the listing this would create has its source assessment
# frozen inside the container, so it could never be edited nor cut a further version.
SNAPSHOT_REJECTION = 'This is a published version of an existing listing, not a source assessment.'

def create
return render json: { errors: [SNAPSHOT_REJECTION] }, status: :unprocessable_content if
@assessment.marketplace_snapshot?

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

# Cuts a new version from the authoring copy. Deliberately separate from `create`: re-listing an unlisted
# assessment reactivates the row but must NOT silently republish changed content.
def publish_version
listing = @assessment.marketplace_listing
return render json: { errors: ['Not listed on the marketplace.'] }, status: :unprocessable_content if listing.nil?

version = Course::Assessment::Marketplace::PublishService.publish_new_version(listing, current_user)
render json: { published_at: version.published_at }, status: :ok
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_content
end

def destroy
listing = @assessment.marketplace_listing
if listing&.update(published: false)
head :ok
else
head :unprocessable_content
end
end

private

# Publishing is admin-only. `authorize!(:publish_to_marketplace, @assessment)` alone is
# insufficient: teaching staff hold `can :manage, Course::Assessment` over their own course's
# assessments (assessment_ability.rb:189), and CanCan's `:manage` wildcard subsumes every
# custom action — including `:publish_to_marketplace`. Gate explicitly on administrator status.
def authorize_publish_to_marketplace!
authorize!(:publish_to_marketplace, @assessment)
raise CanCan::AccessDenied unless current_user&.administrator?
end

def component
current_component_host[:course_assessments_component]
end
end
2 changes: 1 addition & 1 deletion app/controllers/course/statistics/aggregate_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def correctness_hash
id
SQL
)
query.map { |u| [u.id, u.correctness] }.to_h
query.to_h { |u| [u.id, u.correctness] }
end

def fetch_all_assessment_related_statistics_hash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAccessBlocksController < System::Admin::Controller
def create
block = Course::Assessment::Marketplace::AccessBlock.new(
user_id: params[:user_id], creator: current_user
)
if block.save
render json: { id: block.id, userId: block.user_id }, status: :ok
else
render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request
end
end

def destroy
block = Course::Assessment::Marketplace::AccessBlock.find(params[:id])
if block.destroy
head :ok
else
render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request
end
end
end
8 changes: 8 additions & 0 deletions app/controllers/system/admin/marketplace_access_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAccessController < System::Admin::Controller
def index
query = Course::Assessment::Marketplace::AccessListQuery.new
@rows = query.rows
@summary = query.summary
end
end
Loading