Skip to content

Lws49/feat marketplace pr8 singleton attempt - #8521

Draft
LWS49 wants to merge 38 commits into
lws49/feat-marketplace-pr6d-auditfrom
lws49/feat-marketplace-pr8-singleton-attempt
Draft

Lws49/feat marketplace pr8 singleton attempt#8521
LWS49 wants to merge 38 commits into
lws49/feat-marketplace-pr6d-auditfrom
lws49/feat-marketplace-pr8-singleton-attempt

Conversation

@LWS49

@LWS49 LWS49 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

LWS49 added 30 commits July 29, 2026 20:02
A version IS its publication datetime, and the snapshot it serves is duplicated
into a `preview` container course so a published version can never change under
the courses that adopted it. Non-admins cannot edit container content.
Deleting the source assessment now orphans the listing instead of destroying it:
the marketplace keeps serving the snapshot, and the authoring copy is rebuilt in
the container automatically so a new version can still be published.
Nothing rolls back in this suite, so two unscoped `delete_all`s leaked into every
later spec file and failed them under some seeds: `User::Email.delete_all` stripped
the address off every earlier file's users (surfacing as `SMTP To address may not be
blank` in the mailer specs and a nil `user.email` in the external-assessment import
specs), and allow-list rows left behind granted `:access_marketplace` to users the
ability spec asserts cannot have it.

Scope the email cleanup to the domains that file hardcodes, and clear the allow-list
tables in the ability spec the way access_list_query_spec.rb already does.
Browse, listing/question preview and duplication all read the current version's
snapshot rather than the live source assessment, and a manager can cut a new
version from the assessment page.
The banner dates both content vintages rather than numbering them, and cannot be
dismissed or muted: it is a statement of fact about the copy, so it stands until
the copy is updated or deleted. It offers the in-place update only while no
student has submitted; once one has, it explains why no action is possible.
Inside the preview container every listing's snapshots share one title, so the
index chip dates each one and links it to the listing it belongs to.
A snapshot in the container course is an ordinary assessment with every management
affordance live, and editing one is silently destructive: it changes what future
adopters copy for a version that was never published, and stops the version's
publication date describing its content.

A soft guard only. Nothing is disabled, because the surface is admin-only and the
escape hatch for repairing served content without minting a version is deliberate.
The banner names the risk and links straight at the source assessment to edit
instead, on that assessment's own host since it may live on another instance.
Every listing across instances, with its version history, adoption list and source
provenance, plus the actions only an admin has: unlist, re-list, rebuild the
authoring copy, and permanently delete a listing that is off the marketplace.

The whole read path runs tenant-free — listings span instances while their snapshots
live in the preview container — and links to a source assessment are absolute, since
a course id only resolves on its own instance's host.
The listings table, its per-listing page, and the two maintenance buttons. Actions sit
in one fixed order so a given action always occupies the same slot, and delete is
present on every row — disabled until the listing is unlisted — so its tooltip can
state the rule rather than leaving the admin to infer it from a missing icon.
Provisions a hands-on marketplace preview: duplicates the listed
assessment into the singleton container course (linked via
listing.preview_assessment_id, idempotent across re-launches), enrols
the previewer as a course manager, and returns the absolute attempt
URL on the preview host. Runs with system privileges by design — the
Slice 2 content-freeze must not block its own provisioning; the caller
(Slice 3's controller action, not yet added) is responsible for
authorization.
POST /courses/:course_id/marketplace/listings/:id/launch_preview
mirrors #show's authorization (published listing + preview_in_marketplace
gate) and delegates provisioning to PreviewLaunchService, returning the
attempt URL as JSON.
A previewer grades and publishes their own submission in a preview
sandbox course; guard the graded-email chokepoint (the only
notification path that doesn't already self-suppress for non-student
roles) and the Cikgo task-completion push so a rehearsal never emails
the previewer or reports a fake completion to their real LMS.
Aged marketplace preview submissions must be reaped so a previewer can
restart a listing without waiting indefinitely (the unique
assessment/creator index allows only one submission per pair), while
still giving async autograding room to land before deletion. Mirrors
DeletingJob's destroy!-per-row mechanics so the EXP after_destroy hook
and MonitoringService parity aren't skipped by a bulk delete. The
container course, assessment copies, and enrolments are never touched.
Previewers are enrolled as manager of a container course shared by every
previewer in the instance. Revoke cross-previewer submission access,
view_all_submissions, the gradebook, and the users roster inside a preview
course, carving back exactly the previewer's own submission.
show.json.jbuilder emitted @assessment.id under the `id` key, so
TryItHandsOnButton and DuplicateConfirmation posted back with the wrong id to
endpoints keyed on Listing#id — the lookup missed, raised
CanCan::AccessDenied, and the frontend's 403 handler nuked the whole page.
index.json.jbuilder already used listing.id correctly; this brings show in
line with it.
…test

tab.location was an empty object literal, so TS rejected the later .href
read once the redirect assertion was added. Cast it to { href?: string }.
RECORDER_COMPONENT_UNMOUNT unconditionally called recorderHelper.stopRecord()
whenever the last Voice-question component unmounted, even if it was never
recording. stopRecord() rejects in that case ("Recorder has already
stopped"), and nothing awaited or caught it, so it became an unhandled
rejection — silent in production, but rendered as a full-page dev-overlay
crash under React 18 StrictMode's mount/unmount/remount cycle, which fires on
every page load that renders an unanswered Voice question.

Guard on recorderHelper.isRecording() before calling stopRecord(), plus a
defensive .catch() against any residual race.
Once Fabric.js constructs the canvas, it wraps the raw <canvas> node in its
own container div, re-parenting it out from under this div's direct
children. The loading indicator was rendered as a PRECEDING sibling, so any
later toggle back to "loading" (e.g. a redundant re-initialize) required
React to insertBefore a sibling relative to that no-longer-direct-child
node — which throws (surfaced as "Failed to execute 'insertBefore'... is
not a child of this node").

Moving the indicator after the canvas means React only ever needs to
append a trailing sibling, which needs no reference node and is safe
regardless of what Fabric has done to the canvas by then. Added a
regression test that reproduces the crash with a real Fabric canvas (a
stubbed Image whose src setter fires `load` asynchronously, since jsdom
never does) and confirms a redundant re-initialize no longer throws.
The sidebar already de-links entirely for any preview course; breadcrumbs
did not, so a previewer could still click through the course-title/tab/
assessment-title crumbs to browse the rest of the shared sandbox. Adds a
disableLinks prop to Breadcrumbs (renders every crumb as inert text via the
same Link/Crumb de-linking mechanism already used for the last crumb),
driven off CourseLayoutData.isPreview in CourseContainer.
…e page

The container course's own home page is otherwise blank (no announcements,
todos, or activity), yet it's the first thing a previewer sees when
"Try it hands-on" enrolls them there before the assessment tab opens. Seed a
description on the container course (blank-only, so an admin edit is never
clobbered, and seeded on every provisioning call rather than only at
creation since the container is created once and reused forever) and show
it on CourseShow for previewers, who otherwise see nothing else on the page.
…preview banner

Previewers share one submission per (assessment, previewer) — relaunching
"Try it hands-on" resumes it rather than starting over — but until now the
only way to reset it was a manual Rails console script that wipes the
entire shared container course. Adds a "Reset submission" button to the
preview banner, scoped strictly to the current user's own submission for
the assessment on the current page.

Backend: Submission#reset_preview! resets workflow/grading state back to a
pristine `attempting` (works from any prior state, including `published`)
and gives every question a genuinely blank current answer via
`question.attempt(submission)` with no `last_attempt` — unlike `unsubmit`'s
`recreate_current_answers`, this destroys the old answers outright (and,
via their normal dependent: :destroy associations, any auto-grading and
programming test-case results with them) rather than keeping them as
non-current history, since a preview reset has no audit-trail requirement
to preserve. New PreviewSubmissionsController#update authorizes via the
`reset_own_preview_submission` ability (already scoped to the caller's own
submission) and never accepts a submission id from the client — it's always
looked up by (assessment, creator: current_user).

Frontend: the button stays on the same page after resetting (no navigation
away) and re-dispatches fetchSubmission so the now-blank state renders in
place, since the submission row keeps its id.
…ished

publish() already toasted a generic "update successful" message, but
PublishButton also unmounts immediately on success (it only renders while
workflowState === 'submitted'), so in a solo preview session — no student to
observe the effect — publishing looked like it did nothing. When
CourseLayoutData.isPreview is true, the success toast is replaced with copy
explaining what publishing would do in a real course; the failure path is
unchanged.
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr8-singleton-attempt branch from 9d6c66a to 49f0ffb Compare July 30, 2026 04:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant