Skip to content

Checking Window / Checking Exercise refactor AB#297801 - #323

Merged
davidgouge merged 12 commits into
mainfrom
307-checking-window-exercise-refactor
Aug 20, 2026
Merged

Checking Window / Checking Exercise refactor AB#297801#323
davidgouge merged 12 commits into
mainfrom
307-checking-window-exercise-refactor

Conversation

@davidgouge

@davidgouge davidgouge commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #307.

One PR for the whole epic. The children are not PR'd separately — each lands as commits on this branch and is ticked off below.

Why

The 16-19 window runs two activities on two different date ranges: results enquiry 7 Oct to 31 Mar, pupil data checking 7 Oct to 18 Oct. The user sees one card and one landing page. KS4 Autumn is the same shape, and more window types will follow.

CheckingWindow has one StartDate/EndDate pair, so a window is either fully open or fully closed, and whether the enquiry entry point appears is decided by a window-type test rather than by dates. So the enquiry and pupil-data options are visible for the same range, which is wrong for both, and KS4 Autumn offers no enquiry option at all.

Approach

Add a CheckingExercises child collection to CheckingWindow. Each row carries its own dates. The window keeps the outer pair as the union of its exercises. A window type with one exercise gets one row and does not change behaviour; a window type with several gets several rows, and a third or fourth needs no schema change and no new branch.

Two product decisions this PR must hold to:

  1. When an exercise closes, its actions go, but the content it produced stays read-only — for pupil data that means the list, the search, the CSV download and the ZIP download all keep working.
  2. Exercise state controls actions only, never visibility. A window with no open exercise still shows its card.

Children

Landed so far

#324 — The results-enquiry exercise has its own ingress step, so the enquiry journey works on a deployed environment. Until now nothing wrote results-enquiry/data/ outside Web/Seeding/SeedStudentResults.cs, which is development-only: everywhere else the blob simply did not exist and the result picker had nothing to show. This is what makes the journey real, not a tidy-up.

Slots belong to an exercise. WindowDatasets.DefaultsFor takes the exercise as well as the window type, and WindowService reconciles every exercise rather than only pupil data. A results enquiry gets one slot per source file, each named by the ResultsFileTags tag it stamps — five on a 16-19 window, four on KS4, none on KS2, which has no results feed. Naming the slot after the supplier's own file name is what lets an admin match six delivered files to six upload boxes; a plain label would leave them guessing which of three late-results files is which.

Provenance is stamped by file of origin, the exact analogue of the existing Included flag: Included decides inclusion by which file a record came from, SourceFile decides provenance the same way. No supplier CSV carries a SOURCE column, so the processor stamps it before validation and guards it on the schema declaring the property, exactly as INCLUDED is guarded — AllowAdditionalProperties is false, so an unguarded stamp would fail every run. StudentResultRecord.SourceFile, the result picker's file column and ILateResultsAvailability all read it.

The output name is chosen in the lookup, not by the caller. A run writes CheckingExerciseBlobPaths.DataBlobName(exercise, laestab), a new switch with no default case for the same reason as ExercisePrefix. The two names normalise the laestab differently on purpose — PupilsBlobName strips only the slash so every pupil blob already written is still found, ResultsBlobName runs LaestabNormaliser because that is how the results reader turns a Sign-in claim into a blob name — so a results run that reached for the pupil name would write files the journey cannot find.

One slot may be optional, and that is not a nicety. Six required slots and the existing "every dataset must be complete" rule would have made the exercise permanently unvalidatable: the late, revised and retention files land weeks apart and one may never land at all. Only the main file is required. HasRequiredFiles asks that every required slot is filled and at least one slot is; the run reads DatasetsToIngest, the complete slots only; and because a run rewrites the exercise's whole output, the exercise is simply re-run when the next file arrives. Every pupil-data slot stays required — each 16-19 pupil file carries a whole population, so a run missing one writes a blob missing half the school. The summary page marks optional slots and shows an empty one as "Not supplied".

The migration backfills the slots, because slots are only reconciled when a window is saved through WindowService. Without it every results-enquiry exercise already on a deployed environment — including the ones #317's own backfill created — would read "This exercise has no ingress files to load" until an admin happened to re-save its window, and nobody could upload anything. Only empty slots are created, NOT EXISTS-guarded, and Down removes only slots nobody has uploaded to.

Two things about the input are still assumptions, not confirmed facts. No supplier sample or JSON schema for these files exists in this repo. The CSVs are read as carrying the output contract's own column names (CYPMD_ID, QAN, QUAL_NAME, SYLLABUS, SESSION, GRADEAB#296999), because ingress passes CSV columns through verbatim against the admin-supplied JSON schema and has never had a renaming step; if the headers differ, a mapping step is new work rather than a schema edit. And they must carry a LAESTAB column, which is what splits one supplier file into one blob per school — a file without it now fails the run naming the file, instead of throwing out of the group-by. Both are recorded in docs/16-19-window-model.md so the next person does not re-derive them.

The results-enquiry ingress slice is tested end to end through StudentResultsBlobClient, the client the journey itself reads, so the blob name, the JSON shape and the property names are all checked by the code that has to consume them. The migration's backfill is tested against real Postgres for each window type, for idempotency, and for a rollback that keeps an admin's uploaded files.

#320 — Three loose ends, and one bug found while tying them.

WhatToChangeCheckingExerciseMap moved from Application/ResultsEnquiry/ to Application/WindowManagement/. It maps every WhatToChange member, not just the enquiry one, so filing it under one exercise read as if results enquiry were the special case. #318 had already typed its values, so no exercise-name string constant survives anywhere in the solution — the only "ResultsEnquiry" string left is a comment noting that the value fits ChangeRequest.RequestType's 20-character column.

The Amendment Requests grid stays unsplit. Both populations keep one table, one set of checkboxes, one bulk submit. Splitting it would double the bulk-submit control and the empty states for a school that in practice holds a handful of requests, and would make the common case — a window with one exercise — carry a grouping header that says nothing.

But the deadline on it was wrong. The page read AmendmentRequestsResult.WindowEndDate, which is the outer window's end, and since #319 that is the union of the exercises — so on a 16-19 window it was the results-enquiry close, months after pupil data shuts. The page told a school it still had until 31 March to amend pupil data that closed on 18 October. This is the same defect #317 fixed on Check your pupil data; it survived here because this page reads its own DTO.

WindowEndDate is replaced by Deadlines, one ExerciseDeadlineDto per exercise in SortOrder, each carrying its own EndDate and its own IsOpen from ICheckingExerciseService. The page prints one sentence per exercise: "Submit your … requests by …" while open, "The deadline for … requests passed at …" once closed. The Requests tab lists only the open ones, and says so plainly when every exercise has closed rather than offering an edit that #318 blocks.

The bulk-submit confirmation page had the same bug and is fixed differently: its banner offers another amendment, and that journey shuts when pupil data shuts, so it reads the pupil-data exercise's end specifically. A window that runs no pupil-data exercise now drops the banner rather than quoting a date from an unrelated exercise.

The config-key trigger is written down in docs/16-19-window-model.md. The key stays {WhatToChange}_{CheckingWindowType}.json; the one thing that forces a third axis is two exercises wanting the same WhatToChange for the same window type. The note records what has to move together when that happens — the rename of both the blobs and Web/Data/QuestionFlows/, the retirement of the map, and where IsSessionReady then gets the exercise from (passed by the entry-point controller, not stored on RequestState, which is the disagreement the map exists to prevent).

The design note's open questions 1, 2 and 4 are now answered in place, and 3 is marked as split out to #324.

#319 — An admin can now build a multi-exercise window, which until today had to be done by hand in the database. The wizard gained one step — "Which checking exercises does this window run?" — listing every CheckingExerciseType and pre-ticking the window type's defaults, then asks each ticked exercise for its own dates. That shape satisfies both acceptance criteria at once: a new member of the enum surfaces with no change to the page, and a single-exercise window is one Continue and one date page (fewer steps than the two window-level date pages it replaces). It also means KS4 Autumn can be given a results enquiry by ticking a box, which is the gap docs/16-19-window-model.md opens with.

The outer pair is derived, not typed. StartDateController and EndDateController are deleted, along with their views: there is no window-level date step for the exercises to disagree with. CheckingWindowDto.DeriveDatesFromExercises takes the earliest start and the latest end, and WindowService calls it on every create and update, so moving one exercise's end past the window's own end widens the window rather than being rejected. The summary page shows the derived pair with no Change link and labels it as such.

The validation stamp moved down to the exercise and gained a meaning it never had. CheckingWindow.Validated is gone; CheckingExercise.Validated (ExerciseValidated, renamed from WindowValidated) takes its place. The old one was written unconditionally by both CreateAsync and UpdateAsync, so every window on every environment read as validated whether or not anything had been — which is why the migration deliberately backfills nothing rather than propagating a value that recorded nothing. The new stamp carries the checksums of the datasets the run actually read, so replacing an ingress file afterwards leaves a stamp the summary shows as "Not since the files changed" rather than as a clean bill of health for data nobody validated.

Validation is per exercise, not per window. ValidateWindowController takes the exercise in its route and the summary page renders a Validate button per exercise. A loop over every exercise in one run was considered and rejected: it would emit several terminal events down a progress stream whose client expects one, and would stop an admin revalidating a single exercise after swapping one of its files. One run at a time is exactly the shape the processor and the stream already handle, and is what makes "a window is usable while another exercise is still unvalidated" true rather than merely allowed.

The schema and ingress steps take the exercise in their routes too, because a dataset name is only unique within one exercise. CheckingWindowDto.AllDatasets is retired with its #319 retires this comment — the wizard, the summary and the validate run all name the exercise they mean. Two pre-existing accessibility defects on pages this touched are fixed in passing: the validate page rendered a second <h1> in its no-JS result panel, and the create check-answers page had two <h1>s plus a "Confirm you want to delete this window" heading.

Split out: the results-enquiry ingress step is #324, as this ticket's text allows. docs/results-enquiry.md already places the six-file ingestion pipeline with the FACT tickets, and the supplier CSV column headers for those files are not confirmed anywhere in this repo — #324 records the two questions (headers, and whether the files carry LAESTAB) that a sample CSV would settle.

#318 — Every entry point into a journey now rejects a closed checking exercise, so a bookmarked URL or a tab left open across the closing date can no longer post into a shut journey. The gate is ICheckingExerciseService.IsOpen in all five places: JourneyController.IsSessionReady (which every journey action already ran, so all thirteen are covered by one change), WhatToChangeController.Index/.Confirm and ConfirmCorrectController.Index/.Confirm on PupilData, and ResultIssueController.Index/.Confirm on ResultsEnquiry — that last one was previously reachable for as long as the outer window was open, which on a 16-19 window is months too long in one direction and, for pupil data, months too long in the other. CheckYourPupilDataController's next-steps POST was already gated by #317.

The journey's exercise is derived from RequestState.SelectedWhatToChange through WhatToChangeCheckingExerciseMap, never stored: a stored copy can disagree with the journey's own change type, and every session in flight on deploy day would have it null. Adding an exercise type therefore means adding a row to that map, not editing the guard. The map returns CheckingExerciseType now rather than the string names it carried before the enum existed.

No gated path 404s. Every rejection lands on Check your pupil data with a GOV.UK notification banner saying which deadline has passed and that the data is still viewable and downloadable — closed removes actions, never content. DownloadEvidence was the one action answering NotFound for an unready session; it redirects like the rest now. Wording lives in ClosedExerciseGuard (Web, beside NextStepLabels) with no default case, and a test pins that every CheckingExerciseType has its own message.

Product decision 2026-08-20 on drafts across the boundary: block the resume, not the submit. AmendmentRequestsController.Edit rejects a draft whose exercise has closed, so nobody edits a request that could never be sent. The draft stays listed and readable on Amendment Requests.

#317 — The check-your-pupil-data page's "what would you like to do?" options are built from the exercises open right now, for any number of exercises, instead of from a window-type test. INextStepsService holds the mapping — PupilData gives RequestChange + Confirm, which appear and disappear together because they belong to the same exercise; ResultsEnquiry gives its own option. Adding a future exercise type means adding a row to that dictionary, never a new branch, and nothing in the service looks at CheckingWindowType. An unmapped type contributes nothing rather than throwing — one bad row must not take the page down — and a test pins that every type that exists is mapped, so a new one cannot ship unreachable. A KS4 Autumn window configured with an enquiry exercise now offers the option, which the old Post16-only test denied it.

The POST re-derives the same list and rejects anything outside it, so not rendering an option stays a UI courtesy rather than the only guard. OffersResultsEnquiry and ShowResultsEnquiryOption are gone with both their PARKED comments, and the controller no longer takes a TimeProvider at all — its one clock use was the outer-window comparison, which per #315 belongs in one place.

Three form states. Many options render radios; one renders a single button with a hidden field, because a one-item radio group is a poor pattern and contradicts its own "select one option" hint; none renders no form at all, plus a line saying the window is closed for changes. The tables, the search and the downloads sit above the form and always render — a closed exercise removes actions, never content. Confirm keeps the last position after the or divider, so KS4 and 16-19 both render exactly as they do today.

The deadline sentence now reads EndDateFor(PupilData) rather than the outer window's end date, which on a multi-exercise window is months later and promised slack the school does not have, and it turns past tense from IsOpen(PupilData). Checking-window dates are UK wall-clock values, so it is formatted as it stands and not routed through LondonTime.

One addition the ticket did not ask for. #313 backfilled PupilData and nothing else, so the moment the options follow the exercises, every deployed 16-19 window would have lost "Report an issue with an exam result" — a shipped feature silently withdrawn, since only the local dev seed creates a ResultsEnquiry exercise. BackfillResultsEnquiryExercise gives each Post16 window that exercise on the window's own dates, reproducing today's behaviour exactly. It is NOT EXISTS-guarded, so a window already configured with real enquiry dates keeps them, and its Down removes only rows still matching the shape it wrote. Deliberately transitional: #319's admin sets the real per-exercise dates.

#316 — Each checking exercise owns a prefix inside the existing {windowId} container, and an ingress run belongs to an exercise rather than to the window. The failure this closes is the clear sweep: it deleted everything under data/ and every {windowId}_summary_ blob, so a second exercise's run destroyed the first's output and forced a re-upload of data that was never at fault.

Application/WindowManagement/CheckingExerciseBlobPaths.cs is now the only description of the layout. Pupil data keeps the bare prefix and results enquiry keeps results-enquiry/, so this needed no blob migration — blob prefixes match as plain strings, so a data/ sweep never reaches results-enquiry/data/, and every blob already written is still found where it was. The lookup is a kebab-case slug rather than the enum's ToString() (which would emit ResultsEnquiry/ and orphan every results blob), and it has no default case, so a new exercise type throws instead of silently sharing another's prefix.

The ticket named two sweep prefixes. There was a third leak it did not: the error log was named on the window alone, so a second exercise's run would have overwritten it. Summaries and the error log are both exercise-scoped now, with pupil data's staying exactly where they already are.

ICsvSchemaFileProcessor.ProcessAsync takes the exercise — it selects the write prefix and scopes the sweep. So does every path-naming method on IPupilDataBlobClient, ListSchoolLaestabsAsync included, which is what feeds the dashboard's eligible-schools figure; that now counts the pupil-data prefix explicitly and is unchanged for an already-ingested window. IStudentResultsClient keeps its parameterless surface, its exercise implied by the class, but ResultsEnquiryBlobPaths derives from the shared helper rather than describing the layout twice.

ValidateWindowController still makes one processor pass, now scoped: it resolves the exercise that owns the datasets, in sort order, and passes it through. WindowService only ever attaches datasets to the pupil-data exercise today, so this is exactly current behaviour. Looping it raises questions #316 does not answer — multiple terminal progress events, when the Validated stamp is written, what the no-JS fallback summarises — and #319 owns those, which is why #316's file list omits this controller.

#315ICheckingExerciseService is the only place in the solution that compares a checking exercise's dates against the clock. IsOpen(exercises, type), OpenCheckingExercises(exercises) in SortOrder, and EndDateFor(exercises, type). Time comes from an injected TimeProvider (GetLocalNow(), as LandingPageService already reads it) and is never accepted from a caller, so no caller can supply its own clock. No branch in the service depends on CheckingWindowType.

It takes the exercise rows rather than a window DTO because two unrelated classes are named CheckingWindowDto, and the WindowManagement one already carries its own IsOpen property that a DTO parameter would read as contradicting.

Fails closed: no row for a type, or an empty list, is closed for that type. That is an actions-only rule — an empty OpenCheckingExercises must never remove a card or hide content, and #317/#318 build on that as they land.

Both remaining window read paths now carry the list: LandingPageRepository.GetOpenWindowsAsync and CheckYourPupilDataRepository.GetCheckingWindowAsync project onto CheckingWindowDto.Exercises; WindowRepository already did from #314. The property is Exercises, not CheckingExercises as the ticket text has it, because #314 had already named that list on the WindowManagement DTO and one list should not carry two names. Persistence imports the shared CheckingExerciseDto through a using alias, since importing the whole WindowManagement namespace makes CheckingWindowDto ambiguous there.

One acceptance criterion is met but not guarded by a test: nothing outside the service compares an exercise date. A source scan cannot tell an exercise-date comparison from the window-date comparison LandingPageRepository legitimately does, so the check was done by inspection rather than pinned by a brittle test.

#314CheckingWindowDataset.CheckingExerciseId replaces the window foreign key, and the unique index moves from (CheckingWindowId, Name) to (CheckingExerciseId, Name), so two exercises of one window may each hold a dataset of the same name. CheckingWindow.Datasets is gone; CheckingExercise.Datasets holds them, any number including none. CheckingWindowId survives as a plain column, marked legacy and still written but read by nothing, so the release can be rolled back — a follow-up ticket drops it.

Migration 20260819164322_ReparentDatasetsOntoCheckingExercise adds the column, backfills, then adds the index and foreign key, in that order because the key cannot land before the rows have a parent. It does one thing the ticket did not specify: a window created between the #313 and #314 deploys has dataset rows but no exercise, because the previous release wrote datasets straight to the window, and the foreign key would reject those rows. So the migration re-runs #313's idempotent exercise backfill first, then repoints, then raises a clear error if anything is still unparented rather than letting it surface as an opaque constraint violation.

On the read side, CheckingWindowDto.Exercises (holding a new CheckingExerciseDto) replaces CheckingWindowDto.Datasets, with AllDatasets and FindDataset(name) as the derived paths. WindowService syncs the window type's dataset slots onto the pupil-data exercise, creating that exercise on the window's own dates when a window has none; WindowRepository syncs exercises by type and datasets by name within each, so ids and already-uploaded files survive. The four admin readers — SchemaController, IngressFileController, SummaryController, ValidateWindowController — go through the exercise path.

AllDatasets is deliberately transitional. The ingest still runs a window's files in one pass and the summary page still lists them as one table; #316 and #319 make both per-exercise and should retire the flattening. #316 moved the ingest off it — ValidateWindowController now reads the ingesting exercise's own datasets. Its last caller is the summary page's one flat table, which #319 retires.

#313CheckingExercise entity and CheckingExerciseType enum, wired into PortalDbContext. Migration 20260819144921_AddCheckingExercises backfills a single PupilData exercise on the window's own dates for every existing window, so single-exercise window types behave exactly as before. SeedCheckingWindows seeds one row for KS2 / KS4 June and several for 16-19 and KS4 Autumn.

Also on this branch, from the results-enquiry side: the requireResults search restriction on PupilSearch pages, applied before the ten-suggestion cap; IStudentResultsClient.GetStudentIdsWithResultsAsync with its blob client and seeding; a separate ResultSearch.cshtml view whose no-match text reads "No students found with results", so a school can tell a typo from a student who holds nothing. Docs refreshed: docs/16-19-window-model.md, docs/results-enquiry.md, docs/request-journey.md.

Verification

Unit 4207 passed (7 new for #320 covering the per-exercise deadline rows, their SortOrder, the open/closed wording, a window with no exercises, and the confirmation banner's pupil-data-only date; 39 for #319 across the wizard, the draft's step order and derived dates, the stamp's staleness rule and the window-type defaults, replacing the deleted start/end date controller tests; 23 for #318; 21 for #317; 11 for #316; 13 for #315), integration 705 passed (5 new for #319 proving the exercise stamp round-trips, goes stale when a file is replaced, and that an exercise's dates are now editable at all — none of which the schema previously allowed). E2E 149 passed, 4 skipped, 0 failed against a rebuilt container, including two new tests that walk the wizard: one building a 16-19 window whose two exercises run on different ranges, one confirming a KS4 window still takes a single set of dates.

Done when

  • A multi-exercise window shows one card for the whole outer range.
  • Each exercise's actions appear only while that exercise is open.
  • When an exercise closes, its content is still readable and downloadable.
  • Every single-exercise window type behaves exactly as it does today.

Design note: docs/16-19-window-model.md

@davidgouge davidgouge linked an issue Aug 19, 2026 that may be closed by this pull request
@davidgouge davidgouge changed the title Start of CheckingWindow / CheckingExercise refactor (#313) CheckingWindow / CheckingExercise refactor (#313) Aug 19, 2026
@davidgouge davidgouge changed the title CheckingWindow / CheckingExercise refactor (#313) Checking Window / Checking Exercise refactor AB#297801 Aug 19, 2026
@davidgouge
davidgouge marked this pull request as ready for review August 20, 2026 14:59
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review app for PR 323 was deleted

@davidgouge
davidgouge merged commit 1d3d3b5 into main Aug 20, 2026
8 checks passed
@davidgouge
davidgouge deleted the 307-checking-window-exercise-refactor branch August 20, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checking Window / Checking Exercise refactor AB#297801

2 participants