Skip to content
Merged
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
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
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.
# 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(authoring_assessment_id: nil).
includes(authoring_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(&:authoring_assessment_id))
@question_counts = question_counts(@listings.map { |listing| listing.current_version.assessment_id })
@destination_tabs = destination_tabs
end
end
Expand All @@ -29,11 +31,11 @@ def duplicate
def show
ActsAsTenant.without_tenant do
@listing = Course::Assessment::Marketplace::Listing.published.
includes(:authoring_assessment).find_by(id: params[:id])
includes(current_version: :assessment).find_by(id: params[:id])
raise CanCan::AccessDenied unless @listing

@assessment = @listing.authoring_assessment
# This page renders the authoring copy, which an orphaned listing no longer has — see `index`.
# The SNAPSHOT, never the authoring copy (design §4.2).
@assessment = @listing.current_version&.assessment
raise CanCan::AccessDenied unless @assessment

authorize!(:preview_in_marketplace, @listing)
Expand Down Expand Up @@ -73,10 +75,8 @@ def destination_tabs

def authorized_listings
listings = ActsAsTenant.without_tenant do
# Orphaned listings excluded for the reason `index` gives — the duplicate copies the
# authoring assessment, so there is nothing for it to read.
Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).
where.not(authoring_assessment_id: nil).includes(:authoring_assessment)
includes(current_version: :assessment)
end
raise CanCan::AccessDenied if listings.empty?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ class Course::Assessment::Marketplace::QuestionsController < Course::Assessment:

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

@assessment = listing.authoring_assessment
# An orphaned listing has nothing left to preview — see ListingsController#index.
# The SNAPSHOT, never the authoring copy.
@assessment = listing.current_version&.assessment
raise CanCan::AccessDenied unless @assessment

authorize!(:preview_in_marketplace, listing)
Expand Down
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
Expand Up @@ -2,20 +2,31 @@
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
listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(authoring_assessment: @assessment)
now = Time.zone.now
listing.published = true
listing.first_published_at ||= now
listing.last_published_at = now
# `publisher` is an audit userstamp for the *latest* publish (design D29), so it moves with
# `last_published_at`. `creator` already retains whoever first created the row.
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
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
Expand Down
18 changes: 18 additions & 0 deletions app/jobs/course/assessment/marketplace/apply_version_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true
# Runs the in-place version update in the background, matching marketplace import's polling flow.
class Course::Assessment::Marketplace::ApplyVersionJob < ApplicationJob
include TrackableJob
include Rails.application.routes.url_helpers

queue_as :duplication

protected

def perform_tracked(assessment, options = {})
current_user = options[:current_user]
Course::Assessment::Marketplace::ApplyVersionService.apply(assessment, current_user)

course = assessment.course
redirect_to course_assessment_url(course, assessment, host: course.instance.host)
end
end
105 changes: 86 additions & 19 deletions app/jobs/course/assessment/marketplace/duplication_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,26 @@ class Course::Assessment::Marketplace::DuplicationJob < ApplicationJob

queue_as :duplication

# Mirrors `validates :title, length: { maximum: 255 }` on Course::LessonPlan::Item, which is where
# an assessment's title actually lives.
TITLE_LIMIT = 255

protected

def perform_tracked(listing_ids, destination_course, destination_tab_id, options = {})
current_user = options[:current_user]
ActsAsTenant.without_tenant do
listings = Course::Assessment::Marketplace::Listing.published.where(id: listing_ids)
target_tab = find_tab(destination_course, destination_tab_id)
last_copy = nil
listings.each do |listing|
# The adoption row is written by the duplication service itself, which tracks every copy of a
# listed assessment regardless of the path that produced it. See
# `Course::Duplication::BaseService#record_marketplace_adoptions`.
last_copy = duplicate_listing(listing, destination_course, current_user)
reparent_into_tab(last_copy, target_tab)
copies = listings.map do |listing|
copy = duplicate_listing(listing, destination_course, current_user)
reparent_into_tab(copy, target_tab)
resolve_title_collision(copy, listing, destination_course)
record_adoption(listing, destination_course, copy, current_user)
Comment on lines 17 to +23

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The job now re-filters on the served version, mirroring the browse query.

copy
end
redirect_to assessments_url(destination_course, target_tab || last_copy&.tab)
landing_url = landing_url_for(copies, destination_course)
redirect_to landing_url if landing_url
end
end

Expand All @@ -36,7 +40,7 @@ def find_tab(destination_course, destination_tab_id)
end

def duplicate_listing(listing, destination_course, current_user)
source = listing.authoring_assessment
source = listing.current_version.assessment
Course::Duplication::ObjectDuplicationService.duplicate_objects(
source.course, destination_course, source, current_user: current_user
)
Expand All @@ -50,15 +54,78 @@ def reparent_into_tab(copy, target_tab)
copy.save!
end

# Points at the tab the copies actually landed in. No tab is requested from the sidebar entry
# point, and a requested tab may not belong to the destination course -- in both cases the
# duplication picks the destination's default tab, and the redirect has to follow it there
# instead of naming a tab (and its category) that the user cannot open.
def assessments_url(destination_course, tab)
redirect_category_id = tab&.category_id || destination_course.assessment_categories.first.id
course_assessments_url(destination_course,
category: redirect_category_id,
tab: tab&.id,
host: destination_course.instance.host)
# Renames an imported copy whose title is already taken in the destination course.
#
# Fires on every import, not only on re-import of the same listing: a copy landing on top of an
# unrelated assessment of the same name collides just as badly, and previously landed silently.
#
# Escalates only as far as it has to:
# "Lab 3" -> "Lab 3 [12 Jun 2026]" -> "Lab 3 [12 Jun 2026] (2)" -> (3) ...
#
# @param [Course::Assessment] copy
# @param [Course::Assessment::Marketplace::Listing] listing
# @param [Course] destination_course
# @return [void]
def resolve_title_collision(copy, listing, destination_course)
taken = Course::Assessment.titles_in_course(destination_course, except_id: copy.id)
base = copy.title
return if taken.exclude?(base.downcase)

published_at = ActsAsTenant.without_tenant { listing.current_version&.published_at }
# A listing with no recorded vintage has nothing to name, so it goes straight to the counter —
# stamping an empty "[]" would be worse than the collision it is trying to resolve.
dated = published_at ? "#{base} [#{published_at.strftime('%d %b %Y')}]" : base
candidate = truncate_to_limit(dated, base)

suffix_number = 2
while taken.include?(candidate.downcase)
candidate = truncate_to_limit("#{dated} (#{suffix_number})", base)
suffix_number += 1
end

copy.title = candidate
copy.save!
end

# Truncate the base for an over-long title.
#
# @param [String] candidate
# @param [String] base
# @return [String]
def truncate_to_limit(candidate, base)
return candidate if candidate.length <= TITLE_LIMIT

suffix = candidate.delete_prefix(base)
base.truncate(TITLE_LIMIT - suffix.length) + suffix
end

# Where the completion toast's link sends the manager.
#
# @param [Array<Course::Assessment>] copies
# @param [Course] destination_course
# @return [String, nil] nil when every listing was filtered out by `.published`, in which case
# nothing landed and there is nowhere to link to.
def landing_url_for(copies, destination_course)
return nil if copies.empty?

host = destination_course.instance.host
return course_assessment_url(destination_course, copies.first, host: host) if copies.one?

tab = copies.first.tab
course_assessments_url(destination_course, category: tab.category_id, tab: tab.id, host: host)
end

# Written here rather than left to `Course::Duplication::BaseService#record_marketplace_adoptions`:
# that sweep keys off the SOURCE's own `marketplace_listing`, and the source here is the container
# snapshot, which authors no listing. This path is the only one that knows which listing it served.
def record_adoption(listing, destination_course, copy, current_user)
Course::Assessment::Marketplace::Adoption.create!(
listing: listing,
destination_course: destination_course,
duplicated_assessment: copy,
adopted_version_at: listing.current_version.published_at,
creator: current_user,
updater: current_user
)
end
end
6 changes: 6 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,17 @@ def csv_downloadable?
# @param [User] current_user The user who triggered the duplication.
def record_marketplace_adoption(duplicate, destination_course, current_user)
return unless marketplace_listing&.published?
# Publishing duplicates the source INTO the container to cut a snapshot. That is the listing
# growing a version, not a course adopting it, so the container is never an adopter.
return if destination_course.preview?

Course::Assessment::Marketplace::Adoption.create!(
listing: marketplace_listing,
destination_course: destination_course,
duplicated_assessment: duplicate,
# Stamped here rather than at the call site: this is the single writer of adoption rows, and the
# adopter's "your copy is behind" banner has nothing to compare against without it.
adopted_version_at: marketplace_listing.current_version&.published_at,
creator: current_user,
updater: current_user
)
Expand Down
Loading