Skip to main content

Publish teacher training courses - Bulk updating placement schools

Applying one placement school change to many of a provider's courses at once. Large providers were editing the same school course by course, across hundreds of courses.

The attach page no longer writes. It records what was ticked and hands it on; the change is written once the provider has said which courses it is for.

The flow

flowchart TD
    A["/schools<br/>tick the schools"]
    A -->|selection unchanged| W["write this course<br/>then Basic details"]
    A -->|selection changed| D[["draft written<br/>state_key goes in the URL"]]
    D --> B["/schools/bulk-update/:state_key<br/>What courses do you want<br/>to apply this change to?"]
    B -->|only this course| W
    B -->|any wider scope| R["/schools/bulk-update/:state_key/review<br/>the courses that will change,<br/>and the ones that cannot"]
    R -->|"Update ... on N courses"| Q[["BulkUpdateCourseSchoolsJob"]]
    Q --> BD["Basic details<br/>Schools updated on N courses"]
    R -->|Cancel| NC["Basic details<br/>nothing written"]
    B -.->|Back| A
    R -.->|Back| B
    X{{"state_key expired, spent,<br/>or for another course"}} -.-> A

Every bulk page is a GET keyed by the state key, so Back and refresh both work. Back to /schools carries the key, so the boxes show what was ticked rather than what the course holds — nobody re-ticks three hundred schools.

Where the change lives

Publish::Schools::BulkUpdate::Draft, in Rails.cache for 24 hours. The session is a cookie store, so it cannot hold hundreds of UUIDs; a query string cannot either. Same pattern as the add-course wizard.

Field Why
school_uuids what was ticked
baseline_uuids what was attached when the page was served
scope the radio answer, once given

Key is course_schools_bulk_update_{provider}_{cycle}_{course}_{state_key}, so a key issued for one course cannot resolve against another.

The baseline travels rather than being re-read. The pages play back a diff and the write applies that same diff, so both must measure against the schools the provider was looking at — not whatever the course holds when they press the button.

The two rules

The change is a diff, not a list. Each course keeps what it has, gains the additions, loses the removals. Two courses that differed still differ afterwards.

new_set = (course's schools ∪ added) − removed

Applying it twice lands on the same schools, which is why a duplicate submit is harmless and the draft is only deleted after the job is safely queued.

A course cannot be left with no schools. One whose every school is being removed is set aside under These courses will not be updated, unless support has exempted it (publish_without_schools_allowed). Since a course gains whatever is being added, that can only happen when the change adds nothing — so the question is only asked on a pure removal.

A course that already had no schools is not set aside: it is not losing its last one, and the explanation would not be true of it.

The write

sequenceDiagram
    participant J as BulkUpdateCourseSchoolsJob
    participant A as BulkUpdate::Apply
    participant S as UpdateCourseSchoolsService
    participant T as ProviderSchools::TouchParents
    J->>A: call(courses, added, removed)
    Note over A: TouchSuppression.suppress
    loop each course
        A->>S: call(new_set, notify: false)
        S-->>A: written / raised
    end
    A->>T: stamp the provider once
    A-->>J: Result(updated_ids, failed_ids)
    alt failures, attempts left
        J->>J: perform_in(5 min, failed_ids only, attempt + 1)
    else failures, none left
        J->>J: Sentry once, naming every course outstanding
    end

Queued, always: a bulk update writes school relationships and their legacy site statuses for every course it matches, and the single-course write is already queued above thirty schools.

changed_at is UNIQUE on both course and provider, so an unsuppressed run would put thousands of writes on one provider row. Touches are suppressed and the provider is stamped once; each course still gets its own timestamp from its own save.

notify: false — the notification is per course, and one change across hundreds of them would be hundreds of emails saying the same thing.

A course that fails does not hold up the rest and is not dropped: the job comes back for those alone. Anything escaping Apply entirely still bubbles, and Sidekiq retries as it would any job. Per-course failure is ours; whole-job failure is Sidekiq's.

The pieces

Owns
Publish::Courses::SchoolsController the attach page; hands over or writes
…::Schools::BulkUpdatesController the options page
…::Schools::BulkUpdateReviewsController the review page, and confirming
Publish::Schools::BulkUpdateDraft (concern) course + draft + the expiry redirect, shared by both bulk controllers
BulkUpdate::Draft / ::Repository the unapplied change
BulkUpdate::Scope the scope vocabulary — one object per radio, holding both its label and the courses it means
BulkUpdate::MatchedCourses splits the matched courses into will / will not
BulkUpdate::Apply the write, one course at a time
Publish::Schools::SchoolChanges what is being added and removed

Scope is the single source of that vocabulary: the form, the page and the write all read it, so what the provider was offered, what they were shown and what got written cannot drift apart.

The radios

Token Label Matches Shown
only_this_course Only this course - name (code) this course always, above the "or" divider
funding All fee-paying / school direct salaried / apprenticeship courses same funding always
secondary All secondary courses secondary_course secondary courses only
subject All subject courses same subject; same level for primary and further education when there is a subject to name
all All courses every course in the cycle always

Primary and further education are treated as subjects, so they name and match their level. Nothing is pre-selected, and a scope the course was never offered is rejected rather than quietly matching.

Playing the change back

Publish::Schools::ChangesListComponent on both bulk pages: You are adding N schools, You are removing N schools, or all schools in your list either way. Both lists move into details at nine or more — both together, since one open beside one closed reads as though the closed half mattered less.

It is the server-rendered twin of ChangesSummaryComponent, which the browser fills in on the attach page as the provider ticks. They share their wording (publish.schools.changes.*) and their logic is mirrored case for case — SchoolChanges against schools_changes.js, including the asymmetric "all": every school ticked, or none left ticked.

Two things to know before changing it

The exclusion subqueries are bounded on purpose. They are correct unbounded, but Postgres answers an unbounded IN over course_school by materialising the whole table and rescanning it once per matched course — 1.7s for a 150-course provider, on a page they are only reading. Scoped to the matched ids: 12ms.

Confirming does not build the list. MatchedCourses#ids is matched_ids - excluded_ids, not the list query. That holds only because Publish::Courses::Query LEFT joins everything it joins, so no matched course is dropped. A spec pins it.