Skip to content

Commit bbd43f0

Browse files
committed
fix(marketplace): track adoptions through a course roll-forward
Adoption rows were keyed off the SOURCE's own marketplace listing, so they only ever fired for copies of the authoring assessment. A copy of an adopted copy -- next semester's course, rolled forward -- carries no listing of its own, so it dropped out of the listing's reach silently: no adoption row, no "a newer version is available" reminder ever again, and the count missed a course genuinely using the content. Key the row off the source's own adoption row instead. The chain now propagates through every generation, and each copy inherits the vintage its source holds rather than what the listing currently serves, so a rolled-forward copy is still told when it falls behind. Copies of the assessment that AUTHORS a listing stop being recorded. Those are the publisher's own -- their course rolled forward, or the assessment handed to a colleague directly -- made without anyone choosing the listing, and recording them let a listing with no adopters at all show an adoption count that climbed every semester its author re-ran the course.
1 parent 6eac0c4 commit bbd43f0

4 files changed

Lines changed: 163 additions & 42 deletions

File tree

app/models/course/assessment.rb

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -295,29 +295,40 @@ def csv_downloadable?
295295
questions.any?(&:csv_downloadable?)
296296
end
297297

298-
# Records +duplicate+, a copy of this assessment, as an adoption of this assessment's marketplace
299-
# listing. Every copy of a listed assessment is an adoption, whichever duplication path produced
300-
# it, so this is called by the duplication services rather than by the marketplace's own job.
298+
# Records +duplicate+, a copy of this assessment, against the marketplace listing its content came
299+
# from. Called by the duplication services rather than by the marketplace's own job, because a copy
300+
# of an adopted assessment is made by ordinary duplication -- rolling a course forward for the new
301+
# semester, copying a selection of objects across -- and a copy the listing cannot reach is a copy
302+
# it can never send a version reminder to.
303+
#
304+
# Keyed off this assessment's own ADOPTION ROW, so only content that came through the marketplace
305+
# propagates. An assessment that AUTHORS a listing is deliberately not a source here: copies of it
306+
# are the publisher's own -- their course rolled forward, or the assessment handed to a colleague
307+
# directly -- made without anyone choosing the listing, so counting them would let a listing nobody
308+
# adopted show a rising adoption count.
301309
#
302310
# The listing itself is never carried over -- +initialize_duplicate+ below does not duplicate the
303-
# +marketplace_listing+ association -- so a copy always starts out unlisted.
311+
# +marketplace_listing+ association -- so a copy always starts out unlisted, which is exactly why a
312+
# copy of a copy has to find its listing through the source's adoption row rather than its own.
304313
#
305314
# @param [Course::Assessment] duplicate The saved copy of this assessment.
306315
# @param [Course] destination_course The course the copy was duplicated into.
307316
# @param [User] current_user The user who triggered the duplication.
308317
def record_marketplace_adoption(duplicate, destination_course, current_user)
309-
return unless marketplace_listing&.published?
310318
# Publishing duplicates the source INTO the container to cut a snapshot. That is the listing
311319
# growing a version, not a course adopting it, so the container is never an adopter.
312320
return if destination_course.preview?
313321

322+
source_adoption = Course::Assessment::Marketplace::Adoption.find_by(duplicated_assessment_id: id)
323+
return if source_adoption.nil?
324+
314325
Course::Assessment::Marketplace::Adoption.create!(
315-
listing: marketplace_listing,
326+
listing: source_adoption.listing,
316327
destination_course: destination_course,
317328
duplicated_assessment: duplicate,
318-
# Stamped here rather than at the call site: this is the single writer of adoption rows, and the
319-
# adopter's "your copy is behind" banner has nothing to compare against without it.
320-
adopted_version_at: marketplace_listing.current_version&.published_at,
329+
# The vintage the SOURCE holds, not what the listing currently serves: crediting a rolled-forward
330+
# copy with the latest version would silently mark stale content as up to date.
331+
adopted_version_at: source_adoption.adopted_version_at,
321332
creator: current_user,
322333
updater: current_user
323334
)

app/services/course/duplication/base_service.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ def initialize_duplicator(*)
3232

3333
# Hands every duplicated assessment its own copy so it can record a marketplace adoption. Copies
3434
# made outside +Course::Assessment::Marketplace::DuplicationJob+ -- selected object duplications
35-
# and full course duplications that happen to carry a listed assessment along -- are adoptions
36-
# too, and the listing has to know about them to reach every course holding a copy.
35+
# and full course duplications that happen to carry an ADOPTED assessment along, most often a
36+
# course rolled forward for a new batch of students -- are adoptions too, and the listing has to know
37+
# about them to reach every course holding a copy.
3738
#
3839
# This sweep lives in the duplication service rather than in a model's +after_duplicate_save+
3940
# hook because that hook only runs for the top-level objects of an object duplication, and never

spec/jobs/course/assessment/marketplace/duplication_job_spec.rb

Lines changed: 67 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -362,19 +362,6 @@ def backdate_current_version(record = listing)
362362
end
363363
end
364364

365-
# Grandchildren-excluded: an adoption is written for copies of a *listed* assessment, and a copy
366-
# is never itself listed, so duplicating an already-adopted copy writes no second-generation row.
367-
it 'does not write an adoption for an ordinary ObjectDuplicationService copy' do
368-
run
369-
copy = destination_course.assessments.order(:created_at).last
370-
third_course = create(:course)
371-
expect do
372-
Course::Duplication::ObjectDuplicationService.duplicate_objects(
373-
destination_course, third_course, copy, current_user: user
374-
)
375-
end.not_to change(Course::Assessment::Marketplace::Adoption, :count)
376-
end
377-
378365
# The sidebar entry point sends no tab, and a tab from another course can be sent by an
379366
# out-of-date URL. Neither may leave the redirect pointing at a tab the user cannot open.
380367
describe 'when the requested tab is absent or foreign' do
@@ -429,11 +416,11 @@ def run_with_tab(tab_id)
429416
end
430417
end
431418

432-
# A listed assessment can leave its course by paths that do not go through this job: an
433-
# instructor duplicating selected objects, or a full course duplication that carries the
434-
# listed assessment along. Those copies must obey the same two rules as the job's copies --
435-
# the listing stays singular, and the destination course is recorded as an adopter.
436-
describe 'manual duplication of a listed assessment' do
419+
# Marketplace content leaves a course by paths that do not go through this job: an instructor
420+
# duplicating selected objects, or a full course duplication carrying the assessment along. Both
421+
# must keep the listing singular, and both must record the destination as an adopter -- but only
422+
# for content that came THROUGH the marketplace, which is what separates these two describes.
423+
describe 'manual duplication of the assessment that authors a listing' do
437424
let(:manual_destination) { create(:course) }
438425

439426
before { listing }
@@ -460,14 +447,11 @@ def duplicate_whole_course
460447
expect(copy.marketplace_listing).to be_nil
461448
end
462449

463-
it 'records the destination course as an adopter' do
464-
copy = nil
465-
expect { copy = duplicate_selected_objects }.
466-
to change(Course::Assessment::Marketplace::Adoption, :count).by(1)
467-
adoption = Course::Assessment::Marketplace::Adoption.order(:id).last
468-
expect(adoption.listing).to eq(listing)
469-
expect(adoption.destination_course).to eq(manual_destination)
470-
expect(adoption.duplicated_assessment).to eq(copy)
450+
# The publisher handing their own assessment to somebody directly bypassed the marketplace
451+
# entirely, so the marketplace has no adoption to record.
452+
it 'records no adoption' do
453+
expect { duplicate_selected_objects }.
454+
not_to change(Course::Assessment::Marketplace::Adoption, :count)
471455
end
472456
end
473457

@@ -481,13 +465,65 @@ def duplicate_whole_course
481465
expect(new_course.assessments.map(&:marketplace_listing)).to all(be_nil)
482466
end
483467

484-
it 'records the new course as an adopter' do
468+
# The publisher rolling their own course forward. Recorded, this would let a listing nobody
469+
# has adopted show an adoption count that climbs by one every semester its author re-runs.
470+
it 'records no adoption' do
471+
expect { duplicate_whole_course }.
472+
not_to change(Course::Assessment::Marketplace::Adoption, :count)
473+
end
474+
end
475+
end
476+
477+
# The other half: an ADOPTED copy carried along by ordinary duplication. This is the semester
478+
# roll-forward, and the copy it makes both counts as a course using the listing and stays
479+
# reachable by the listing's version reminders.
480+
describe 'manual duplication of an adopted copy' do
481+
let(:adopting_course) { create(:course) }
482+
483+
# The real import path, so the copy carries a genuine adoption row rather than a hand-built one.
484+
def adopt
485+
described_class.perform_now([listing.id], adopting_course,
486+
adopting_course.assessment_categories.first.tabs.first.id,
487+
current_user: user)
488+
adopting_course.assessments.order(:created_at).last
489+
end
490+
491+
context 'when duplicating selected objects' do
492+
it 'records the destination course as an adopter of the same listing' do
493+
adopted = adopt
494+
onward_destination = create(:course)
495+
copy = nil
496+
497+
expect do
498+
copy = Course::Duplication::ObjectDuplicationService.duplicate_objects(
499+
adopting_course, onward_destination, adopted, current_user: user
500+
)
501+
end.to change { listing.reload.adoption_count }.from(1).to(2)
502+
503+
adoption = Course::Assessment::Marketplace::Adoption.
504+
find_by(duplicated_assessment_id: copy.id)
505+
expect(adoption.listing).to eq(listing)
506+
expect(adoption.destination_course).to eq(onward_destination)
507+
# The vintage its source held, so the copy is told it is behind once a newer version lands.
508+
expect(adoption.adopted_version_at).
509+
to be_within(1.second).of(listing.current_version.published_at)
510+
end
511+
end
512+
513+
context 'when duplicating the whole course' do
514+
it 'records the new course as an adopter of the same listing' do
515+
adopt
516+
485517
new_course = nil
486-
expect { new_course = duplicate_whole_course }.
487-
to change(Course::Assessment::Marketplace::Adoption, :count).by(1)
488-
adoption = Course::Assessment::Marketplace::Adoption.order(:id).last
518+
expect do
519+
new_course = Course::Duplication::CourseDuplicationService.duplicate_course(
520+
adopting_course, current_user: user, new_title: "#{adopting_course.title} copy"
521+
)
522+
end.to change { listing.reload.adoption_count }.from(1).to(2)
523+
524+
adoption = Course::Assessment::Marketplace::Adoption.
525+
find_by(destination_course_id: new_course.id)
489526
expect(adoption.listing).to eq(listing)
490-
expect(adoption.destination_course).to eq(new_course)
491527
end
492528
end
493529
end

spec/models/course/assessment_spec.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,79 @@
547547
end
548548
end
549549

550+
# Only content that came THROUGH the marketplace propagates: a copy descended from an adoption
551+
# stays reachable by version reminders and counts as a course using the listing, while copies of
552+
# the publisher's own authoring assessment are not adoptions at all.
553+
describe '#record_marketplace_adoption' do
554+
let(:destination_course) { create(:course) }
555+
let(:copy) { create(:assessment, course: destination_course) }
556+
let(:duplicating_user) { create(:user) }
557+
let(:listing) { create(:course_assessment_marketplace_listing, :versioned, course: course) }
558+
559+
def record(source, destination = destination_course)
560+
source.record_marketplace_adoption(copy, destination, duplicating_user)
561+
Course::Assessment::Marketplace::Adoption.find_by(duplicated_assessment_id: copy.id)
562+
end
563+
564+
# Nobody chose the listing here — the publisher is duplicating their own assessment, whether
565+
# into next semester's course or a colleague's. Recording it would let a listing with no
566+
# adopters at all show an adoption count that climbs every term.
567+
it 'records nothing for a copy of the assessment that authors the listing' do
568+
expect(record(listing.authoring_assessment)).to be_nil
569+
end
570+
571+
it 'records nothing for an assessment with neither a listing nor an adoption' do
572+
expect(record(create(:assessment, course: course))).to be_nil
573+
end
574+
575+
# The roll-forward case: next semester's course carries a copy of a copy. That source holds no
576+
# listing of its own, so the chain runs through its adoption row -- without it the new copy
577+
# drops out of the listing's reach and never sees a version reminder again.
578+
context 'when the source is itself an adopted copy' do
579+
let(:adopted) { create(:assessment, course: create(:course)) }
580+
let!(:source_adoption) do
581+
create(:course_assessment_marketplace_adoption,
582+
listing: listing, destination_course: adopted.course,
583+
duplicated_assessment: adopted, adopted_version_at: 30.days.ago.change(usec: 0))
584+
end
585+
586+
it 'records the copy against the same listing' do
587+
adoption = record(adopted)
588+
589+
expect(adoption).to be_present
590+
expect(adoption.listing).to eq(listing)
591+
expect(adoption.destination_course).to eq(destination_course)
592+
end
593+
594+
# The copy holds whatever vintage its source held, NOT the listing's latest -- crediting it
595+
# with the served version would silently mark a stale copy as up to date.
596+
it 'inherits the vintage its source holds rather than the served one' do
597+
adoption = record(adopted)
598+
599+
expect(adoption.adopted_version_at).
600+
to be_within(1.second).of(source_adoption.adopted_version_at)
601+
end
602+
603+
# Unlisting is a visibility decision. Severing the chain there would strand copies that
604+
# already exist and can still be updated.
605+
it 'records the row even once the listing is off the marketplace' do
606+
listing.update!(published: false)
607+
608+
expect(record(adopted)).to be_present
609+
end
610+
611+
# Publishing duplicates the source INTO the container to cut a snapshot. That is the listing
612+
# growing a version, not a course adopting it.
613+
it 'records nothing when the copy lands in the marketplace container' do
614+
container = ActsAsTenant.without_tenant do
615+
Course::Assessment::Marketplace::PreviewContainerService.container_course
616+
end
617+
618+
expect(record(adopted, container)).to be_nil
619+
end
620+
end
621+
end
622+
550623
describe 'in-transaction marketplace authoring re-point' do
551624
# The re-point enqueues nothing, but the env default is `:background_thread` — a real thread
552625
# sharing this example's connection — and these examples assert on row counts in the container.

0 commit comments

Comments
 (0)