Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5c9d2bb
fix(cikgo): skip the destroy push when the course is gone
LWS49 Jul 29, 2026
62d4e40
fix(toast): let an updated toast render a React node
LWS49 Jul 29, 2026
cd9835d
fix(admin): keep the nav tab selected on nested admin routes
LWS49 Jul 29, 2026
2548040
feat(marketplace): serve an immutable snapshot per published version
LWS49 Jul 29, 2026
0bbcfea
feat(marketplace): rebuild or purge a listing that lost its source
LWS49 Jul 29, 2026
213421c
fix(spec): stop the marketplace specs polluting the shared test DB
LWS49 Jul 30, 2026
e3a36e7
feat(marketplace): browse, preview and duplicate the served snapshot
LWS49 Jul 29, 2026
1c7bec5
feat(marketplace): tell an adopter their copy is behind, and update it
LWS49 Jul 29, 2026
2f03131
feat(marketplace): badge container snapshots in the assessment index
LWS49 Jul 29, 2026
621c34d
feat(marketplace): warn against editing a published snapshot
LWS49 Jul 29, 2026
7f5a444
feat(marketplace): admin API for listing and version management
LWS49 Jul 29, 2026
aef1eb3
feat(marketplace): admin UI for listing and version management
LWS49 Jul 29, 2026
e8e0de5
feat(marketplace): launch a hands-on preview in the sandbox container
LWS49 Jul 31, 2026
e572d95
feat(marketplace): suppress real-course side effects in preview courses
LWS49 Jul 31, 2026
f38ac40
feat(assessment): serve breadcrumbs from a payload of their own
LWS49 Jul 31, 2026
926a428
feat(marketplace): confine previewers to the preview flow
LWS49 Jul 31, 2026
3dee33b
feat(marketplace): de-link sandbox navigation for a restricted previewer
LWS49 Jul 31, 2026
ee9d06f
feat(marketplace): show the sandbox explainer on the container's home…
LWS49 Jul 31, 2026
5393d46
feat(marketplace): let a previewer reset their own preview submission
LWS49 Jul 31, 2026
6b8d3a0
feat(marketplace): add the "Try it hands-on" launch button
LWS49 Jul 31, 2026
8fdab02
feat(marketplace): add the preview sandbox banner and its Reset action
LWS49 Jul 31, 2026
05bbfad
feat(marketplace): poll auto-marking and refresh the preview in place
LWS49 Jul 31, 2026
add945c
feat(marketplace): tailor the publish toast to the preview sandbox
LWS49 Jul 31, 2026
54119b1
fix(submission): send a missing submission to a usable not-found page
LWS49 Jul 31, 2026
2086bbe
fix(submission): render the scribing canvas before its loading indicator
LWS49 Jul 31, 2026
ea06272
fix(submission): don't call stopRecord when nothing was recording
LWS49 Jul 31, 2026
55f0039
chore(marketplace): reap aged preview submissions on a TTL
LWS49 Jul 31, 2026
3c9a87a
docs(marketplace): drop dangling design-spec references from comments
LWS49 Jul 31, 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
6 changes: 6 additions & 0 deletions app/controllers/announcements_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ def publicly_accessible?
requesting_unread? || action_name.to_sym == :mark_as_read
end

# The announcement bell polls this on every page. There are no global announcements on the preview
# instance, but a 403 on every poll would surface to the previewer as a stream of errors.
def preview_sandbox_accessible?
true
end

private

def requesting_unread?
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class ApplicationController < ActionController::Base
include ApplicationAbilityConcern
include ApplicationAnnouncementsConcern
include ApplicationPaginationConcern
# Last, so its `before_action` runs after the tenant is deduced and the user authenticated — both of
# which it reads.
include ApplicationPreviewSandboxConcern

rescue_from AuthenticationError, with: :handle_authentication_error
rescue_from IllegalStateError, with: :handle_illegal_state_error
Expand Down
8 changes: 8 additions & 0 deletions app/controllers/attachment_references_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ def show
end
end

protected

# A previewed assessment's questions carry attachments (description images, template files) and its
# answers accept uploads, so both reading and writing an attachment are part of attempting one.
def preview_sandbox_accessible?
true
end

private

def file_params
Expand Down
87 changes: 87 additions & 0 deletions app/controllers/concerns/application_preview_sandbox_concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true
# Confines a marketplace previewer to the preview flow, at the request layer.
#
# `Course::Assessment::Marketplace::PreviewLaunchService` enrols a previewer as a `manager` of the one
# container course — the lowest role that can attempt, grade and publish, which the submission
# machinery needs. That role is also, on the ordinary abilities, a licence to read and edit the whole
# course. `Course::AssessmentMarketplaceAbilityComponent` subtracts from it verb by verb, and the
# de-linked sidebar and breadcrumbs hide what is left; neither stops a typed URL, and a subtractive
# list can never be complete — it has to name every component, every custom verb and every collection
# action, and each new one arrives switched on.
#
# So this inverts it: on the preview instance a non-administrator may reach only what a controller
# explicitly claims. `preview_sandbox_accessible?` defaults to false and is overridden by the handful
# of actions the preview flow actually makes, mirroring how `publicly_accessible?` marks out the
# unauthenticated surface. A component added tomorrow is denied here without anyone remembering to
# deny it.
#
# The lock is per-VIEWER, not per-course: a system administrator curates the container from inside it
# — its version snapshots, and the authoring copies `RestoreAuthoringJob` rebuilds there — so the
# sandbox stays fully navigable for them. Mirrors the `!user&.administrator?` guard that gates the
# content freeze in `Course::AssessmentMarketplaceAbilityComponent#define_permissions`.
#
# `CanCan::AccessDenied` rather than a bespoke error: `ApplicationUserConcern` already rescues it into
# a JSON 403, and the frontend already knows what that means.
module ApplicationPreviewSandboxConcern
extend ActiveSupport::Concern

included do
before_action :enforce_preview_sandbox_lock!

# The root payload reports the lock to the courseless navigation shell, which has no course to read
# a flag off — the 404 page drops its "go back home" link on it. One fact, one source: the same
# predicate this concern enforces, rather than a second guess at who is confined.
helper_method :preview_sandbox_locked?
end

protected

# Whether this action belongs to the marketplace preview flow, and may therefore run for a
# non-administrator on the preview instance. Deny by default; override in the controllers that serve
# the flow.
#
# Devise is exempt wholesale rather than by action: sign-in, sign-up, password reset and
# confirmation all happen on the preview host, so a previewer who arrives without a session must be
# able to complete them or the sandbox is unreachable. Exempting the base class cannot miss one of
# the four subclasses.
#
# The root payload (locale, time zone, and the courses the user is in — here, only the container) is
# fetched on every page, the previewer's included. Singled out the same way `publicly_accessible?`
# singles out that one action.
#
# @return [Boolean]
def preview_sandbox_accessible?
devise_controller? || (controller_name == 'application' && action_name.to_sym == :index)
end

# Whether `assessment_id` names an assessment a previewer was actually handed: the snapshot a listed
# listing currently serves. Every other assessment in the container — superseded snapshots, restored
# authoring working copies, snapshots of delisted listings — is content nobody was given a URL to,
# and ids there run consecutively, so hiding the index is not on its own a boundary.
#
# Deliberately no `can?` call. This runs before `load_and_authorize_resource :course`, and
# `Course::Controller#current_ability` memoizes on `current_course`; building the ability here would
# freeze a nil-course one for the rest of the request and deny the previewer everything downstream.
# The marketplace allow-list is enforced where the preview is launched, not on every page of it.
#
# @param [Integer, String, nil] assessment_id
# @return [Boolean]
def previewable_assessment?(assessment_id)
Course::Assessment::Marketplace::Listing.serving_assessment?(assessment_id)
end

private

def enforce_preview_sandbox_lock!
return unless preview_sandbox_locked?
return if preview_sandbox_accessible?

raise CanCan::AccessDenied
end

def preview_sandbox_locked?
return false if current_user&.administrator?

Course::Assessment::Marketplace::PreviewContainerService.preview_instance?(current_tenant)
end
end
100 changes: 98 additions & 2 deletions app/controllers/course/assessment/assessments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ class Course::Assessment::AssessmentsController < Course::Assessment::Controller
include Course::Assessment::Question::KoditsuQuestionConcern
include Course::Assessment::KoditsuAssessmentInvitationConcern

before_action :load_submissions, only: [:show]
before_action :load_submissions, only: [:show], unless: :crumb_request?
after_action :create_koditsu_invitation_job, only: [:update]
after_action :create_fetch_koditsu_submissions_job, only: [:update]

include Course::Assessment::MonitoringConcern
include Course::Statistics::CountsConcern

before_action :load_question_duplication_data, only: [:show, :reorder]
before_action :load_question_duplication_data, only: [:show, :reorder], unless: :crumb_request?

def index
@assessments = @assessments.ordered_by_date_and_title.with_submissions_by(current_user)
Expand All @@ -30,15 +30,22 @@ 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
@assessment_time = @assessment.time_for(current_course_user)
return render 'authenticate' unless can_access_assessment?
return render 'crumb' if crumb_request?

@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 @@ -249,6 +256,15 @@ def plagiarism

protected

# Both breadcrumb handles on a preview submission page fetch `show`, so the previewer needs it —
# but only for a title, and only for the snapshot they were handed. Not the page: a previewer is a
# `manager`, and `show` serves a manager the whole authoring surface. The index is not here either:
# it is the whole container, one row per published snapshot and per restored authoring copy, each
# with an Attempt button.
def preview_sandbox_accessible?
crumb_request? && previewable_assessment?(params[:id])
end

def load_assessment_options
return super if skip_tab_filter?

Expand All @@ -257,6 +273,86 @@ def load_assessment_options

private

# Whether this `show` is asking only for what a breadcrumb renders — the assessment's title and its
# tab's. Both crumb handles on any assessment page fetch `show`, and so does the assessment page
# itself; one endpoint serving both is what made the marketplace sandbox's crumb allowance a licence
# to read the authoring surface. Splitting them on the request rather than on the viewer keeps the
# payload the same for everyone and saves the page's ~50 queries on a fetch that renders two strings.
#
# @return [Boolean]
def crumb_request?
action_name.to_sym == :show && params[:crumb].present?
end

# 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: the index is a hot path used by every course, and the badge is noise for
# the previewers enrolled into the container as managers. `:manage, :all` is what excludes them —
# course managers hold a blanket `can :manage, Course`, which satisfies any `Course`-subject ability
# but never the `:all` subject (Ability#initialize grants that to administrators only).
#
# @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.
#
# `without_tenant` is load-bearing. Viewing a container snapshot means the request is tenanted to
# the container's instance, so a source published elsewhere has its course filtered out and
# `assessment.course` returns nil rather than raising — dropping the link in the common case.
#
# @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,
**host_options(assessment.course.instance.host))
end
end

# `Instance#host` carries the port the app is publicly served on, and that port must be named
# explicitly: a controller's `url_options` always supplies `port: request.optional_port`, and Rails
# reads a port out of `host:` only when no `:port` key is present, so passing the host alone swaps
# in the port the request reached Rails on.
#
# Duplicated from System::Admin::MarketplaceListingsController, which sits above this commit in the
# stack; extract once the marketplace stack lands.
#
# @param [String] host an instance host, optionally carrying a port
# @return [Hash] the `host:`/`port:` options for a url on that instance
def host_options(host)
name, port = host.split(':', 2)
{ host: name, port: port }
end

def load_assessment_submission_counts
@all_students = current_course.course_users.students.without_phantom_users
@assessment_counts = num_submitted_students_hash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ class Course::Assessment::Marketplace::ListingsController < Course::Assessment::

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. The source course is deliberately NOT preloaded: the MVP exposes no
# attribution, so nothing in the view reaches for it.
# 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.
includes(assessment: :lesson_plan_item).to_a
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(&:assessment_id))
@question_counts = question_counts(@listings.map { |listing| listing.current_version.assessment_id })
@destination_tabs = destination_tabs
end
end
Expand All @@ -26,16 +28,37 @@ def duplicate

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

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

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

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

# A preview rehearses the SNAPSHOT, never the authoring copy — the same row a duplicate would
# copy. Guarded here rather than in the service so a published listing with no
# snapshot is denied instead of crashing on a nil deep inside provisioning.
raise CanCan::AccessDenied unless @listing.current_version&.assessment

authorize!(:preview_in_marketplace, @listing)
url = Course::Assessment::Marketplace::PreviewLaunchService.launch(@listing, current_user)
render json: { url: url }
end
end

private

def authorize_access!
Expand Down Expand Up @@ -67,11 +90,12 @@ def destination_tabs

def authorized_listings
listings = ActsAsTenant.without_tenant do
Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).includes(:assessment)
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.assessment) }
listings.each { |listing| authorize!(:duplicate_from_marketplace, listing) }
authorize!(:duplicate_to, current_course)
listings
end
Expand Down
Loading