Skip to content

Filter course schools by education phase - #6263

Draft
tomas-stefano wants to merge 15 commits into
mainfrom
td/filter-course-schools-by-education-phase
Draft

Filter course schools by education phase#6263
tomas-stefano wants to merge 15 commits into
mainfrom
td/filter-course-schools-by-education-phase

Conversation

@tomas-stefano

Copy link
Copy Markdown
Contributor

Context

WIP

@tomas-stefano tomas-stefano added the deploy A Review App will be created for PRs with this label label Jul 30, 2026
@tomas-stefano tomas-stefano changed the title Td/filter course schools by education phase Filter course schools by education phase Jul 30, 2026
CourseSchools::Creator and Remover keyed on gias_school_id, but the
provider_school unique index is (provider_id, gias_school_id, site_code)
— a provider may legitimately hold the same GIAS school under two site
codes. Creator#find_by! then picked between them arbitrarily, and
Remover#destroy_all detached both.

Both services now take the Provider::School directly. The one caller,
UpdateCourseSchoolsService, does the site -> urn -> gias_school ->
provider_school hop itself; the warn-and-skip for a provider that has
not been backfilled moves there with it.

No user-visible change.
Moving the course school pickers onto Provider::School keys them on uuid,
which relies on provider_school.uuid being a copy of site.uuid. Three
checks find the rows where that pairing has broken:

  orphan_provider_schools  a Provider::School with no kept school Site.
                           Blocking — Provider::School has no discarded_at,
                           so a site removed by discard_invalid_schools can
                           leave its provider_school behind, and that school
                           would reappear in the migrated picker.
  unmapped_sites           a kept school Site with no Provider::School;
                           it would vanish from the picker.
  unmapped_attachments     a live course_site row with no course_school row;
                           relies on the picker's union rule.

rake schools_remodel_preflight:run[2026] aborts when the blocking check
finds anything, so it can gate a deploy.
CourseSchools::Identity delegated its school list to
ProviderSchools::Identity, so it returned legacy Sites until the
schools_remodel_cycle_year cutover. The course pickers need GIAS
education phase data, which only hangs off Provider::School, so they move
to the new model now and this stops branching on the cycle.

current_school_uuids unions two sources: course.schools, plus the
provider_schools whose uuid matches an attached legacy site. The second
covers courses the schools backfill skipped — without it the picker would
render those attachments unticked and the next save would detach them. A
legacy attachment with no Provider::School is deliberately left out,
since the picker cannot render it and the write path could not address
it. Both gaps are counted by the pre-flight report.

The cycle predicate survives as legacy_site_writes?, which is now purely
a write-path question, and the legacy union is skipped once it is false:
after rollover ProviderCopier mints fresh uuids, so site.uuid and
provider_school.uuid diverge and matching on them would be wrong.

school_records_for no longer raises. A school removed by a colleague
between render and submit, a tampered form and a stale wizard session all
arrive there, and none of them should 500; it returns the records it
resolved alongside the uuids it could not.

Still has no callers — the pickers switch over in later commits.
Three gaps that would have degraded silently once the pickers list
Provider::School records:

NewlyAddedTagComponent guarded on respond_to?(:register_import?), so it
rendered nothing for Provider::School — the "Newly added" rollover cue
would have vanished with no failing test. added_via is only recorded on
the legacy site, so Provider::School reaches it through a legacy_site
association matched on the shared uuid, and the component now calls
register_import? directly. The association is preloaded in
CourseSchools::Identity so the tag costs one query, not one per checkbox.

Provider::SchoolDecorator had no full_address, so delegate_all forwarded
to the model and the checkbox hints lost the smart quotes that
SiteDecorator applies. Both models now render addresses identically.

legacy_site returns nil after rollover, when ProviderCopier mints fresh
uuids and the pairing no longer holds — correct, since the tag only shows
during the 2026 rollover anyway.
The pickers are about to post Provider::School uuids, so the write path
diffs uuids and resolves them through CourseSchools::Identity.

Orphan protection falls out of that: current_school_uuids only contains
schools the picker could render, so an attachment it cannot represent is
in neither the submitted nor the current set and is never treated as a
removal. Previously course.site_ids included such rows and unticking
anything detached them.

The legacy SiteStatus write survives via provider_school.legacy_site.
Attaching without it would publish a course Find cannot return, since
Course.findable joins site_statuses, so a missing site raises and rolls
the transaction back. Detaching is the user cleaning up and must not be
blocked, so that logs instead.

normalise_params translates legacy site_ids payloads. Two senders need
it: the pickers until they post uuids themselves, and jobs enqueued
before this deploy — which are by definition the largest submissions,
since only those above ENQUEUE_THRESHOLD are enqueued. Without it the
service would diff against nothing and silently discard them.

Notification names now come from Provider::School, so main sites read
"<GIAS name> (Main site)", and are sorted: neither course.schools nor the
legacy union has an inherent order, and an unstable one would notify for
an unchanged course.

The system fixtures created provider_schools without copying site.uuid,
which production always does. SchoolPairingHelper states that invariant
in one place so fixtures stay representative.
The edit picker now lists Provider::School records and posts their uuids,
which is what lets the next commits filter the list by the education
phase held on gias_school.

Labels and hints come from the paired GIAS school rather than the site,
so a main site reads "<GIAS name> (Main site)" and addresses use the GIAS
county rather than the site's address4. Ordering moves from the site's
location_name to the GIAS name, which reshuffles the list for providers
whose site names diverged from GIAS.

A legacy site with no Provider::School can no longer be rendered, so it
disappears from the picker. The pre-flight report counts those; the audit
in school-mapping-gaps-by-provider.md records exactly one kept, URN-less
school site in the 2026 cycle and no findable course using it.

The site_ids error keys move with the field, or the error summary would
render "translation missing".
attached_schools_count sits directly above the checkbox list and branched
on :course_publishing_uses_new_school_model, so neither branch matched
what the page rendered: with the flag off it counted course.sites, which
includes discarded sites and sites with no Provider::School.

The rollover error copy had the same problem — it chose between "review
the schools" and "enter schools" from course.sites, so a provider could
be told to review a list that rendered empty.

Both now read CourseSchools::Identity#current_school_uuids, so the
number, the copy and the ticked boxes cannot disagree.

The list display spec's legacy/new-model split goes with it: there is one
read path now, and its former "legacy" fixtures were sites with no
Provider::School, which is a case worth keeping as its own example.
The course creation path resolved schools by walking site -> urn ->
gias_school -> provider_school, which meant a provider holding the same
GIAS school twice was ambiguous and a site whose GIAS school had since
closed was silently dropped from the new model while still being attached
to the legacy one.

It now resolves the selection to Provider::School records directly.
school_uuids is the form the pickers are moving to; sites_ids is still
accepted, translated through the shared site/provider_school uuid, until
every caller has moved over.

Two behaviour changes fall out. A site with no Provider::School is no
longer selectable at all, rather than getting a legacy row on its own —
consistent with the picker, which cannot render it either. And the
GiasSchool.available filter is gone, so a course can now be attached to a
school GIAS has closed; the picker offers it, so refusing to record it
would leave the two models disagreeing.

The legacy site_status write stays unconditional here, unlike the edit
path. Courses::PublishRules::SchoolPresence reads course.sites whenever
:course_publishing_uses_new_school_model is off, in every cycle, so
skipping it after the cutover fails validation on the schools step.

System fixtures gain the provider_school rows production always has.
The wizard step now lists Provider::School records and stores their
uuids, matching the edit picker, so both can be filtered by education
phase in the same place.

Draft#school_uuids drops anything that is not a uuid. Sessions started
before this deploy hold integer site ids in the state store, and querying
uuid = '123' raises PG::InvalidTextRepresentation — a stale session
re-asks the question instead of erroring.

The check answers row reads through the decorator, so a main site is
listed there as "<GIAS name> (Main site)" too.

Note the auto-select-when-one-school shortcut now counts Provider::School
records, so a provider whose only other sites are unpaired skips the step
entirely. That becomes more visible once phase filtering lands, and is
worth confirming with the product owner.
The last picker to move. All three now list Provider::School records and
post uuids, so CourseSchools::Identity is the single source for what a
provider may attach to a course.

CourseDecorator#sites_ids becomes #school_uuids and reads through the
identity, since it round-trips the in-progress course through the hidden
fields that carry it across the legacy add-course steps.

The single-school shortcut in #new and #back counts Provider::School
records rather than sites, so a provider whose only other sites are
unpaired now skips the page. Same caveat as the wizard.

The schools list is exposed as a helper method rather than an ivar:
#continue re-renders :new on validation failure without running #new.

Two spec-side notes. The error summary anchor moved with the field name,
from site-ids to school-uuids. And several fixtures created sites with no
Provider::School, which production never has — those schools were
silently unselectable, which is exactly the failure mode the pairing
helper exists to prevent.
The education phase and age range have been imported from GIAS since the
gias_school table was created but read nowhere in the app. This is what
reads them.

Most phase codes map straight onto a level, with all-through counting as
both primary and secondary. "Not applicable" is the largest phase in
GIAS, so those are segmented by statutory age range instead, and more
than one level's rule can match the same school.

The rules fail open. A school with no phase code, a code this enum does
not know, or "not applicable" with no usable age range shows on every
level — a provider must never lose access to a school because of a gap in
GIAS data.

minimum_age and maximum_age are free text, so the cast strips non-digits
and NULLIFs before ::int. A CASE guard would read more naturally but
Postgres does not promise the condition is evaluated before the cast in
its branch, so it can still raise 22P02; this cannot.

The predicate is one disjunction in a raw SQL fragment and Rails ANDs
those onto a relation without wrapping them, so the outer parentheses are
load-bearing. There is a spec pinning that.

Nothing calls the scopes yet — the pickers pass the level in next.
Filtering by education phase can leave a provider with nothing to select.
Further education is the exposed case: only the sixteen_plus phase code
matches it outright, so a school-based provider whose schools are all
secondary would see an empty list and no way forward but the GIAS link in
the callout.

The check is built from Provider::School.for_course_level, the same scope
the picker uses, so the report cannot drift from what providers see. It
reports the provider's total school count alongside, so the size of the
problem is visible rather than just its existence.

Not blocking — an empty list is a content decision for the product owner,
not a data integrity fault.
This is the change providers asked for. The picker listed every school in
their account, so a Biology course offered primary schools and providers
coped by selecting everything, which polluted Find results.

All three pickers now pass the course level through
CourseSchools::Identity: the edit form and the legacy add page take it
from the course, the wizard from its state store, since it has no course
record yet.

Schools already attached to the course are unioned back in. Rolled over
courses keep schools the filter would now hide, and detaching those is a
separate ticket — leaving them visible and ticked means a provider
removes them deliberately rather than the next save doing it silently.
There is a system spec covering exactly that round trip.

out_of_phase_schools? reports when the union has surfaced one, because
the callout copy says only schools of a single phase are shown and that
stops being true when it does. The callout reads it next.
A filtered list is only helpful if providers understand why a school they
expected is missing, so a callout at the bottom of all three pickers names
the course level and spells out the two things they can act on: the school
may not be in their account, or its GIAS record may be wrong.

The heading softens when an out-of-phase school is still listed. Saying
"only secondary schools are shown" is untrue the moment a rolled over
attachment surfaces, which the picker does deliberately.

New app-inset-text--attention modifier: orange, sitting between
--important (blue, action needed) and --error. This explains rather than
warns.

The final design and content were still marked as pending on the Lucid
board, so the copy is the version from the mockup and is worth confirming
with the designer before release.
@tomas-stefano
tomas-stefano force-pushed the td/filter-course-schools-by-education-phase branch from e7751f3 to 8025a6a Compare July 30, 2026 10:13
Brakeman flagged the for_course_level scope as possible SQL injection. It
was a false positive — every interpolated value was a frozen constant and
the level only picked between them via fetch — but the check cannot see
that, and a reader has to trace the constants to be sure too.

The three finished predicates are now built once at load time into
FOR_COURSE_LEVEL_SQL, so the query call site takes a string it looked up
rather than one it interpolated. Nothing a caller passes can reach the
SQL, which is now evident rather than inferred.

No behaviour change, and no brakeman.ignore entry needed.
@github-actions

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy A Review App will be created for PRs with this label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant