diff --git a/docs/16-19-window-model.md b/docs/16-19-window-model.md index b5f63547c..4f86a1cdc 100644 --- a/docs/16-19-window-model.md +++ b/docs/16-19-window-model.md @@ -1,56 +1,65 @@ -# 16-19 window: two checking exercises inside one window +# Checking exercises: many activities inside one checking window -*Design note by Dave Gouge, 2026-08-13. Terminology updated 2026-08-18: the window's child items -are now called **CheckingExercises** (not activities), per the lead developer's review comment on -the AB#296648 PR.* +*Design note — Dave Gouge, 2026-08-19.* ## Context -The 16-19 checking window is really two windows in one: +A checking window currently means one thing happening over one date range. 16-19 breaks that +assumption: it runs two activities, on two different ranges, behind one card. | | Runs | What the user can do | |---|---|---| -| Outer — 16-19 Results Enquiry | 7 Oct to March | Results enquiry journeys | +| Outer — 16-19 Results Enquiry | 7 Oct to 31 Mar | Results enquiry journeys | | Inner — Pupil data checking | 7 Oct to 18 Oct | Pupil data change journeys | -The user sees **one** "16-19 Data" card on the landing page. Inside it, results enquiry is always -available. Pupil data journeys stop when the inner window closes on 18 Oct. +The user sees **one** "16-19 Data" card on the landing page. Inside it, results enquiry stays +available all the way to March. Pupil data journeys stop when the inner window closes on 18 Oct. -KS4 Autumn repeats the same pattern: pupil data checking plus KS4 results enquiry. +KS4 Autumn is the same shape: pupil data checking plus a results enquiry. -The code cannot express this today: +This note calls each of those activities a **checking exercise**, and describes how a window comes +to hold several of them. It is a design note. Implementation is ticketed separately in +`16-19-window-model-tickets.md`. + +### What the code does today + +The results-enquiry journey exists and works. What does not exist is any notion that it and pupil +data checking are separate activities with separate dates. - `CheckingWindow` (`Persistence/Entities/CheckingWindow.cs:9-11`) has exactly one - `StartDate`/`EndDate` pair. -- `LandingPageRepository.GetOpenWindowsAsync` brackets `now` against that single pair, so a window - is either fully open or fully closed. -- There is **no results-enquiry concept anywhere in the repo**. A repo-wide grep for - "results enquiry" / `ResultsEnquiry` returns nothing. -- `WhatToChange` (`Application/CheckYourPupilData/WhatToChange.cs`) is a flat enum - (`Merge`, `Include`, `Remove`, `Add`) and the options are hardcoded in - `Views/WhatToChange/Index.cshtml`. -- Journey configs key off `{WhatToChange}_{CheckingWindowType}.json` - (`IQuestionFlowService.GetConfigAsync`), so every journey belongs to a change type, not to a - checking exercise. - -**Deliverable: a design note only.** No code changes in this piece of work. The note describes the -two-level window model and how KS4 Autumn reuses it. Implementation is planned separately. - -Agreed decisions (from this session): - -1. After 18 Oct the Check your pupil data page stays **read-only**. Pupil list, search, CSV and ZIP - downloads all keep working. Only the change journeys disappear. -2. The card still lands the user on **Check your pupil data**. That page offers the two checking - exercises. + `StartDate`/`EndDate` pair, and one `Validated` stamp for the window as a whole. +- `LandingPageRepository:45` brackets `now` against that single pair, so a window is either fully + open or fully closed. +- A journey is identified by `WhatToChange` plus `CheckingWindowType`, and its config blob is + `{WhatToChange}_{CheckingWindowType}.json`. The enquiry journey is `IncorrectGrade_Post16.json`. +- `WhatToChangeCheckingExerciseMap` already maps each `WhatToChange` member to the exercise it + belongs to, but returns `const string` values because there is no exercise type to return. +- Whether the enquiry option appears is a **window-type test**: `CheckYourPupilDataController:202` + returns `windowType == CheckingWindowType.Post16`, surfaced as `ShowResultsEnquiryOption` and + re-checked on the POST at `:103-104`. Both sites are marked `PARKED` against this note. + +Two consequences follow from that last point, and they are the reason this work exists: + +1. **The enquiry option shows for the whole outer range, and so do the pupil-data options.** Nothing + can express "pupil data closes on 18 Oct while results enquiry runs to March". +2. **KS4 Autumn gets no enquiry option at all**, because the test names `Post16` specifically. + +### Agreed decisions + +1. When a checking exercise closes, its **actions** go and its **content stays**. For pupil data + that means the list, the search, the CSV download and the ZIP download all keep working after 18 + Oct. Only the change journeys disappear. +2. The card still lands the user on **Check your pupil data**. That page offers whichever exercises + are open. 3. The model is a **checking-exercise child collection** on `CheckingWindow`. --- ## The model -`CheckingWindow` keeps its outer `StartDate`/`EndDate`. It gains a `CheckingExercises` child -collection. The existing `Datasets` collection **moves down onto the checking exercise**: a dataset -belongs to the exercise that consumes it, not to the window. +`CheckingWindow` keeps its outer `StartDate`/`EndDate` and gains a `CheckingExercises` child +collection. The existing `Datasets` collection **moves down onto the exercise**: a dataset belongs +to the exercise that consumes it, not to the window. ``` CheckingWindow @@ -60,29 +69,36 @@ CheckingWindow { PupilData, 07 Oct - 18 Oct, Datasets [ included, nonincluded ] }, { ResultsEnquiry, 07 Oct - 31 Mar, - Datasets [ ... ] } + Datasets [ the six results files ] } ] ``` -- KS4 June and KS2 get **one** exercise row, `PupilData`, with the same dates as the window. Their - behaviour does not change. -- 16-19 and KS4 Autumn get **two** rows. -- The outer pair is the **only** thing that decides whether the window shows a card. It must equal - the union of the exercise dates. -- A window that is open but has **no** checking exercise open still shows its card. The user goes - into a read-only page: pupil list, search, CSV and ZIP downloads, and no action options at all. - Exercise state controls actions, never visibility. - -Why this shape and not the alternatives: - -- A second date pair on `CheckingWindow` (`PupilDataEndDate`) hardcodes "exactly two phases, one of - which is pupil data". A third checking exercise later needs another migration. -- Two linked `CheckingWindow` rows (`ParentWindowId`) would reuse the window machinery per row, but - every `windowId`-keyed thing would then have to pick the right row: the session `RequestState`, - the blob container named by `windowId`, `ChangeRequest`, and the pupil data blobs. That is a large - blast radius for no gain. -- The `Datasets` collection already set the precedent for child rows on `CheckingWindow`. This - follows it, and then reparents `Datasets` onto the checking exercise — see below. +The collection is sized by data, not by code: + +- A window type with one activity gets one row. KS2 and KS4 June get a single `PupilData` exercise + on the window's own dates, and nothing about them changes. +- A window type with several gets several rows. 16-19 and KS4 Autumn get two. A third exercise later + is a row, not a migration. + +Three rules hold the model together: + +- The outer pair is the **only** thing that decides whether a window shows a card. It must equal the + union of its exercise dates. +- A window that is open but has **no** exercise open still shows its card, and still shows its + content. Exercise state controls actions, never visibility. +- Nothing outside one Application service compares an exercise's dates to the clock. + +### Why this shape + +- **A second date pair on `CheckingWindow`** (`PupilDataEndDate`) hardcodes "exactly two phases, one + of which is pupil data". A third exercise needs another migration, and KS4 Autumn's shape has to + be inferred from which columns are null. +- **Two linked `CheckingWindow` rows** (`ParentWindowId`) would reuse the window machinery per row, + but every `windowId`-keyed thing would then have to pick the right row: the session + `RequestState`, the blob container named by `windowId`, `ChangeRequest`, and the pupil blobs. + Large blast radius, no gain. +- **A child collection** follows the precedent `Datasets` already set on this entity, and reparents + `Datasets` onto the exercise on the way past. ### New types @@ -105,88 +121,127 @@ public sealed class CheckingExercise public DateTime StartDate { get; init; } public DateTime EndDate { get; init; } public int SortOrder { get; init; } + public List Datasets { get; init; } = []; } ``` -Mirror `CheckingWindowDatasetConfiguration` for the `IEntityTypeConfiguration`. Store the enum as a -string, as `CheckingWindow` does for `KeyStage` and `CheckingWindowType`. - -### Datasets belong to the checking exercise - -`CheckingWindowDataset.CheckingWindowId` becomes `CheckingExerciseId`. `CheckingWindow` no longer -holds `Datasets` directly; it reaches them through `CheckingExercises`. - -This is the right home for them. A dataset is an input to one checking exercise. The two 16-19 -pupil CSVs feed pupil data checking. Results enquiry will have its own inputs, on its own dates, -validated against its own schemas. Hanging both off the window would put unrelated files in one -flat list with nothing to say which exercise each serves. - -Three consequences fall out of the move: - -1. **Ingress runs per checking exercise, not per window.** `16-19-pupils-plan.md` requires the two - 16-19 pupil CSVs to be ingested in a **single run**, because a second run would wipe the first - run's output. That constraint now applies **within a checking exercise**. Two exercises are two - independent runs, which is what you want — results-enquiry data must not have to be re-uploaded - to correct a pupil file. - -2. **The blob layout must become exercise-scoped.** `CsvSchemaFileProcessor` writes - `data/{schoolId}_pupils.json` (`:300`) and its clear sweep deletes the whole `data/` prefix - (`:411`). If two checking exercises ingest into the same `{windowId}` container, the second run - destroys the first exercise's output. Give each exercise its own prefix — `{windowId}` container, - `{exercise}/data/{laestab}.json` — and scope the sweep to that prefix. Pupil data keeps its - current path shape under the new prefix. - -3. **`HasPupilData` becomes exercise-scoped.** The landing page existence check - (`IPupilDataBlobClient.HasPupilDataAsync(windowId, laestab)`) must ask about the pupil-data - exercise's prefix. Note this is now only about whether the read-only pupil content can render — - it no longer decides whether a card appears. - -Also per-exercise: the window admin wizard's schema and ingress steps, and `WindowValidated` — a -window is not validated as a whole any more, each checking exercise is. +Mirror `CheckingWindowDatasetConfiguration` for the `IEntityTypeConfiguration`, and store the enum +as a string, as `CheckingWindow` already does for `KeyStage` and `CheckingWindowType`. One row per +type per window is a sensible unique index; the number of **types** a window may hold must stay +open. + +### Datasets belong to the exercise + +`CheckingWindowDataset.CheckingWindowId` becomes `CheckingExerciseId`. `CheckingWindow` stops +holding `Datasets` directly and reaches them through `CheckingExercises`. + +A dataset is an input to one activity. The two 16-19 pupil CSVs feed pupil data checking; the six +results files feed results enquiry, on their own dates and against their own schemas. Hanging both +off the window would put unrelated files in one flat list with nothing to say which activity each +one serves — and would leave the admin wizard unable to tell an incomplete pupil-data window from a +complete one that has not had its results files loaded yet. + +Two consequences follow. + +**Ingress runs per exercise, not per window.** The rule that a window type's ingress files must be +validated and written in a **single run** — because a second run's sweep would wipe the first run's +output — still holds, but now *within* an exercise. Two exercises are two independent runs, which is +what you want: results-enquiry data must never have to be re-uploaded to correct a pupil file. + +**Each exercise owns a blob prefix.** `CsvSchemaFileProcessor:300` writes +`data/{schoolId}_pupils.json`, and its clear sweep at `:411` deletes everything under the `data/` +prefix. Two exercises writing into the same `{windowId}` container under the same prefix would mean +the second run destroys the first's output. + +The layout that avoids this is already half in place. Results enquiry reads +`results-enquiry/data/{laestab}_results.json`; pupil data sits at the bare `data/` prefix. Because +blob prefixes match as plain strings, a `data/` sweep does not touch `results-enquiry/data/`, so the +two are already isolated. Formalise it rather than change it: + +| Exercise | Prefix | +|---|---| +| `PupilData` | `data/` | +| `ResultsEnquiry` | `results-enquiry/data/` | + +Keeping pupil data on the bare prefix is deliberate. It costs one legacy-looking row in a lookup and +saves migrating every window's blobs. Derive the prefix from the exercise in one place, and let an +unmapped exercise type throw rather than default — a new exercise silently sharing another's prefix +is the one failure this design exists to prevent. + +That one place is `Application/WindowManagement/CheckingExerciseBlobPaths.cs`. The per-school data +files are not the whole story: a run also writes a timestamped **summary** and an **error log**, and +both were named on the window alone, so an unscoped sweep would still have let one exercise delete +another's summaries. They carry the exercise prefix too — `{windowId}_summary_…` and +`{windowId}_error_log.txt` for pupil data, `results-enquiry/{windowId}_summary_…` and +`results-enquiry/{windowId}_error_log.txt` for results enquiry — so pupil data's stay exactly where +they already are and nothing has to move. + +**Backfill note (#317).** #313's migration gave every existing window a `PupilData` row and nothing +else. Once the page's options follow the exercises, that would have silently withdrawn the +results-enquiry option from every deployed 16-19 window, so `BackfillResultsEnquiryExercise` gives +each `Post16` window a `ResultsEnquiry` exercise on the window's own dates — reproducing exactly the +behaviour it has today. It is transitional and guarded by `NOT EXISTS`, so a window someone has +configured with real enquiry dates keeps them, and #319's admin replaces the placeholder dates. + +`HasPupilData` on the landing page becomes a question about the pupil-data exercise's prefix. Note +that it now only decides whether the read-only pupil content can render. It no longer decides +whether a card appears. + +Per-exercise too: the admin wizard's schema and ingress steps, and the `Validated` stamp. A window +is not validated as a whole any more — each exercise is. ### Migration Add **new** migrations. Never amend a shipped one — `AddCheckingWindowDatasets` shipped on -2026-07-28 and has five migrations after it, so it is live. +2026-07-28 and has migrations after it, so it is live everywhere. -1. Create `CheckingExercises`. Backfill one `PupilData` row per existing window, copying that - window's `StartDate` and `EndDate`. -2. Add `CheckingExerciseId` to `CheckingWindowDatasets` and point every existing row at its - window's backfilled `PupilData` row. Drop `CheckingWindowId` in a **later** migration, once the - readers have moved, so a rollback is safe. This mirrors how the legacy scalar - `IngressFile`/`SchemaFile` columns were left in place for a release. +1. Create `CheckingExercises`, and backfill one `PupilData` row per existing window copying that + window's `StartDate` and `EndDate`. Every window in the database is single-exercise, so the + backfill is uniform. +2. Add `CheckingExerciseId` to `CheckingWindowDatasets` and point every existing row at its window's + backfilled `PupilData` row. Drop `CheckingWindowId` in a **later** migration, once the readers + have moved, so a rollback stays safe — the same treatment the legacy scalar + `IngressFile`/`SchemaFile` columns got. -Existing blobs sit at the old unprefixed paths. Either move them as part of the release or have the -reader fall back to the old path when the prefixed one is absent. Decide before implementation — -this is the one step that is not purely additive. +No blob migration is needed, because pupil data keeps its existing paths. --- -## Where "is this checking exercise open" is answered +## Where "is this exercise open" is answered One rule, in one place, in Application. Nothing else may compare dates. ```csharp // Application/WindowManagement/ICheckingExerciseService.cs -bool IsOpen(CheckingWindowDto window, CheckingExerciseType exercise, DateTime now); -IReadOnlyList OpenCheckingExercises(CheckingWindowDto window, DateTime now); -DateTime? EndDateFor(CheckingWindowDto window, CheckingExerciseType exercise); +bool IsOpen(IReadOnlyList exercises, CheckingExerciseType exercise); +IReadOnlyList OpenCheckingExercises( + IReadOnlyList exercises); +DateTime? EndDateFor(IReadOnlyList exercises, CheckingExerciseType exercise); ``` -Fail closed: a window with **no** checking-exercise row for a type is closed for that type. A -window whose exercise list is empty is closed for everything. That way a half-configured window -cannot open a journey by accident. +These take the exercise rows rather than a window DTO for two reasons. There are two unrelated +classes named `CheckingWindowDto` — `Application/LandingPage/ILandingPageService.cs:23` and +`Application/WindowManagement/IWindowService.cs:18` — so a DTO parameter is ambiguous at the call +site; and the second already carries its own `IsOpen` property (`:33`), which would read as a direct +contradiction of `IsOpen(...)` on this service. -Fail closed applies to **actions only**. `OpenCheckingExercises` returning an empty list must never -remove the card or the pupil data. The read-only content is always available for the whole outer -window. +Time comes from a `TimeProvider` injected into the implementation, as `LandingPageService` already +does (`timeProvider.GetLocalNow()`). Keeping `now` inside is what stops a caller supplying its own +clock; do not call `DateTime.Now`, and do not accept `now` as a parameter. -Time comes from the injected `TimeProvider`, as `LandingPageService` already does -(`timeProvider.GetLocalNow()`). Do not call `DateTime.Now`. +**Fail closed.** A window with no row for a type is closed for that type. A window with an empty +exercise list is closed for everything. A half-configured window must not open a journey by +accident. -The window DTO that reaches the Web layer must carry the checking-exercise list, so the repository -projection in `LandingPageRepository` and the `CheckYourPupilData` window read both need the extra -`.Select`. +**Fail closed applies to actions only.** An empty `OpenCheckingExercises` must never remove the card +or hide content. Read-only content is available for the whole outer window. + +The window DTOs that reach Web must carry the exercise list, so the `LandingPageRepository` +projection and the `CheckYourPupilData` window read both need the extra `.Select`. The property is +`Exercises` on both `CheckingWindowDto` classes — `WindowManagement` named it that when datasets +were reparented onto the exercise, and the two read paths match it rather than introducing a second +name for the same list. Persistence aliases the shared `CheckingExerciseDto` on import, because +importing the whole `WindowManagement` namespace would make `CheckingWindowDto` ambiguous there. --- @@ -194,128 +249,163 @@ projection in `LandingPageRepository` and the `CheckYourPupilData` window read b ### Landing page -No change to the card itself. The window appears while the **outer** pair brackets `now`, whether or -not any checking exercise is open. The card title is the window title ("16-19 Data"). +Nothing. The window appears while the **outer** pair brackets `now`, whether or not any exercise is +open, and the card title stays the window title. -Consider showing the pupil-data deadline on the card as a hint while that exercise is open. Confirm -with content design; it is not required by the model. +Showing the pupil-data deadline on the card as a hint while that exercise is open would be useful, +but it is a content design decision, not something the model requires. ### Check your pupil data (`Views/CheckYourPupilData/Index.cshtml`) -This page already ends with a `NextSteps` radio group, not buttons: +The page ends with a `NextSteps` radio group whose options are decided by window type +(`Index.cshtml:68` onwards, `CheckYourPupilDataController:118-121`): ``` -( ) Request a change to pupil data - or +( ) Request an amendment to pupil data +( ) Report an issue with an exam result <- Post16 only ( ) Confirm pupil data is correct ``` -`CheckYourPupilDataController:110-111` routes `RequestChange` to `WhatToChange` and `Confirm` to -`ConfirmCorrect`. - Four changes: -1. `NextSteps` gains `ResultsEnquiry`, routed to the results-enquiry entry point. -2. The controller builds the visible option list from `OpenCheckingExercises(...)`. `RequestChange` - and `Confirm` both belong to `PupilData` and both disappear together when it closes. - `ResultsEnquiry` appears only while that checking exercise is open. -3. If only one option survives, do not render a one-item radio group. Render a single button - instead — a radio group with one choice is a poor pattern and fails the "select one option" hint. -4. If **no** option survives, render no form at all. The page becomes read-only: the tables, search - and downloads stay, and a short statement says the window is closed for changes. Everything above - the form is unchanged, so this needs no new page and no redirect. - -The deadline sentence at the top of the page currently reads: +1. The controller builds the option list from `OpenCheckingExercises(...)`, mapping each open + exercise to the options that belong to it. `RequestChange` and `Confirm` belong to `PupilData` + and disappear together when it closes; `ResultsEnquiry` appears only while its exercise is open. + This replaces `OffersResultsEnquiry` at `:202` and `ShowResultsEnquiryOption` on the view model, + and fixes KS4 Autumn along the way. +2. The mapping from exercise to options belongs in Application, not in the controller. Adding a + future exercise type should mean adding a mapping entry, not editing branching logic. +3. If only one option survives, render a **single button**, not a one-item radio group. A radio + group with one choice is a poor pattern and contradicts its own "select one option" hint. +4. If **no** option survives, render no form at all. The tables, the search and the downloads stay, + with a short statement that the window is closed for changes. Everything above the form is + unchanged, so this needs no new page and no redirect. + +The deadline sentence at `Index.cshtml:22` reads: > You must request any changes to pupil data before `@Model.WindowEndTime` on `@Model.WindowEndDate` -`WindowEndTime` / `WindowEndDate` come from the **outer** window. For 16-19 that would show March, -which is wrong — the pupil-data deadline is 18 Oct. They must come from -`EndDateFor(window, PupilData)`. After that date the sentence must change to a past-tense statement -that the pupil data window has closed. - -Keep this in London time, per the existing display convention. +Those values come from the **outer** window, so on a 16-19 window the sentence promises March when +the real pupil-data deadline is 18 Oct. They must come from `EndDateFor(..., PupilData)`, and after +that date the sentence becomes a past-tense statement that the pupil data window has closed. Keep it +in London time, per the existing display convention. ### Server-side gating -The radio list is presentation. The gate must also sit on the POST paths, because a user can hold a -bookmarked URL or a stale tab across the 18 Oct boundary: +The option list is presentation. The gate must also sit on the POST paths, because a user can hold a +bookmarked URL or a stale tab across a closing date: -- `CheckYourPupilDataController` next-steps POST — reject a closed checking exercise. -- `WhatToChangeController.Index` and `.Confirm` — both require `PupilData` open. -- `JourneyController` — its existing `IsSessionReady` guard runs on every action - (`JourneyController.cs:39, 69, 93, 237, 454, 539, 573, 610, 630, 693`). Extend that one helper to - also require the journey's checking exercise to be open. One change covers every journey action. +- `CheckYourPupilDataController` next-steps POST — currently rejects a results enquiry on a + non-Post16 window at `:103-104`; becomes a closed-exercise rejection for every option. +- `WhatToChangeController.Index` and `.Confirm` — require `PupilData` open. +- `ResultIssueController.Index` and `.Confirm` — require `ResultsEnquiry` open. +- `JourneyController` — the `IsSessionReady` guard at `:1143` already runs on every action (`:51, + 94, 118, 285, 312, 455, 710, 808, 842, 887, 907, 1037`). Extending that one helper covers every + journey action, for every exercise. - `ConfirmCorrectController` — same gate. -A rejected request should redirect back to Check your pupil data with an explanation, not 404. +A rejected request redirects back to Check your pupil data with an explanation. It must not 404. ---- +The gate needs no new session state: `RequestState.SelectedWhatToChange` plus +`WhatToChangeCheckingExerciseMap` already yields the journey's exercise. Deriving beats storing — a +stored copy can disagree with the journey's own change type. -## How a journey knows its checking exercise +--- -Today a journey is identified by `WhatToChange` plus `CheckingWindowType`, and the config blob is -`{WhatToChange}_{CheckingWindowType}.json`. +## How a journey knows its exercise -Results enquiry journeys are a different **checking exercise**, not a different change type. Two -options, both compatible with the model above. Pick when the results-enquiry flows are specified: +A journey is identified by `WhatToChange` plus `CheckingWindowType`, and +`WhatToChangeCheckingExerciseMap` maps the member to its exercise. Results enquiry did not need a +new axis in the config key; `IncorrectGrade` is a `WhatToChange` member like any other, and its flow +is `IncorrectGrade_Post16.json`. -**A — new `WhatToChange` members, with a checking-exercise attribute.** Add e.g. -`WhatToChange.ResultsEnquiry`. Map each member to its checking exercise in one lookup in -Application. The config naming rule is untouched. Smallest change; works while results enquiry is -one journey. +Keep that. #318 made the map's values `CheckingExerciseType` instead of `const string`, so there is +one spelling of an exercise name in the solution, and #320 moved the map out of +`Application/ResultsEnquiry/` into `Application/WindowManagement/` — it answers a question about +every exercise, so filing it under one of them read as if results enquiry were a special case. The +naming rule for the config key itself needs no change. -**B — the checking exercise becomes part of the config key.** The blob becomes -`{Exercise}_{WhatToChange}_{CheckingWindowType}.json`, with the existing files treated as -`PupilData_*`. Cleaner if results enquiry grows several distinct journeys. +### The trigger to put the exercise in the config key (#320) -Either way `RequestState` should carry the checking exercise so `IsSessionReady` can gate on it, -and so `ChangeRequest` rows and the Amendment Requests grid can tell the two populations apart. +The config key stays `{WhatToChange}_{CheckingWindowType}.json`. The one thing that forces a third +axis is a **name collision**: two exercises both wanting the same `WhatToChange` for the same +`CheckingWindowType` — say a pupil-data `Remove` and a results-enquiry `Remove` on `Post16`. The +key cannot name both files, and `WhatToChangeCheckingExerciseMap` cannot answer which exercise a +`Remove` belongs to, because the answer would depend on the window type. ---- +When that happens: -## Open questions +1. The exercise joins the key: `{Exercise}_{WhatToChange}_{CheckingWindowType}.json`. Every existing + file is renamed to read `PupilData_*` except the results-enquiry ones, which read + `ResultsEnquiry_*`. Blobs are renamed in the `question-flows` container, and + `Web/Data/QuestionFlows/` renamed to match, in the same change — the seeder uploads by filename. +2. `WhatToChangeCheckingExerciseMap` is retired. The exercise is no longer derived from the change + type; it comes from whichever page started the journey and is carried into the key. +3. `IsSessionReady`'s gate then needs the exercise from somewhere else. Storing it on `RequestState` + is the obvious move but reintroduces the disagreement the map exists to prevent, so pass it from + the entry-point controller rather than persisting it. -These do not block the model. Resolve them before implementation. +Until a collision exists, none of that buys anything: the key is shorter, the map is three lines, +and the blobs need no rename. -1. **Drafts across the boundary.** A user saves a pupil-data draft on 17 Oct and returns on 19 Oct. - `AmendmentRequestsController.ResumeDraft` (`:117`, `:172`) would rebuild a journey for a closed - checking exercise. Options: block resume with a clear message, or allow resume but block submit. - Product decision. The gate must be in `IsSessionReady` either way. -2. **Are results enquiries pupil-centric?** If a results enquiry starts by picking a pupil, it can - reuse the `PupilSearch` page type and the pupil blobs. If it starts from a qualification or a - result, it needs a new data source that does not exist yet. This is the largest unknown in the - whole feature. -3. **Amendment Requests grid.** Does it show both checking exercises' requests in one list, or - split them? -4. **Window admin wizard.** The exercise dates need a step. Suggest: for a window type with more - than one checking exercise, the wizard asks for each exercise's dates and derives the outer pair - as their union, so the two can never disagree. -5. **KS4 Autumn dates.** Confirm its inner and outer dates. The model assumes they nest the same - way as 16-19. +`ChangeRequest` needs no exercise column either — `AmendmentType` (`:33`) plus the map derives it. --- -## Plan - -This note lives at `docs/16-19-window-model.md`. Its companion notes `16-19-pupils-plan.md` and -`16-19-reuse-investigation.md` are on the original design branch. - -Reconcile `16-19-pupils-plan.md`. Two of its statements are superseded by this note: - -- Its step 2 puts the dataset collection on `CheckingWindow`. Datasets now hang off the checking - exercise. -- Its step 1 treats "one ingress run per window" as the unit. The unit is now the checking - exercise, and `clearExistingFiles` must be scoped to the exercise's blob prefix rather than all - of `data/`. - -Its "Deliberately out of scope" section also defers 16-19 journey configs. This note is where the -checking-exercise model for those now lives. - -No code changes. No migration. No tests. +## Open questions -## Verification +These do not block the model. -- Read the note back and check every file path and line anchor still resolves. -- Check the note against `16-19-pupils-plan.md` for contradictions, in particular the - `CheckingWindow` shape, since both notes add a child collection to the same entity. +1. **Drafts across the boundary.** A user saves a pupil-data draft on 17 Oct and returns on 19 Oct. + `AmendmentRequestsController.ResumeDraft` (`:117`, `:172`) would rebuild a journey for a closed + exercise. ~~Block the resume with a clear message, or allow the resume and block the submit?~~ + **Decided in #318: block the resume.** `AmendmentRequestsController.Edit` refuses to rebuild a + journey whose exercise has closed, so nobody edits a request that could never be sent. The gate + also sits in `IsSessionReady`, and it holds for every exercise type. +2. **The Amendment Requests grid.** ~~It now holds two populations. One list, or filtered/grouped by + exercise?~~ **Decided in #320: one list, unsplit.** Both populations keep one table, one set of + checkboxes and one bulk submit. Splitting the grid would double the bulk-submit control and the + empty states for a school that in practice holds a handful of requests, and it would make the + common case — a window with one exercise — carry a grouping header that says nothing. + + What was wrong was never the grid; it was the deadline. The page printed the **window's** end + date once, and the window's end is the union of its exercises (#319), so on a 16-19 window it is + the results-enquiry close — months after pupil data shuts. It told a school it still had time to + amend pupil data when that had closed. `AmendmentRequestsResult.Deadlines` now carries one + `ExerciseDeadlineDto` per exercise, in `SortOrder`, each with its own `EndDate` and its own + `IsOpen` from `ICheckingExerciseService`, and the page prints one sentence per exercise — "Submit + your … by …" while open, "The deadline for … passed at …" once closed. The confirmation page + after a bulk submit reads the **pupil-data** exercise's end, because the banner it sits in offers + another amendment and that journey shuts when pupil data shuts; a window with no pupil-data + exercise drops the banner rather than quoting a date from elsewhere. +3. **Results-enquiry ingress.** Split out of #319 into #324. ~~Nothing writes `results-enquiry/data/` + outside `Web/Seeding/SeedStudentResults.cs`, which is development-only.~~ **Done in #324.** The + results-enquiry exercise owns one dataset slot per source file, named by the `ResultsFileTags` + tag it stamps (five for a 16-19 window, four for KS4, none for KS2 — it has no results feed), so + an admin uploads the supplier's files and validates the exercise exactly as for pupil data. A + dataset carries a `SourceFile` tag, the exact analogue of `Included`: `Included` stamps inclusion + by file of origin, `SourceFile` stamps provenance by file of origin, and the run stamps it onto + every record because no supplier CSV carries a `SOURCE` column. The run writes + `CheckingExerciseBlobPaths.DataBlobName(exercise, laestab)` — pupil data strips only the slash + from the laestab, results normalises it, and the choice is made in the lookup rather than left to + whichever name a caller reached for. + + Only the main file is required. The late, revised and retention files land weeks apart and one + may never land, so `CheckingExerciseDto.HasRequiredFiles` asks that every *required* slot is + filled and at least one slot is, and the run reads `DatasetsToIngest` — the complete slots only. + A run rewrites the exercise's whole output, so the exercise is re-run when the next file lands. + Pupil-data slots stay required: each 16-19 pupil file carries a whole population. + + Two things about the input remain assumptions rather than confirmed facts, both flagged on #324: + the results CSVs are read as carrying the output contract's own column names (`CYPMD_ID`, `QAN`, + `QUAL_NAME`, `SYLLABUS`, `SESSION`, `GRADE`), because ingress passes CSV columns through verbatim + against the admin-supplied JSON schema and has never had a renaming step; 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 by name instead of throwing. If a supplier sample shows different headers, + the mapping step is new work — it is not a matter of editing a schema. +4. **Window admin wizard.** ~~The exercise dates need a step.~~ **Done in #319.** The wizard asks + which exercises the window runs, then one date page per ticked exercise, and derives the outer + pair as their union (`CheckingWindowDto.DeriveDatesFromExercises`) so the two can never disagree. + There is no window-level date step left. +5. **KS4 Autumn dates.** Confirm its inner and outer dates. The model assumes they nest the same way + as 16-19. diff --git a/docs/request-journey.md b/docs/request-journey.md index d302f5661..01ceff4e8 100644 --- a/docs/request-journey.md +++ b/docs/request-journey.md @@ -122,12 +122,15 @@ All flow configs begin with one or more `PupilSearch` pages. These are full-page |---|---|---|---| | `pupilFilter` | yes | `"Included"` / `"All"` | `Included` limits results to Pincl codes `[401,403,414,421,431]`; `All` returns every pupil for the school | | `pupilKey` | yes | `"primary"` / `"match"` | Controls which session field is populated (see below) | +| `requireResults` | no | `true` / `false` (default) | Limits the search to students the school holds a 16-19 result for. Independent of `pupilFilter`, which selects by inclusion status. Used by the results enquiry — there is no grade to correct for a student with no result. A page that sets it must say so in its `subheading`, because the restriction hides students silently | | `nextPageId` | no | page id string | Absent → redirect to Summary after selection | | `validationFailure` | no | string | Error shown when no pupil is submitted. Supports `{pupilName}`. Falls back to `"Enter the name of the pupil"` | -**Suggestions endpoint:** `GET /pupils/suggestions?windowId={id}&query={q}&filter=Included|All&excludePupilId={guid}` +**Suggestions endpoint:** `GET /pupils/suggestions?windowId={id}&query={q}&filter=Included|All&excludePupilId={guid}&requireResults=true` Served by `PupilSuggestionsController`. Queries PostgreSQL via `ICheckYourPupilDataService.GetPupilSuggestionsAsync`. The `match` pupil page automatically passes the primary pupil's ID as `excludePupilId` so the same pupil cannot be selected twice. +`requireResults` is sent only when the page config sets it. The service resolves the school's set of CYPMD ids with results (`IStudentResultsClient.GetStudentIdsWithResultsAsync`, served from the same cached results file the enquiry itself reads) and passes it to `SearchPupilsAsync` as an allow-list, which applies it **before** the ten-suggestion cap — filtering afterwards would drop the one student who holds results whenever ten who do not sort ahead of them. It is a search restriction, never a permission: it can reach no pupil outside the signed-in school's own file. With it on, the autocomplete's no-match text becomes "No students found with results", so a school can tell a typo from a student who holds nothing. + **On successful pupil selection (`PupilSearchPost`):** - **`pupilKey: "primary"`** — saves to `SelectedPupil*`, generates the reference number, resets `QuestionAnswers` and `QuestionHistory`. This is the pupil the request is about. diff --git a/docs/results-enquiry.md b/docs/results-enquiry.md index baf0b3e8b..2c6af51f7 100644 --- a/docs/results-enquiry.md +++ b/docs/results-enquiry.md @@ -13,8 +13,8 @@ data), AB#297013. | `check-late-results` | `Content` | Guidance: check your second late results file first. Entered by the controller, not the flow's `firstPageId` — see [Late results guidance](#late-results-guidance). | | `cohort-scope` | `Question` / Radio | Does the incorrect grade affect the whole cohort? Branches the journey. | | `cohort-count` | `Question` / FreeText | How many students (cohort branch only). Validated by the `WholeNumber` format validator. | -| `select-student-cohort` | `PupilSearch` | One student as an example (cohort branch). | -| `select-student-single` | `PupilSearch` | The affected student (single branch). | +| `select-student-cohort` | `PupilSearch` | One student as an example (cohort branch). Lists only students who hold results — see [Only students who hold results](#only-students-who-hold-results). | +| `select-student-single` | `PupilSearch` | The affected student (single branch). Same restriction. | | `select-result` | `ResultSearch` | Which of the student's results is wrong. | | `grade-details` | `ResultDetails` | Shows the chosen result; asks for the revised grade. | | `additional-info` | `Question` / TextArea | Optional comments, 250 characters. | @@ -63,6 +63,14 @@ decides what "the second late results file has landed" means. Container `{windowId}`, blob `results-enquiry/data/{laestab}_results.json`. One merged array per school across all six supplier files, each row stamped with its source tag. +Written by the results-enquiry checking exercise's own ingress run (#324): one dataset slot per +source file, each stamping its `SOURCE` tag onto every record it contributes, all merged into one +file per school in a single run. `SeedStudentResults` still writes the same blob in development, so +a developer needs no supplier files. Only the main file is required to validate the exercise — the +late, revised and retention files are optional slots, because they land weeks apart and one may +never land, and each run rewrites the school's whole file from the slots that are filled. The supplier CSVs must carry a `LAESTAB` column — that is what +splits one file into one blob per school — and a file without one fails the run by name. + The `results-enquiry/` prefix is deliberate: per consequence #2 of `docs/16-19-window-model.md` each checking exercise owns its own blob prefix, so when ingress becomes per-exercise no migration is needed and one exercise's sweep cannot destroy another's output. Pupil-data checking keeps its bare @@ -104,6 +112,40 @@ The checked-in seed holds the three AB#297130 examples plus the dev QANs. The IB scale is derived from the ticket (44 pass: `24B`/`24D` … `45B`/`45D`; 49 fail: `00F`–`45F`, `R`, `U`, `X`) and is what gives the tests their `24F`-vs-`24D` case. +## Only students who hold results + +Both `PupilSearch` pages set `"requireResults": true`. A student with no result has no grade to +correct, so they are not a candidate, and offering them leads only to a dead end. + +How it is wired, layer by layer: + +1. `IStudentResultsClient.GetStudentIdsWithResultsAsync(windowId, laestab)` returns the school's CYPMD + ids, case-insensitively, from the **already cached** results file — an autocomplete keystroke costs + no download. +2. `CheckYourPupilDataService.GetPupilSuggestionsAsync(..., requireResults)` resolves that set only + when asked, and hands it to the repository. Every other journey passes null and searches the whole + roll. +3. `CheckYourPupilDataRepository.SearchPupilsAsync(..., cypmdIdAllowList)` applies it **before** the + ten-suggestion cap. Filtering after the cap would drop the one student who holds results whenever + ten who do not sort ahead of them. + +Persistence never learns what a result is — it receives a set of ids. + +The restriction is a search restriction, never a permission. It only ever narrows a search that is +already scoped to the signed-in school's own file, so a request that forges or omits +`requireResults=true` reaches nothing new. + +**Because it hides students, the pages say so.** The `subheading` ends "You can only search for +students who have results", and the autocomplete's no-match text becomes "No students found with +results" rather than the component's default "No results found" — otherwise a school cannot tell a +typo from a student who holds nothing. Copy on both is FLAGGED for content sign-off. + +`select-result` keeps its own empty state for the cases the restriction cannot cover — back +navigation, a stale session, or a results file that changes mid-journey. Rather than an autocomplete +that can never answer, it states that we hold no results for the student and links back to the +student search. It renders instead of the control and the Continue button, which could only ever +fail validation. + ## Revised-grade rules Server-authoritative, in this order (`JourneyValidationService.ValidateGradeSelect`): @@ -218,13 +260,21 @@ Validation failures flow through the existing `validation_error` event; `GradeSe ## Local development -`SeedStudentResults` writes results for Kingsmead (`860/4070`) in the seeded Post16 window. Three -students, mixed `16to19_MAIN` / `16to19_LR1` tags, one qualification held twice in different sessions, -and **no `16to19_LR2` rows** so the interstitial is on the happy path. +`SeedStudentResults` writes results for Kingsmead (`860/4070`) in the seeded Post16 window: mixed +`16to19_MAIN` / `16to19_LR1` tags, one qualification held twice in different sessions, and **no +`16to19_LR2` rows** so the interstitial is on the happy path. + +Three students (`500001`–`500003`) carry the Figma screens' own qualification fixtures. The CYPMD ids +are the ones `SeedPupilData` actually generates — a result keyed to Figma's own id would belong to no +selectable student and dead-end the journey. E2E drives `500001` by name, so those three rows are +pinned by `SeedStudentResultsTests`. -The qualification fixtures come from the Figma screens, but the CYPMD ids are the ones -`SeedPupilData` actually generates (`500001`–`500003`) — a result keyed to Figma's own id would belong -to no selectable student and dead-end the journey. +The rest is generated across both populations (every third included student, every fifth +non-included), giving roughly a quarter of the school. That is deliberate on both sides: with the +search restricted to students who hold results, three students leave a manual tester unable to +exercise a common-surname search or the ten-suggestion cap, while seeding *everyone* would hide both +the restriction and the empty state behind data that never exercises them. Generated qualifications +come from the seeded grade reference, so the revised-grade picker can always list grades. ``` docker compose --profile web --profile database --profile storage up -d --build @@ -250,7 +300,8 @@ states, dataset reparenting, per-exercise ingress, draft-across-boundary rules. ## Deliberately out of scope The "Review exam results" / Results / Late-results tab pages and CSV/ZIP downloads (entry-point -ticket); the six-file ingestion pipeline (FACT tickets — this feature seeds the blobs it reads); +ticket); the six-file ingestion pipeline itself (FACT tickets — the portal side of it, the admin upload and +ingress run that fill these blobs, is #324); missing-qualification and result-does-not-belong-to-student flows (sibling tickets); drafts (decided against); duplicate-enquiry blocking (the spec allows multiples). diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsResult.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsResult.cs index ccac8d6e1..3bf8d98a5 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsResult.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsResult.cs @@ -1,9 +1,28 @@ +using DfE.CheckPerformanceData.Domain.Enums; + namespace DfE.CheckPerformanceData.Application.AmendmentRequests; public sealed class AmendmentRequestsResult { - public required DateTime WindowEndDate { get; init; } public required string WindowTitle { get; init; } + + /// + /// One deadline per checking exercise the window runs, in sort order (#320). The page used to + /// print the outer window's end date, which on a 16-19 window is the results-enquiry close — + /// months after pupil data checking shuts. Since the grid holds both populations, one date + /// could not be right for both. + /// + public required IReadOnlyList Deadlines { get; init; } public required IReadOnlyList Rows { get; init; } public required IReadOnlyList SubmittedRows { get; init; } } + +/// When one of the window's checking exercises closes, and whether it still has. +public sealed class ExerciseDeadlineDto +{ + public required CheckingExerciseType Exercise { get; init; } + public required DateTime EndDate { get; init; } + + /// False once the deadline has passed, so the page can say so in the past tense. + public required bool IsOpen { get; init; } +} diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs index 125ac9633..07c91e489 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs @@ -1,12 +1,14 @@ using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Application.RequestSubmission; +using DfE.CheckPerformanceData.Application.WindowManagement; namespace DfE.CheckPerformanceData.Application.AmendmentRequests; public sealed class AmendmentRequestsService( ICheckYourPupilDataService checkYourPupilDataService, IRequestRepository requestRepository, + ICheckingExerciseService checkingExercises, ICurrentUserService currentUserService) : IAmendmentRequestsService { public async Task GetAmendmentRequestsAsync(Guid windowId) @@ -18,8 +20,20 @@ public async Task GetAmendmentRequestsAsync(Guid window return new AmendmentRequestsResult { - WindowEndDate = window.EndDate, WindowTitle = window.Title, + // #320: a deadline per exercise the window runs, not the outer window's end date. The + // grid lists both populations, and on a 16-19 window pupil data checking shuts months + // before results enquiry does — one date could only ever be right for one of them. + Deadlines = window.Exercises + .OrderBy(e => e.SortOrder) + .Select(e => new ExerciseDeadlineDto + { + Exercise = e.ExerciseType, + EndDate = e.EndDate, + // The clock lives in one place, so "has this closed" is asked, never computed. + IsOpen = checkingExercises.IsOpen(window.Exercises, e.ExerciseType) + }) + .ToList(), Rows = requests.Select(r => new AmendmentRequestDto { PupilName = PupilNameFormatter.Format(r.PupilFirstname, r.PupilSurname), diff --git a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/CheckYourPupilDataService.cs b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/CheckYourPupilDataService.cs index fb7ac2757..d6bb976f5 100644 --- a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/CheckYourPupilDataService.cs +++ b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/CheckYourPupilDataService.cs @@ -2,12 +2,14 @@ using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Application.Journey; using DfE.CheckPerformanceData.Application.LandingPage; +using DfE.CheckPerformanceData.Application.ResultsEnquiry; namespace DfE.CheckPerformanceData.Application.CheckYourPupilData; public sealed class CheckYourPupilDataService( ICheckYourPupilDataRepository repository, - ICurrentUserService currentUserService) : ICheckYourPupilDataService + ICurrentUserService currentUserService, + IStudentResultsClient studentResultsClient) : ICheckYourPupilDataService { public async Task<(PupilTable Table, int TotalCount)> GetPupilTableAsync(Guid windowId, bool included, string? search, int page, int pageSize) { @@ -30,11 +32,19 @@ public async Task GetPupilCsvAsync(Guid windowId, bool included) public Task GetCheckingWindowAsync(Guid windowId) => repository.GetCheckingWindowAsync(windowId); - public async Task> GetPupilSuggestionsAsync(Guid windowId, string query, PupilFilter filter, Guid? excludeId = null) + public async Task> GetPupilSuggestionsAsync(Guid windowId, string query, PupilFilter filter, Guid? excludeId = null, bool requireResults = false) { var laestab = currentUserService.OrganisationLaestab; var urn = currentUserService.OrganisationUrn; - return await repository.SearchPupilsAsync(windowId, laestab, urn, query, filter, excludeId); + + // A results enquiry names a student whose grade is wrong, so a student with no result is + // not a candidate. The set comes from the same cached school file the enquiry itself reads, + // and is resolved only when asked for — every other journey searches the whole roll. + var withResults = requireResults + ? await studentResultsClient.GetStudentIdsWithResultsAsync(windowId, laestab) + : null; + + return await repository.SearchPupilsAsync(windowId, laestab, urn, query, filter, excludeId, withResults); } public async Task GetPupilAsync(Guid windowId, Guid pupilId) diff --git a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataRepository.cs b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataRepository.cs index 86457b219..a60afab0d 100644 --- a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataRepository.cs +++ b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataRepository.cs @@ -13,6 +13,16 @@ public interface ICheckYourPupilDataRepository Task> GetAllPupilsAsync(Guid windowId, string laestab, bool included); Task GetCheckingWindowAsync(Guid windowId); - Task> SearchPupilsAsync(Guid windowId, string laestab, string urn, string query, PupilFilter filter, Guid? excludeId = null); + + /// + /// Autocomplete suggestions for the pupil search, capped at ten. + /// + /// restricts the search to a set of students, and is how a + /// results enquiry keeps a school from naming a student who holds no result. Null means no + /// restriction (every other journey); an empty set correctly matches nobody. It is applied + /// before the cap, so a student who does hold results is never crowded out by ten who do not. + /// + Task> SearchPupilsAsync(Guid windowId, string laestab, string urn, string query, PupilFilter filter, Guid? excludeId = null, IReadOnlySet? cypmdIdAllowList = null); + Task GetPupilAsync(Guid windowId, string laestab, Guid pupilId); } diff --git a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataService.cs b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataService.cs index 5e1878ca3..b72e0ed6f 100644 --- a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataService.cs +++ b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/ICheckYourPupilDataService.cs @@ -14,8 +14,15 @@ public interface ICheckYourPupilDataService Task GetPupilCsvAsync(Guid windowId, bool included); Task GetCheckingWindowAsync(Guid windowId); + /// + /// Autocomplete suggestions for the pupil search. + /// + /// limits the search to students the school holds a result + /// for — a results enquiry has nothing to correct otherwise. It costs a read of the school's + /// (cached) results file, so it is opt-in rather than the default. + /// Task> GetPupilSuggestionsAsync(Guid windowId, string query, - PupilFilter filter, Guid? excludeId = null); + PupilFilter filter, Guid? excludeId = null, bool requireResults = false); Task GetPupilAsync(Guid windowId, Guid pupilId); } diff --git a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/INextStepsService.cs b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/INextStepsService.cs new file mode 100644 index 000000000..686f3b5e2 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/INextStepsService.cs @@ -0,0 +1,44 @@ +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Application.CheckYourPupilData; + +/// +/// What the check-your-pupil-data page may offer right now. The page offers whatever is open, for +/// any number of exercises, so the options follow the exercise dates rather than the window type +/// (#317). +/// +public interface INextStepsService +{ + /// Next-step options for the exercises open right now, in display order. + IReadOnlyList GetAvailableSteps(IReadOnlyList exercises); +} + +/// +/// +/// The mapping is domain knowledge, so it lives here rather than in the controller. Adding a future +/// exercise type must mean adding a row to , never editing branching +/// logic — and no branch here may look at CheckingWindowType. Which exercises are open is +/// never decided here either: that is 's single job, and it +/// owns the only clock in this path. +/// +public sealed class NextStepsService(ICheckingExerciseService checkingExercises) : INextStepsService +{ + /// + /// One entry per exercise type. RequestChange and Confirm both belong to PupilData, so they + /// appear and disappear together when that exercise opens and closes. + /// + private static readonly Dictionary StepsByExercise = new() + { + [CheckingExerciseType.PupilData] = [NextSteps.RequestChange, NextSteps.Confirm], + [CheckingExerciseType.ResultsEnquiry] = [NextSteps.ResultsEnquiry] + }; + + public IReadOnlyList GetAvailableSteps(IReadOnlyList exercises) => + checkingExercises.OpenCheckingExercises(exercises) + // An exercise with no mapping contributes nothing. Fail closed rather than throw: it + // must not offer a journey with nothing behind it, but nor should one unmapped row take + // the whole page down. NextStepsServiceTests pins that every type that exists is mapped. + .SelectMany(e => StepsByExercise.TryGetValue(e, out var steps) ? steps : []) + .ToList(); +} diff --git a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/IPupilDataBlobClient.cs b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/IPupilDataBlobClient.cs index abaa29544..cf05e1293 100644 --- a/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/IPupilDataBlobClient.cs +++ b/src/DfE.CheckPerformanceData.Application/CheckYourPupilData/IPupilDataBlobClient.cs @@ -4,8 +4,13 @@ namespace DfE.CheckPerformanceData.Application.CheckYourPupilData; /// /// Reads (and, for dev seeding, writes) the per-school pupil JSON held in blob storage at -/// container {windowId}, blob data/{laestab}_pupils.json. The / in a -/// laestab is stripped when forming the blob name. +/// container {windowId}, blob {exercise prefix}data/{laestab}_pupils.json. The +/// / in a laestab is stripped when forming the blob name. +/// +/// Every method that names a blob path takes the checking exercise (#316): two exercises share one +/// window container, and the prefix is what keeps their files apart. Pupil-data checking uses the +/// bare data/ prefix, so its existing blobs did not move. See +/// CheckingExerciseBlobPaths, which is the only description of the layout. /// /// The blob holds a different record shape per window type (KS4 vs Post16), so reads and writes /// take the window type and the caller sees only . The container and @@ -18,18 +23,22 @@ public interface IPupilDataBlobClient /// Returns the school's pupils for a window, or null when the container or blob /// does not exist. Malformed JSON is allowed to throw. /// - Task?> GetPupilsAsync(Guid windowId, string laestab, CheckingWindowType windowType); + Task?> GetPupilsAsync( + Guid windowId, CheckingExerciseType exercise, string laestab, CheckingWindowType windowType); /// Cheap existence check used by the landing page to populate HasPupilData. - Task HasPupilDataAsync(Guid windowId, string laestab); + Task HasPupilDataAsync(Guid windowId, CheckingExerciseType exercise, string laestab); /// - /// The digits-only laestabs of every school with a pupil file in the window's container - /// (one data/{laestab}_pupils.json per school). Empty when the container does not - /// exist. This is the dashboard's definition of "schools eligible to request amendments". + /// The digits-only laestabs of every school with a pupil file under the exercise's prefix in + /// the window's container (one {laestab}_pupils.json per school). Empty when the + /// container does not exist. With PupilData this is the dashboard's definition of + /// "schools eligible to request amendments". /// - Task> ListSchoolLaestabsAsync(Guid windowId, CancellationToken cancellationToken = default); + Task> ListSchoolLaestabsAsync( + Guid windowId, CheckingExerciseType exercise, CancellationToken cancellationToken = default); /// Writes a school's pupil file. Used only by development data seeding. - Task UploadPupilsAsync(Guid windowId, string laestab, List pupils) where T : IPupilRecord; + Task UploadPupilsAsync(Guid windowId, CheckingExerciseType exercise, string laestab, List pupils) + where T : IPupilRecord; } diff --git a/src/DfE.CheckPerformanceData.Application/Dashboard/DashboardService.cs b/src/DfE.CheckPerformanceData.Application/Dashboard/DashboardService.cs index 6f529e5bc..dbaf822bb 100644 --- a/src/DfE.CheckPerformanceData.Application/Dashboard/DashboardService.cs +++ b/src/DfE.CheckPerformanceData.Application/Dashboard/DashboardService.cs @@ -1,5 +1,6 @@ using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -27,7 +28,11 @@ public async Task GetMetricsAsync( private async Task BuildAsync( CheckingWindowDto window, CancellationToken cancellationToken) { - var eligible = (await pupilDataBlobClient.ListSchoolLaestabsAsync(window.Id, cancellationToken)) + // "Schools eligible to request amendments" means schools that hold pupil data, so this + // counts the pupil-data exercise's prefix specifically (#316) — the same blobs it counted + // before the layout was scoped, so the figure is unchanged for an already-ingested window. + var eligible = (await pupilDataBlobClient.ListSchoolLaestabsAsync( + window.Id, CheckingExerciseType.PupilData, cancellationToken)) .ToHashSet(StringComparer.Ordinal); // Window dates are stored without a kind; Npgsql requires Utc for timestamptz params. diff --git a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs index 344c23d9d..8388bfd3f 100644 --- a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs +++ b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs @@ -40,7 +40,13 @@ public static IServiceCollection AddApplicationDependencies(this IServiceCollect services.AddScoped(); services.AddScoped(); services.AddScoped(); + // #315: the single place that compares an exercise's dates against the clock. Nothing else + // in the solution may do that comparison for itself. + services.AddScoped(); services.AddScoped(); + // #317: which next-step options the check-your-pupil-data page may offer, from the open + // exercises. The exercise-to-options map is domain knowledge, so it is not in the controller. + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/src/DfE.CheckPerformanceData.Application/Journey/JourneyPage.cs b/src/DfE.CheckPerformanceData.Application/Journey/JourneyPage.cs index c68d4adf3..563917993 100644 --- a/src/DfE.CheckPerformanceData.Application/Journey/JourneyPage.cs +++ b/src/DfE.CheckPerformanceData.Application/Journey/JourneyPage.cs @@ -24,5 +24,12 @@ public sealed class JourneyPage public string? NextPageId { get; init; } public PupilFilter? PupilFilter { get; init; } public string? PupilKey { get; init; } + + /// + /// PupilSearch pages only: limit the search to students the school holds a result for. Set on + /// a results enquiry, where a student with no result has no grade to correct. Independent of + /// , which selects the population by inclusion status. + /// + public bool RequireResults { get; init; } public string? ValidationFailure { get; init; } } diff --git a/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs b/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs index 8555b5d02..f90de5b05 100644 --- a/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs +++ b/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs @@ -1,4 +1,5 @@ using DfE.CheckPerformanceData.Application.DfESignInApiClient; +using DfE.CheckPerformanceData.Application.WindowManagement; using DfE.CheckPerformanceData.Domain.Enums; namespace DfE.CheckPerformanceData.Application.LandingPage; @@ -29,6 +30,15 @@ public sealed class CheckingWindowDto public required CheckingWindowType CheckingWindowType { get; init; } public bool HasPupilData { get; init; } public required DateTime StartDate { get; init; } + + /// + /// The window's checking exercises, in sort order. Pass this to + /// to ask whether a given exercise is open — the outer + /// StartDate/EndDate above only say whether the window as a whole is running, and a Post16 + /// window runs pupil data checking and results enquiry on different ranges inside it. + /// Only the exercise dates are projected here; the landing page has no use for the datasets. + /// + public List Exercises { get; init; } = []; public string TurnaroundCommitment { get; init; } = string.Empty; } diff --git a/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/IStudentResultsClient.cs b/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/IStudentResultsClient.cs index 7f6b77a6d..c53100392 100644 --- a/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/IStudentResultsClient.cs +++ b/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/IStudentResultsClient.cs @@ -15,6 +15,17 @@ public interface IStudentResultsClient /// Task> GetResultsAsync(Guid windowId, string laestab, string cypmdId, CancellationToken ct = default); + /// + /// The CYPMD ids of every student the school holds a result for. The pupil search restricts + /// itself to this set on a results enquiry, so that a school cannot start an incorrect-grade + /// enquiry for a student who has no grade to correct. Served from the same cached school file + /// as , so an autocomplete keystroke costs no download. + /// + /// The set is case-insensitive, matching how compares ids — + /// otherwise a student could be offered by the search and then be found to hold nothing. + /// + Task> GetStudentIdsWithResultsAsync(Guid windowId, string laestab, CancellationToken ct = default); + /// /// Whether the school holds any result from a given source file. This is how the service works /// out for itself whether a supplier file has landed, rather than being told separately. diff --git a/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/ResultsEnquiryBlobPaths.cs b/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/ResultsEnquiryBlobPaths.cs index b2c33aa44..751a5383b 100644 --- a/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/ResultsEnquiryBlobPaths.cs +++ b/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/ResultsEnquiryBlobPaths.cs @@ -1,4 +1,5 @@ -using DfE.CheckPerformanceData.Application.Dashboard; +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; namespace DfE.CheckPerformanceData.Application.ResultsEnquiry; @@ -9,20 +10,23 @@ namespace DfE.CheckPerformanceData.Application.ResultsEnquiry; /// /// Layout: container {windowId} (the existing per-window container), blob /// results-enquiry/data/{laestab}_results.json — one merged array per school across all six -/// source files. The results-enquiry/ prefix is deliberate: per consequence #2 of -/// docs/16-19-window-model.md each checking exercise owns its own blob prefix, so when ingress -/// becomes per-exercise no blob migration is needed and one exercise's sweep cannot destroy -/// another's output. Pupil-data checking keeps its existing bare data/ prefix. +/// source files. +/// +/// The layout itself is no longer described here. #316 moved it to +/// , which every exercise's reader and writer shares, so the +/// prefix cannot be changed in one place and missed in another. What is left here is the +/// results-enquiry flavour of those paths, plus the grade-reference blob, which lives in the +/// rules-config container and is not part of a window's layout at all. /// public static class ResultsEnquiryBlobPaths { - public const string ResultsPrefix = "results-enquiry/data/"; - public const string ResultsSuffix = "_results.json"; + public static string ResultsPrefix => CheckingExerciseBlobPaths.DataPrefix(CheckingExerciseType.ResultsEnquiry); + public const string ResultsSuffix = CheckingExerciseBlobPaths.ResultsSuffix; /// The grade-reference blob, seeded alongside rules.json in the rules-config container. public const string GradeReferenceBlobName = "grade-reference.json"; /// e.g. "933/4070" -> "results-enquiry/data/9334070_results.json". public static string ResultsBlobName(string laestab) - => $"{ResultsPrefix}{LaestabNormaliser.Normalise(laestab)}{ResultsSuffix}"; + => CheckingExerciseBlobPaths.ResultsBlobName(laestab); } diff --git a/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/WhatToChangeCheckingExerciseMap.cs b/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/WhatToChangeCheckingExerciseMap.cs deleted file mode 100644 index 2e8570c9c..000000000 --- a/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/WhatToChangeCheckingExerciseMap.cs +++ /dev/null @@ -1,21 +0,0 @@ -using DfE.CheckPerformanceData.Application.CheckYourPupilData; - -namespace DfE.CheckPerformanceData.Application.ResultsEnquiry; - -/// -/// Option A of docs/16-19-window-model.md: each member belongs to -/// one checking exercise. The future ICheckingExerciseService gating consumes this; nothing else -/// may hardcode the mapping. String values (not the not-yet-built CheckingExerciseType -/// enum) so this ticket does not depend on the checking-exercise model landing first. -/// -public static class WhatToChangeCheckingExerciseMap -{ - public const string PupilData = "PupilData"; - public const string ResultsEnquiry = "ResultsEnquiry"; - - public static string CheckingExerciseFor(WhatToChange change) => change switch - { - WhatToChange.IncorrectGrade => ResultsEnquiry, - _ => PupilData - }; -} diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/CheckingExerciseBlobPaths.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/CheckingExerciseBlobPaths.cs new file mode 100644 index 000000000..50b6aa404 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/CheckingExerciseBlobPaths.cs @@ -0,0 +1,94 @@ +using DfE.CheckPerformanceData.Application.Dashboard; +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Application.WindowManagement; + +/// +/// The blob layout of a checking window's container, in one place (#316). Every exercise owns its +/// own prefix inside the existing {windowId} container, so one exercise's ingress run — and +/// in particular its clear sweep — can never destroy another's output. One exercise's data must +/// never have to be re-uploaded to correct another's. +/// +/// +/// Two things here are deliberate and must survive a tidy-up: +/// +/// The prefix is a kebab-case slug, never the enum's ToString(). $"{exercise}/" +/// would emit ResultsEnquiry/ and orphan every results blob already written. +/// Pupil data keeps the bare prefix, so its blobs stay exactly where they are and this ticket +/// needs no blob migration. Because blob prefixes match as plain strings, a data/ sweep does +/// not reach results-enquiry/data/ — the two are already isolated. The cost is one +/// legacy-looking row in the lookup, which is cheap next to migrating every window's blobs. Do not +/// move pupil data under a pupil-data/ prefix without budgeting that migration. +/// +/// +public static class CheckingExerciseBlobPaths +{ + /// Everything an exercise writes sits under this prefix. Empty for pupil data. + /// + /// The exercise has no prefix mapping. There is no default case on purpose: a new exercise type + /// must fail loudly rather than silently share another exercise's prefix, which is the failure + /// this whole layout exists to prevent. + /// + public static string ExercisePrefix(CheckingExerciseType exercise) => exercise switch + { + CheckingExerciseType.PupilData => string.Empty, + CheckingExerciseType.ResultsEnquiry => "results-enquiry/", + _ => throw new ArgumentOutOfRangeException(nameof(exercise), exercise, + "This checking exercise has no blob prefix. Add one to CheckingExerciseBlobPaths before " + + "ingesting it — sharing another exercise's prefix would let one run delete the other's data.") + }; + + /// Where the exercise's per-school data files live, e.g. data/. + public static string DataPrefix(CheckingExerciseType exercise) => $"{ExercisePrefix(exercise)}data/"; + + /// The prefix every timestamped run summary for this exercise shares. + public static string SummaryPrefix(CheckingExerciseType exercise, Guid windowId) + => $"{ExercisePrefix(exercise)}{windowId}_summary_"; + + /// The exercise's error log. One per exercise, so two runs cannot overwrite each other. + public static string ErrorLogBlobName(CheckingExerciseType exercise, Guid windowId) + => $"{ExercisePrefix(exercise)}{windowId}_error_log.txt"; + + public const string PupilsSuffix = "_pupils.json"; + + /// e.g. "933/4290" -> "data/9334290_pupils.json". + /// + /// The slash is stripped rather than the laestab being run through + /// : ingress writes the supplier's LAESTAB column through + /// verbatim, and the two differ on any value that is not slash-separated digits. Keeping the + /// weaker rule is what guarantees every pupil blob already written is still found. + /// + public static string PupilsBlobName(CheckingExerciseType exercise, string laestab) + => $"{DataPrefix(exercise)}{laestab.Replace("/", string.Empty)}{PupilsSuffix}"; + + public const string ResultsSuffix = "_results.json"; + + /// e.g. "933/4070" -> "results-enquiry/data/9334070_results.json". + public static string ResultsBlobName(string laestab) + => $"{DataPrefix(CheckingExerciseType.ResultsEnquiry)}{LaestabNormaliser.Normalise(laestab)}{ResultsSuffix}"; + + /// + /// The per-school output file an ingress run writes for this exercise (#324). + /// + /// + /// The two names normalise the laestab differently and the difference is deliberate, so the + /// choice has to be made here rather than left to whichever name a caller reached for. + /// only strips the slash, because it has to keep finding every + /// pupil blob already written from a verbatim supplier LAESTAB; + /// runs , which is what the results reader uses to turn a DfE + /// Sign-in claim into a blob name. A results run that wrote the pupil-data name would produce + /// files the enquiry journey cannot find. + /// + /// + /// The exercise has no output name. No default case, for the same reason as + /// . + /// + public static string DataBlobName(CheckingExerciseType exercise, string laestab) => exercise switch + { + CheckingExerciseType.PupilData => PupilsBlobName(exercise, laestab), + CheckingExerciseType.ResultsEnquiry => ResultsBlobName(laestab), + _ => throw new ArgumentOutOfRangeException(nameof(exercise), exercise, + "This checking exercise has no per-school output blob name. Add one to " + + "CheckingExerciseBlobPaths before ingesting it.") + }; +} diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/ICheckingExerciseService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/ICheckingExerciseService.cs new file mode 100644 index 000000000..33389c9ee --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/ICheckingExerciseService.cs @@ -0,0 +1,66 @@ +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Application.WindowManagement; + +/// +/// The only place in the solution that compares a checking exercise's dates against the clock. +/// Fails closed: an exercise that is absent, or a window with no exercises at all, is closed. +/// Closed means "no actions", never "no content" — read-only content stays available for the whole +/// outer window, so callers must not use an empty to hide a +/// card or a page. See docs/16-19-window-model.md. +/// +/// +/// The list is the parameter rather than a window DTO because two unrelated classes are named +/// CheckingWindowDto (LandingPage and WindowManagement) and the second carries its own IsOpen. +/// Taking the list lets either of them call in without the name being ambiguous at the call site. +/// +public interface ICheckingExerciseService +{ + /// True when the exercise exists on the window and brackets now. + bool IsOpen(IReadOnlyList exercises, CheckingExerciseType exercise); + + /// Every exercise open right now, in SortOrder. Empty is a valid answer. + IReadOnlyList OpenCheckingExercises( + IReadOnlyList exercises); + + /// The exercise's end date, or null when there is no row for that type. + DateTime? EndDateFor( + IReadOnlyList exercises, CheckingExerciseType exercise); +} + +/// +/// +/// Time comes from the injected and is never accepted from a caller — +/// keeping the clock inside is what stops one caller supplying its own and disagreeing with the +/// rest. LandingPageService already reads the clock the same way (GetLocalNow), and the exercise +/// dates are stored as local wall-clock values, so the two comparisons stay in step. +/// +public sealed class CheckingExerciseService(TimeProvider timeProvider) : ICheckingExerciseService +{ + public bool IsOpen(IReadOnlyList exercises, CheckingExerciseType exercise) + { + var now = Now(); + return exercises.Any(e => e.ExerciseType == exercise && Brackets(e, now)); + } + + public IReadOnlyList OpenCheckingExercises( + IReadOnlyList exercises) + { + var now = Now(); + return exercises + .Where(e => Brackets(e, now)) + .OrderBy(e => e.SortOrder) + .Select(e => e.ExerciseType) + .ToList(); + } + + public DateTime? EndDateFor( + IReadOnlyList exercises, CheckingExerciseType exercise) => + exercises.FirstOrDefault(e => e.ExerciseType == exercise)?.EndDate; + + private DateTime Now() => timeProvider.GetLocalNow().DateTime; + + // Inclusive at both ends, matching how the outer window's own dates are compared. + private static bool Brackets(CheckingExerciseDto exercise, DateTime now) => + exercise.StartDate <= now && exercise.EndDate >= now; +} diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs index 436978903..8054ade13 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs @@ -1,3 +1,4 @@ +using DfE.CheckPerformanceData.Application.ResultsEnquiry; using DfE.CheckPerformanceData.Domain.Enums; namespace DfE.CheckPerformanceData.Application.WindowManagement; @@ -5,7 +6,8 @@ namespace DfE.CheckPerformanceData.Application.WindowManagement; public interface IWindowService { Task GetAllDataAsync(CancellationToken cancellationToken); - Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + /// Null when no window has that id — every caller is an admin route keyed on a URL segment. + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); Task UpdateAsync(CheckingWindowDto window, CancellationToken cancellationToken); Task CreateAsync(CheckingWindowDto window, CancellationToken cancellationToken); } @@ -28,18 +30,103 @@ public sealed class CheckingWindowDto public string IngressFileChecksum { get; set; } = string.Empty; public string SchemaFile { get; set; } = string.Empty; public string SchemaFileChecksum { get; set; } = string.Empty; - public bool Validated { get; set; } - public DateTime? ValidatedAt { get; set; } public bool IsOpen { get; set; } public string TurnaroundCommitment { get; set; } = string.Empty; + // #319: Validated / ValidatedAt are gone from here. A window is not validated as a whole — ask + // a CheckingExerciseDto, or fold the answer across Exercises. + /// - /// The CSV + schema pairs ingested for this window, in sort order. A Post16 window has two - /// (included + non-included); every other type has one. The legacy scalar + /// The window's checking exercises, in sort order. A dataset belongs to the exercise that + /// consumes it, so this is the only route to the window's ingress files. The legacy scalar /// IngressFile/SchemaFile properties above are kept for one release for rollback safety and /// mirror the first dataset. /// + public List Exercises { get; set; } = []; + + // #319: AllDatasets is gone. It flattened every exercise's datasets into one list, which was + // only ever right while a single exercise held them all — the admin wizard, the summary page + // and the validate run are all per-exercise now, and each asks the exercise it means. + + /// The exercise of this type, or null when the window does not run it. + public CheckingExerciseDto? FindExercise(CheckingExerciseType exercise) => + Exercises.SingleOrDefault(e => e.ExerciseType == exercise); + + /// + /// The outer pair derived from the exercises: earliest start, latest end. The wizard never asks + /// an admin for the window's own dates, so the two can never disagree. A window with no + /// exercises keeps whatever it has — there is nothing to derive from. + /// + public void DeriveDatesFromExercises() + { + if (Exercises.Count == 0) return; + + StartDate = Exercises.Min(e => e.StartDate); + EndDate = Exercises.Max(e => e.EndDate); + } +} + +public sealed class CheckingExerciseDto +{ + public Guid Id { get; init; } + public required CheckingExerciseType ExerciseType { get; init; } + public required DateTime StartDate { get; set; } + public required DateTime EndDate { get; set; } + public int SortOrder { get; init; } + + /// + /// The CSV + schema pairs this exercise ingests, in sort order. Any number, including none. + /// public List Datasets { get; set; } = []; + + /// When this exercise last validated cleanly. Null = never (#319). + public DateTime? ValidatedAt { get; set; } + + /// + /// The dataset checksums the stamp was taken over. When these no longer match the exercise's + /// current datasets, the stamp describes files that have since been replaced. + /// + public string ValidatedIngressChecksum { get; set; } = string.Empty; + public string ValidatedSchemaChecksum { get; set; } = string.Empty; + + /// + /// Every required dataset has both its files, and at least one file pair is present — so the + /// exercise can be validated. Optional slots may be empty (#324): the results feed's late, + /// revised and retention files arrive weeks apart and one of them may never arrive at all, so + /// waiting for every slot would mean never validating. + /// + public bool HasRequiredFiles => + Datasets.Any(d => d.IsComplete) && Datasets.Where(d => d.Required).All(d => d.IsComplete); + + /// + /// The datasets a run actually reads, in sort order — the complete ones. An empty optional slot + /// is a file that has not arrived, not a file to fail on, and a run rewrites the exercise's + /// whole output, so the same exercise is simply re-run when the next file lands. + /// + public IReadOnlyList DatasetsToIngest => + [.. Datasets.Where(d => d.IsComplete).OrderBy(d => d.SortOrder)]; + + /// + /// Validated, and against the files it currently holds. A stamp taken before an ingress file + /// was swapped is stale, and saying so is the only reason the checksums are stored. + /// + public bool IsValidated => + ValidatedAt is not null + && ValidatedIngressChecksum == CurrentIngressChecksum + && ValidatedSchemaChecksum == CurrentSchemaChecksum; + + /// The dataset ingress checksums as they stand, in dataset order. + public string CurrentIngressChecksum => Combine(Datasets.OrderBy(d => d.SortOrder).Select(d => d.IngressFileChecksum)); + + /// The dataset schema checksums as they stand, in dataset order. + public string CurrentSchemaChecksum => Combine(Datasets.OrderBy(d => d.SortOrder).Select(d => d.SchemaFileChecksum)); + + // Hashed rather than joined: each part is 64 hex characters, and an exercise with six datasets + // (the results-enquiry shape) would overflow the 256-character column on a plain join. + private static string Combine(IEnumerable checksums) => + Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(string.Join("|", checksums)))); } public sealed class CheckingWindowDatasetDto @@ -55,6 +142,20 @@ public sealed class CheckingWindowDatasetDto /// inclusion signal (KS4's P_INCL). public bool? Included { get; init; } + /// + /// Stamped onto every record from this file as its SOURCE, so provenance is decided by file of + /// origin exactly as decides inclusion (#324). A + /// value on a results dataset; null on pupil data, + /// where nothing is stamped. + /// + public string? SourceFile { get; init; } + + /// + /// The exercise cannot be validated until this slot holds both its files. False for a slot the + /// supplier may not deliver at all — every results file after the main one (#324). + /// + public bool Required { get; init; } = true; + public int SortOrder { get; init; } public bool IsComplete => @@ -62,16 +163,34 @@ public sealed class CheckingWindowDatasetDto } /// -/// Which datasets a checking window ingests, decided by its type. Post16 is the only type where -/// the supplier delivers pupils as two files. +/// Which datasets a checking exercise ingests, decided by the window type it sits in and the +/// exercise itself. Pupil data checking takes the supplier's pupil files — two for Post16, because +/// the non-included file has no P_INCL column — and a results enquiry takes one file per source in +/// the six-file results feed, each named by its tag (#324). /// +/// +/// An exercise type with no row here gets no dataset slots rather than a throw: an exercise is +/// allowed to hold no datasets, so an unmapped type is an exercise nothing ingests yet — visible on +/// the summary page as "This exercise has no ingress files to load" — not a silent misfile. That is +/// the opposite of , where a missing row would let one +/// exercise write over another's blobs and so must fail loudly. +/// public static class WindowDatasets { public const string Included = "included"; public const string NonIncluded = "nonincluded"; public const string Pupils = "pupils"; - public static IReadOnlyList DefaultsFor(CheckingWindowType type) => + public static IReadOnlyList DefaultsFor( + CheckingWindowType type, CheckingExerciseType exercise) => + exercise switch + { + CheckingExerciseType.PupilData => PupilDataDefaults(type), + CheckingExerciseType.ResultsEnquiry => ResultsEnquiryDefaults(type), + _ => [] + }; + + private static IReadOnlyList PupilDataDefaults(CheckingWindowType type) => type == CheckingWindowType.Post16 ? [ @@ -79,5 +198,38 @@ public static IReadOnlyList DefaultsFor(CheckingWindow new CheckingWindowDatasetDto { Name = NonIncluded, Included = false, SortOrder = 1 } ] : [ new CheckingWindowDatasetDto { Name = Pupils, Included = null, SortOrder = 0 } ]; -} + // One slot per source file. The slot is named by the tag it stamps, so the admin uploading the + // files sees the supplier's own file names and a dataset can never be given the wrong tag. + // KS2 has no results feed, so a results enquiry on a KS2 window gets no slots at all. + private static IReadOnlyList ResultsEnquiryDefaults(CheckingWindowType type) => + type switch + { + CheckingWindowType.Post16 => Slots( + ResultsFileTags.Post16Main, + ResultsFileTags.Post16LateResults1, + ResultsFileTags.Post16LateResults2, + ResultsFileTags.Post16Revised, + ResultsFileTags.Post16Retention), + CheckingWindowType.KS4June or CheckingWindowType.KS4Autumn => Slots( + ResultsFileTags.Ks4Main, + ResultsFileTags.Ks4LateResults1, + ResultsFileTags.Ks4LateResults2, + ResultsFileTags.Ks4Revised), + _ => [] + }; + + // Only the main file is required. The late, revised and retention files land weeks apart and + // one may never land — an exercise that could not be validated until all of them had arrived + // would leave a school with no results at all in the meantime. + private static IReadOnlyList Slots(params string[] tags) => + [.. tags.Select((tag, index) => new CheckingWindowDatasetDto + { + Name = tag, + SourceFile = tag, + // Inclusion is a pupil-data concept: a result row is not included or non-included. + Included = null, + Required = index == 0, + SortOrder = index + })]; +} diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/WhatToChangeCheckingExerciseMap.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/WhatToChangeCheckingExerciseMap.cs new file mode 100644 index 000000000..33970e975 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WhatToChangeCheckingExerciseMap.cs @@ -0,0 +1,32 @@ +using DfE.CheckPerformanceData.Application.CheckYourPupilData; +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Application.WindowManagement; + +/// +/// Option A of docs/16-19-window-model.md: each member belongs to +/// one checking exercise. gating consumes this; nothing else +/// may hardcode the mapping. +/// +/// +/// +/// #318: this returned the exercise name as a string, because the map landed before +/// existed. It returns the enum now, so a journey's exercise can +/// be handed straight to the gate without a name lookup that could drift from the enum. There are +/// no exercise-name string constants left anywhere in the solution. +/// +/// +/// #320 moved it here from Application/ResultsEnquiry/. It maps every +/// member, not just the enquiry one, so it belongs beside +/// and — everything that +/// answers a question about checking exercises lives in one namespace. +/// +/// +public static class WhatToChangeCheckingExerciseMap +{ + public static CheckingExerciseType CheckingExerciseFor(WhatToChange change) => change switch + { + WhatToChange.IncorrectGrade => CheckingExerciseType.ResultsEnquiry, + _ => CheckingExerciseType.PupilData + }; +} diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowExercises.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowExercises.cs new file mode 100644 index 000000000..295bf08df --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowExercises.cs @@ -0,0 +1,31 @@ +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Application.WindowManagement; + +/// +/// Which checking exercises a window type runs by default (#319). The admin wizard pre-ticks these +/// and the admin may tick or untick any of them, so this is a starting point rather than a rule — +/// which is how KS4 Autumn can be given a results enquiry without a code change, the gap +/// docs/16-19-window-model.md opens with. +/// +/// +/// A new appears in the wizard from the enum alone, with no row +/// here — the wizard lists every member. This table only decides what starts ticked, so an unmapped +/// window type falling back to pupil data checking is a sensible default rather than a silent +/// failure, and needs no throw. +/// +public static class WindowExercises +{ + public static IReadOnlyList DefaultsFor(CheckingWindowType type) => + type switch + { + // 16-19 runs pupil data checking and results enquiry on different ranges inside one + // window — the case the whole checking-exercise model exists for. + CheckingWindowType.Post16 => + [CheckingExerciseType.PupilData, CheckingExerciseType.ResultsEnquiry], + _ => [CheckingExerciseType.PupilData] + }; + + /// Display order, and the SortOrder written to each row. Enum order. + public static int SortOrderFor(CheckingExerciseType exercise) => (int)exercise; +} diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs index 9c6e183e0..320a86345 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs @@ -1,3 +1,5 @@ +using DfE.CheckPerformanceData.Domain.Enums; + namespace DfE.CheckPerformanceData.Application.WindowManagement; public class WindowService(IWindowRepository windowRepository, TimeProvider timeProvider): IWindowService @@ -18,7 +20,7 @@ public class WindowService(IWindowRepository windowRepository, TimeProvider time }; } - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => await windowRepository.GetByIdAsync(id, cancellationToken); // Start and end dates carry the admin-chosen time-of-day (defaulting to 00:00 / 17:00 @@ -26,29 +28,59 @@ public async Task GetByIdAsync(Guid id, CancellationToken can public async Task UpdateAsync(CheckingWindowDto window, CancellationToken cancellationToken) { EnsureDatasetsMatchType(window); + window.DeriveDatesFromExercises(); await windowRepository.UpdateAsync(window, cancellationToken); } public async Task CreateAsync(CheckingWindowDto window, CancellationToken cancellationToken) { EnsureDatasetsMatchType(window); + window.DeriveDatesFromExercises(); return await windowRepository.CreateAsync(window, cancellationToken); } /// - /// A window's dataset set is decided by its type, so changing the type (e.g. KS4June -> Post16) - /// adds or removes dataset slots. Files already uploaded to a slot that survives are kept. + /// A window's dataset set is decided by its type and by which exercises it runs, so changing + /// the type (e.g. KS4June -> Post16) adds or removes dataset slots on every exercise. Files + /// already uploaded to a slot that survives are kept. /// + /// + /// Every exercise is asked, not just pupil data: since #324 the results-enquiry exercise owns + /// one slot per source file in the results feed, which is what gives an admin somewhere to + /// upload them on a deployed environment. + /// + /// A window that runs no pupil-data exercise gets no pupil dataset slots and no exercise + /// invented for it — since #319 the admin chooses the exercises, so a results-enquiry-only + /// window is a thing an admin can legitimately build, and silently adding pupil data checking + /// back would undo their choice. The one exception is a window with no exercises at all: that + /// is a caller which predates the wizard, and it keeps the old shape of one pupil-data exercise + /// across the whole window. + /// private static void EnsureDatasetsMatchType(CheckingWindowDto window) { - List wanted = []; - - foreach (CheckingWindowDatasetDto expected in WindowDatasets.DefaultsFor(window.CheckingWindowType)) + if (window.Exercises.Count == 0) { - CheckingWindowDatasetDto? existing = window.Datasets.SingleOrDefault(d => d.Name == expected.Name); - wanted.Add(existing ?? expected); + window.Exercises.Add(new CheckingExerciseDto + { + ExerciseType = CheckingExerciseType.PupilData, + StartDate = window.StartDate, + EndDate = window.EndDate, + SortOrder = WindowExercises.SortOrderFor(CheckingExerciseType.PupilData) + }); } - window.Datasets = wanted; + foreach (CheckingExerciseDto exercise in window.Exercises) + { + List wanted = []; + + foreach (CheckingWindowDatasetDto expected in + WindowDatasets.DefaultsFor(window.CheckingWindowType, exercise.ExerciseType)) + { + CheckingWindowDatasetDto? existing = exercise.Datasets.SingleOrDefault(d => d.Name == expected.Name); + wanted.Add(existing ?? expected); + } + + exercise.Datasets = wanted; + } } } diff --git a/src/DfE.CheckPerformanceData.Domain/Enums/CheckingExerciseType.cs b/src/DfE.CheckPerformanceData.Domain/Enums/CheckingExerciseType.cs new file mode 100644 index 000000000..cd5e2a9e3 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Domain/Enums/CheckingExerciseType.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace DfE.CheckPerformanceData.Domain.Enums; + +/// +/// The activities a checking window can run. A window type with one activity has one exercise; a +/// window type with several has several, each on its own dates. Adding a member here must never +/// need a schema change — see docs/16-19-window-model.md. +/// +public enum CheckingExerciseType +{ + [Display(Name = "Pupil data checking")] + PupilData, + [Display(Name = "Results enquiry")] + ResultsEnquiry +} diff --git a/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/PupilDataBlobClient.cs b/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/PupilDataBlobClient.cs index ec2e6840b..e989f5759 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/PupilDataBlobClient.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/PupilDataBlobClient.cs @@ -5,6 +5,7 @@ using Azure.Storage.Blobs.Models; using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.Dashboard; +using DfE.CheckPerformanceData.Application.WindowManagement; using DfE.CheckPerformanceData.Domain.Enums; namespace DfE.CheckPerformanceData.Infrastructure.BlobStorage; @@ -30,9 +31,10 @@ public static IReadOnlyList Deserialize(ReadOnlySpan utf8Jso ? JsonSerializer.Deserialize>(utf8Json, JsonOptions) ?? [] : JsonSerializer.Deserialize>(utf8Json, JsonOptions) ?? []; - public async Task?> GetPupilsAsync(Guid windowId, string laestab, CheckingWindowType windowType) + public async Task?> GetPupilsAsync( + Guid windowId, CheckingExerciseType exercise, string laestab, CheckingWindowType windowType) { - var blob = GetBlobClient(windowId, laestab); + var blob = GetBlobClient(windowId, exercise, laestab); if (!await blob.ExistsAsync()) return null; @@ -42,17 +44,18 @@ public static IReadOnlyList Deserialize(ReadOnlySpan utf8Jso return Deserialize(response.Value.Content.ToMemory().Span, windowType); } - public async Task HasPupilDataAsync(Guid windowId, string laestab) - => await GetBlobClient(windowId, laestab).ExistsAsync(); + public async Task HasPupilDataAsync(Guid windowId, CheckingExerciseType exercise, string laestab) + => await GetBlobClient(windowId, exercise, laestab).ExistsAsync(); - public async Task> ListSchoolLaestabsAsync(Guid windowId, CancellationToken cancellationToken = default) + public async Task> ListSchoolLaestabsAsync( + Guid windowId, CheckingExerciseType exercise, CancellationToken cancellationToken = default) { var container = blobServiceClient.GetBlobContainerClient(windowId.ToString()); if (!await container.ExistsAsync(cancellationToken)) return []; - const string prefix = "data/"; - const string suffix = "_pupils.json"; + string prefix = CheckingExerciseBlobPaths.DataPrefix(exercise); + const string suffix = CheckingExerciseBlobPaths.PupilsSuffix; var laestabs = new HashSet(StringComparer.Ordinal); await foreach (var blob in container.GetBlobsAsync(BlobTraits.None, BlobStates.None, prefix, cancellationToken)) { @@ -69,23 +72,22 @@ public async Task> ListSchoolLaestabsAsync(Guid windowId, return laestabs.ToList(); } - public async Task UploadPupilsAsync(Guid windowId, string laestab, List pupils) where T : IPupilRecord + public async Task UploadPupilsAsync( + Guid windowId, CheckingExerciseType exercise, string laestab, List pupils) where T : IPupilRecord { var container = blobServiceClient.GetBlobContainerClient(windowId.ToString()); await container.CreateIfNotExistsAsync(); - var blob = container.GetBlobClient(BlobName(laestab)); + var blob = container.GetBlobClient(CheckingExerciseBlobPaths.PupilsBlobName(exercise, laestab)); // Serialise against the runtime type so each record's own [JsonPropertyName] map is used. var json = JsonSerializer.Serialize>(pupils, JsonOptions); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); await blob.UploadAsync(stream, overwrite: true); } - private BlobClient GetBlobClient(Guid windowId, string laestab) - => blobServiceClient.GetBlobContainerClient(windowId.ToString()).GetBlobClient(BlobName(laestab)); - - // laestab e.g. "933/4290" -> "data/9334290_pupils.json"; the slash is stripped so the - // blob name has a single virtual "data/" folder rather than nesting on the laestab. - private static string BlobName(string laestab) - => $"data/{laestab.Replace("/", string.Empty)}_pupils.json"; + // The layout lives in CheckingExerciseBlobPaths and nowhere else, so a prefix change cannot + // leave the reader and the ingress writer disagreeing about where a school's file is. + private BlobClient GetBlobClient(Guid windowId, CheckingExerciseType exercise, string laestab) + => blobServiceClient.GetBlobContainerClient(windowId.ToString()) + .GetBlobClient(CheckingExerciseBlobPaths.PupilsBlobName(exercise, laestab)); } diff --git a/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/StudentResultsBlobClient.cs b/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/StudentResultsBlobClient.cs index dcdcf4fa6..fbbf4cce8 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/StudentResultsBlobClient.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/BlobStorage/StudentResultsBlobClient.cs @@ -27,6 +27,13 @@ public async Task> GetResultsAsync( return all.Where(r => string.Equals(r.CypmdId, cypmdId, StringComparison.OrdinalIgnoreCase)).ToList(); } + public async Task> GetStudentIdsWithResultsAsync( + Guid windowId, string laestab, CancellationToken ct = default) + { + var all = await GetSchoolResultsAsync(windowId, laestab, ct); + return all.Select(r => r.CypmdId).ToHashSet(StringComparer.OrdinalIgnoreCase); + } + public async Task AnyForSourceAsync( Guid windowId, string laestab, string sourceTag, CancellationToken ct = default) { diff --git a/src/DfE.CheckPerformanceData.Infrastructure/Ingress/CsvSchemaFileProcessor.cs b/src/DfE.CheckPerformanceData.Infrastructure/Ingress/CsvSchemaFileProcessor.cs index ed98d54e9..3e3337383 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/Ingress/CsvSchemaFileProcessor.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/Ingress/CsvSchemaFileProcessor.cs @@ -6,6 +6,8 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using CsvHelper; +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -17,6 +19,7 @@ public class CsvSchemaFileProcessor(ILogger logger, IRea { public async IAsyncEnumerable ProcessAsync( Guid checkingWindowId, + CheckingExerciseType exercise, IReadOnlyList datasets, bool validateOnly = false, bool clearExistingFiles = false, @@ -35,7 +38,9 @@ public async IAsyncEnumerable ProcessAsync( yield break; } - string errorLogBlobName = $"{checkingWindowId}_error_log.txt"; + // Every output path this run touches is scoped to its exercise (#316). Two exercises share + // one container, so an unscoped name would let one run overwrite or delete another's output. + string errorLogBlobName = CheckingExerciseBlobPaths.ErrorLogBlobName(exercise, checkingWindowId); BlobContainerClient container = sourceBlobClient.GetBlobContainerClient(checkingWindowId.ToString()); bool multipleDatasets = datasets.Count > 1; @@ -49,13 +54,14 @@ public async IAsyncEnumerable ProcessAsync( // A fresh, timestamped summary file is written on every real run, so runs never overwrite // each other's summary. - string summaryBlobName = $"{checkingWindowId}_summary_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv"; + string summaryBlobName = + $"{CheckingExerciseBlobPaths.SummaryPrefix(exercise, checkingWindowId)}{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv"; // Wipe output left by a previous run before anything is written. Safe now that a single // run produces every dataset's output. if (clearExistingFiles && !validateOnly) { - await ClearOutputAsync(container, checkingWindowId, errorLogBlobName, cancellationToken); + await ClearOutputAsync(container, checkingWindowId, exercise, errorLogBlobName, cancellationToken); } foreach (IngressDataset dataset in datasets) @@ -123,8 +129,21 @@ public async IAsyncEnumerable ProcessAsync( yield return new ValidationProgress("Counting", $"{records.Count} records found{label}", recordsRead, recordsValidated, 0, totalErrors, false, false); + // Every feed keys its rows to a school by a LAESTAB column, which is what lets one + // supplier file be split into one blob per school. A file without it cannot be split at + // all, so it fails the run by name rather than throwing out of the group-by. + if (records.Count > 0 && !records[0].ContainsKey("LAESTAB")) + { + yield return Failed( + $"Ingress file '{dataset.InputCsvFile}' has no LAESTAB column, so its records " + + "cannot be grouped by school."); + yield break; + } + List>> groupedSchools = records - .GroupBy(r => r["LAESTAB"]?.ToString() ?? "UnknownSchool") + .GroupBy(r => r.TryGetValue("LAESTAB", out object? laestab) + ? laestab?.ToString() ?? "UnknownSchool" + : "UnknownSchool") .ToList(); // Validate every school group up front, collecting all errors rather than stopping at @@ -177,6 +196,17 @@ public async IAsyncEnumerable ProcessAsync( record["INCLUDED"] = included; } + // Provenance by file of origin, the exact analogue of INCLUDED above: the + // results CSVs carry no SOURCE column, so the tag comes from the dataset slot + // the file was uploaded to. Stamped BEFORE validation for the same reason + // (AllowAdditionalProperties is false), and guarded by the schema check so a + // pupil-data schema is untouched. StudentResultRecord.SourceFile, the result + // picker's file column and ILateResultsAvailability all read this. + if (dataset.SourceFile is { Length: > 0 } sourceFile && schema.Properties.ContainsKey("SOURCE")) + { + record["SOURCE"] = sourceFile; + } + if (!record.IsValid(schema, out IList errorMessages)) { schoolErrors.AddRange(errorMessages); @@ -297,7 +327,7 @@ public async IAsyncEnumerable ProcessAsync( { cancellationToken.ThrowIfCancellationRequested(); - string outputBlobName = $"data/{schoolId}_pupils.json"; + string outputBlobName = CheckingExerciseBlobPaths.DataBlobName(exercise, schoolId); try { await WriteAsync(container, outputBlobName, jsonArray.ToString(Formatting.Indented), cancellationToken); @@ -398,7 +428,7 @@ private static string EscapeCsv(string value) return value; } - private async Task ClearOutputAsync(BlobContainerClient container, Guid checkingWindowId, string errorLogBlobName, CancellationToken cancellationToken) + private async Task ClearOutputAsync(BlobContainerClient container, Guid checkingWindowId, CheckingExerciseType exercise, string errorLogBlobName, CancellationToken cancellationToken) { if (!await container.ExistsAsync(cancellationToken)) { @@ -407,8 +437,14 @@ private async Task ClearOutputAsync(BlobContainerClient container, Guid checking List blobNames = new List(); - // Per-school data files and every timestamped summary from previous runs. - foreach (string prefix in new[] { "data/", $"{checkingWindowId}_summary_" }) + // Per-school data files and every timestamped summary from previous runs — both scoped to + // the running exercise. Blob prefixes match as plain strings, so sweeping "data/" does not + // reach "results-enquiry/data/" and vice versa. + foreach (string prefix in new[] + { + CheckingExerciseBlobPaths.DataPrefix(exercise), + CheckingExerciseBlobPaths.SummaryPrefix(exercise, checkingWindowId) + }) { await foreach (BlobItem blob in container.GetBlobsAsync(BlobTraits.None, BlobStates.None, prefix, cancellationToken)) { diff --git a/src/DfE.CheckPerformanceData.Infrastructure/Ingress/ICsvSchemaFileProcessor.cs b/src/DfE.CheckPerformanceData.Infrastructure/Ingress/ICsvSchemaFileProcessor.cs index 9b168aa28..d4e393aa0 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/Ingress/ICsvSchemaFileProcessor.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/Ingress/ICsvSchemaFileProcessor.cs @@ -1,3 +1,5 @@ +using DfE.CheckPerformanceData.Domain.Enums; + namespace DfE.CheckPerformanceData.Infrastructure.Ingress; public interface ICsvSchemaFileProcessor @@ -10,10 +12,20 @@ public interface ICsvSchemaFileProcessor /// All datasets are validated up front and all errors are collected; data files are only /// written when EVERY dataset is valid, so a run either commits all clean data or writes /// nothing. Records from all datasets for the same LAESTAB are merged into that school's - /// single data/{laestab}_pupils.json — a Post16 window's included and non-included - /// populations therefore land in one file, which is why the merge must happen within a - /// single run (a second run's write would overwrite the first's). + /// single data file — a Post16 window's included and non-included populations therefore land + /// in one file, which is why the merge must happen within a single run (a second run's write + /// would overwrite the first's). + /// + /// A run belongs to one checking exercise, not to the window (#316). The exercise selects the + /// blob prefix everything is written under and, crucially, scopes the clear sweep — two + /// exercises share one {windowId} container, so an unscoped sweep would let one + /// exercise's run destroy another's output. /// + /// + /// The checking exercise this run belongs to. Selects the prefix via + /// CheckingExerciseBlobPaths and scopes the sweep. The rule that a window type's ingress + /// files are ingested in a single run still holds, but now within an exercise. + /// /// /// When true, the run validates and reports every error but writes no data files, for callers /// that only want to check a file. @@ -24,6 +36,7 @@ public interface ICsvSchemaFileProcessor /// IAsyncEnumerable ProcessAsync( Guid checkingWindowId, + CheckingExerciseType exercise, IReadOnlyList datasets, bool validateOnly = false, bool clearExistingFiles = false, diff --git a/src/DfE.CheckPerformanceData.Infrastructure/Ingress/IngressDataset.cs b/src/DfE.CheckPerformanceData.Infrastructure/Ingress/IngressDataset.cs index 9bf04b69d..732dd198a 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/Ingress/IngressDataset.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/Ingress/IngressDataset.cs @@ -11,10 +11,16 @@ namespace DfE.CheckPerformanceData.Infrastructure.Ingress; /// inclusion is decided by file of origin. null means the record carries its own /// inclusion signal (KS4's P_INCL) and nothing is stamped. /// +/// +/// Stamps a SOURCE marker on every record from this file, so provenance is decided by file +/// of origin — the exact analogue of . A results file tag on a +/// results-enquiry dataset; null on pupil data, where nothing is stamped. +/// public sealed record IngressDataset( string Name, string InputCsvFile, string InputCsvChecksum, string SchemaFile, string SchemaChecksum, - bool? Included); + bool? Included, + string? SourceFile = null); diff --git a/src/DfE.CheckPerformanceData.Persistence/Contexts/IPortalDbContext.cs b/src/DfE.CheckPerformanceData.Persistence/Contexts/IPortalDbContext.cs index 0727fa47a..300805a5e 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Contexts/IPortalDbContext.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Contexts/IPortalDbContext.cs @@ -14,6 +14,7 @@ public interface IPortalDbContext DbSet AuditEntries { get; } DbSet CheckingWindows { get; } DbSet CheckingWindowDatasets { get; } + DbSet CheckingExercises { get; } DbSet ContentBlocks { get; } DbSet ContentBlockVersions { get; } DbSet RulesConfigVersions { get; } diff --git a/src/DfE.CheckPerformanceData.Persistence/Contexts/PortalDbContext.cs b/src/DfE.CheckPerformanceData.Persistence/Contexts/PortalDbContext.cs index 0ede26201..cf3a6cee6 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Contexts/PortalDbContext.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Contexts/PortalDbContext.cs @@ -15,6 +15,7 @@ public sealed class PortalDbContext( { public DbSet CheckingWindows => Set(); public DbSet CheckingWindowDatasets => Set(); + public DbSet CheckingExercises => Set(); public DbSet ContentBlocks => Set(); public DbSet ContentBlockVersions => Set(); public DbSet RulesConfigVersions => Set(); @@ -40,6 +41,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new CheckingWindowConfiguration()); modelBuilder.ApplyConfiguration(new CheckingWindowDatasetConfiguration()); + modelBuilder.ApplyConfiguration(new CheckingExerciseConfiguration()); modelBuilder.ApplyConfiguration(new ContentBlockConfiguration()); modelBuilder.ApplyConfiguration(new ContentBlockVersionConfiguration()); modelBuilder.ApplyConfiguration(new RulesConfigVersionConfiguration()); diff --git a/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingExercise.cs b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingExercise.cs new file mode 100644 index 000000000..ac74d303f --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingExercise.cs @@ -0,0 +1,104 @@ +using DfE.CheckPerformanceData.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DfE.CheckPerformanceData.Persistence.Entities; + +/// +/// One activity inside a checking window, on its own date range. A window type with a single +/// exercise has one row on the window's own dates; a window type with several has one row each, +/// and the window's outer StartDate/EndDate is their union. +/// +/// +/// Each exercise has its own inputs, on its own dates, validated against its own schemas — so the +/// ingress CSV + schema pairs hang off this entity rather than off the window. +/// +public sealed class CheckingExercise +{ + public Guid Id { get; init; } + public Guid CheckingWindowId { get; set; } + public CheckingExerciseType ExerciseType { get; init; } + + // Settable since #319: the admin wizard captures each exercise's dates, so an existing row has + // to be able to take new ones. Before that nothing could change them once written. + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + + /// Display order in the admin wizard and on any per-exercise list. + public int SortOrder { get; set; } + + /// + /// The CSV + schema pairs this exercise ingests, in sort order. Any number, including none — + /// an exercise whose files have not been loaded yet has an empty collection. + /// + public List Datasets { get; init; } = []; + + /// + /// Set when this exercise's ingress + schema pair last validated cleanly. Null = not yet + /// validated. Moved down from (#319), which no longer carries it: + /// each exercise has its own inputs and its own dates, so a single window-level flag could only + /// ever describe one of them. + /// + public ExerciseValidated? Validated { get; set; } +} + +/// +/// Renamed from WindowValidated (#319). Same shape, new owner. +/// +/// +/// The two checksums are what make the stamp falsifiable rather than decorative: they are taken +/// over the exercise's datasets at the moment the run finished clean, so swapping an ingress file +/// afterwards leaves a stamp that visibly no longer describes the current files. The old +/// window-level stamp was written unconditionally on every create and update, so it recorded +/// nothing at all. +/// +public sealed class ExerciseValidated +{ + public DateTime ValidatedAt { get; init; } + public string IngressValidationChecksum { get; init; } = string.Empty; + public string SchemaValidationChecksum { get; init; } = string.Empty; +} + +public sealed class CheckingExerciseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CheckingExercises"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasDefaultValueSql("gen_random_uuid()"); + + builder.Property(x => x.ExerciseType) + .IsRequired() + .HasConversion() + .HasMaxLength(50); + + builder.Property(x => x.StartDate) + .IsRequired() + .HasColumnType("timestamp without time zone"); + + builder.Property(x => x.EndDate) + .IsRequired() + .HasColumnType("timestamp without time zone"); + + builder.HasOne() + .WithMany(w => w.CheckingExercises) + .HasForeignKey(x => x.CheckingWindowId) + .OnDelete(DeleteBehavior.Cascade); + + // One row per exercise type per window: the lookup #315 does. This caps repeats of a type, + // never how many types a window may hold. + builder.HasIndex(x => new { x.CheckingWindowId, x.ExerciseType }).IsUnique(); + + builder.OwnsOne(x => x.Validated, validated => + { + validated.Property(v => v.ValidatedAt); + validated.Property(v => v.IngressValidationChecksum) + .HasMaxLength(256); + validated.Property(v => v.SchemaValidationChecksum) + .HasMaxLength(256); + }); + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs index 69d30bf41..6560782c7 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs @@ -18,21 +18,19 @@ public sealed class CheckingWindow public string SchemaFile { get; init; } = string.Empty; public string IngressFileChecksum { get; init; } = string.Empty; public string SchemaFileChecksum { get; init; } = string.Empty; - public WindowValidated? Validated { get; set; } + + // #319: the validation stamp moved down to CheckingExercise. A window is no longer validated as + // a whole — each exercise validates its own ingress + schema pair, on its own dates, and a + // window is usable while another exercise is still unvalidated. Anything asking "is this window + // validated" must now say which exercise it means, or fold the answer across all of them. /// - /// The window's ingress datasets. A Post16 window has two (included + non-included); every - /// other type has one. The scalar IngressFile/SchemaFile properties above are legacy and - /// mirror the first dataset — kept for one release so a rollback is safe. + /// The window's checking exercises, in sort order, and the only route to its ingress files — + /// a dataset belongs to the exercise that consumes it. A configured window is meant to have at + /// least one, and the window's own StartDate/EndDate equals the union of these rows — the admin + /// wizard derives the outer pair rather than asking for it (#319), so the two cannot disagree. /// - public List Datasets { get; init; } = []; -} - -public sealed class WindowValidated -{ - public DateTime ValidatedAt { get; init; } - public string IngressValidationChecksum { get; init; } = string.Empty; - public string SchemaValidationChecksum { get; init; } = string.Empty; + public List CheckingExercises { get; init; } = []; } public sealed class CheckingWindowConfiguration : IEntityTypeConfiguration @@ -78,14 +76,5 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.SchemaFileChecksum) .HasMaxLength(256); - - builder.OwnsOne(x => x.Validated, validated => - { - validated.Property(v => v.ValidatedAt); - validated.Property(v => v.IngressValidationChecksum) - .HasMaxLength(256); - validated.Property(v => v.SchemaValidationChecksum) - .HasMaxLength(256); - }); } } \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindowDataset.cs b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindowDataset.cs index 600f1b04f..84bfe7843 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindowDataset.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindowDataset.cs @@ -4,14 +4,24 @@ namespace DfE.CheckPerformanceData.Persistence.Entities; /// -/// One ingress CSV + JSON schema pair for a checking window. A Post16 window has two rows -/// (included + non-included) because LDS supplies 16-19 pupils as two files; every other window -/// type has one. Both files are ingested in a single run and merged into one blob per school. +/// One ingress CSV + JSON schema pair for a checking exercise. The pupil-data exercise of a Post16 +/// window has two rows (included + non-included) because LDS supplies 16-19 pupils as two files; +/// every other type has one. Both files are ingested in a single run and merged into one blob per +/// school. An exercise may hold any number of these rows, including none. /// public sealed class CheckingWindowDataset { public Guid Id { get; init; } + + /// The exercise that consumes this file pair. + public Guid CheckingExerciseId { get; set; } + + /// + /// Legacy. Kept for one release so the release can be rolled back, and dropped by a follow-up + /// migration once every environment has moved. No reader may use it. + /// public Guid CheckingWindowId { get; set; } + public string Name { get; init; } = string.Empty; public string IngressFile { get; set; } = string.Empty; public string IngressFileChecksum { get; set; } = string.Empty; @@ -21,6 +31,18 @@ public sealed class CheckingWindowDataset /// Null = inclusion comes from the record's own P_INCL (KS4). public bool? Included { get; init; } + /// + /// The SOURCE tag stamped on every record from this file (#324), e.g. "16to19_LR1". Null = + /// nothing is stamped, which is every pupil-data dataset. + /// + public string? SourceFile { get; init; } + + /// + /// The exercise cannot be validated until this slot holds both files. False for a slot the + /// supplier may not deliver at all — every results file after the main one (#324). + /// + public bool Required { get; init; } = true; + public int SortOrder { get; init; } } @@ -37,16 +59,20 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasMaxLength(50); + builder.Property(x => x.SourceFile).HasMaxLength(50); + builder.Property(x => x.Required).HasDefaultValue(true); builder.Property(x => x.IngressFile).HasMaxLength(255); builder.Property(x => x.SchemaFile).HasMaxLength(255); builder.Property(x => x.IngressFileChecksum).HasMaxLength(256); builder.Property(x => x.SchemaFileChecksum).HasMaxLength(256); - builder.HasIndex(x => new { x.CheckingWindowId, x.Name }).IsUnique(); + // Names are unique within an exercise, not within a window: two exercises of the same + // window may each hold a dataset called "pupils". + builder.HasIndex(x => new { x.CheckingExerciseId, x.Name }).IsUnique(); - builder.HasOne() - .WithMany(w => w.Datasets) - .HasForeignKey(x => x.CheckingWindowId) + builder.HasOne() + .WithMany(e => e.Datasets) + .HasForeignKey(x => x.CheckingExerciseId) .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819144921_AddCheckingExercises.Designer.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819144921_AddCheckingExercises.Designer.cs new file mode 100644 index 000000000..b15a0ada4 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819144921_AddCheckingExercises.Designer.cs @@ -0,0 +1,1389 @@ +// +using System; +using DfE.CheckPerformanceData.Persistence.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20260819144921_AddCheckingExercises")] + partial class AddCheckingExercises + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangedColumns") + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.QueueMetricEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DecisionStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("decision_status"); + + b.Property("LatencyMs") + .HasColumnType("double precision") + .HasColumnName("latency_ms"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at_utc"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("rules_version"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("stage"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_recorded_at"); + + b.HasIndex("QueueName", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_queue_recorded"); + + b.HasIndex("ReferenceNumber", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_reference"); + + b.ToTable("queue_metrics_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("QueryNormalised") + .HasColumnType("text") + .HasColumnName("query_normalised"); + + b.Property("QueryRaw") + .HasColumnType("text") + .HasColumnName("query_raw"); + + b.Property("ResultsBlocks") + .HasColumnType("integer") + .HasColumnName("results_blocks"); + + b.Property("ResultsPages") + .HasColumnType("integer") + .HasColumnName("results_pages"); + + b.Property("ResultsTotal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasColumnName("results_total") + .HasComputedColumnSql("results_pages + results_blocks", true); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("ZeroResults") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("zero_results") + .HasComputedColumnSql("(results_pages + results_blocks) = 0", true); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_events_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_search_events_occurred_at"); + + b.HasIndex("QueryNormalised") + .HasDatabaseName("ix_search_events_query_normalised"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_events_session_id"); + + b.HasIndex("OccurredAtUtc", "QueryNormalised") + .HasDatabaseName("ix_search_events_occurred_at_query_normalised") + .HasFilter("query_normalised IS NOT NULL"); + + b.HasIndex("OccurredAtUtc", "SessionId") + .HasDatabaseName("ix_search_events_occurred_at_session_id"); + + b.HasIndex("ZeroResults", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_zero_results_occurred_at") + .HasFilter("zero_results = true"); + + b.ToTable("search_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Rank") + .HasColumnType("real") + .HasColumnName("rank"); + + b.Property("ResultKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_key"); + + b.Property("ResultKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_kind"); + + b.Property("SearchEventId") + .HasColumnType("bigint") + .HasColumnName("search_event_id"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_event_results_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SearchEventId") + .HasDatabaseName("ix_search_event_results_search_event_id"); + + b.ToTable("search_event_results", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("ReadAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at_utc"); + + b.Property("ReadByAdminSub") + .HasColumnType("text") + .HasColumnName("read_by_admin_sub"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at_utc"); + + b.Property("WhatGot") + .HasColumnType("text") + .HasColumnName("what_got"); + + b.Property("WhatLookingFor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("what_looking_for"); + + b.HasKey("Id"); + + b.HasIndex("IsRead") + .HasDatabaseName("ix_search_messages_is_read"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_messages_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_messages_session_id"); + + b.HasIndex("SubmittedAtUtc") + .HasDatabaseName("ix_search_messages_submitted_at"); + + b.ToTable("search_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.ShareToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("surface"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_share_tokens_token_hash"); + + b.ToTable("share_tokens", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AdminSectionAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("RoleName", "SectionKey") + .IsUnique(); + + b.ToTable("AdminSectionAccesses"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AppLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.ToTable("AppLogs"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmendmentType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CrmId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedRuleId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OutcomeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilFirstname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilId") + .HasColumnType("uuid"); + + b.Property("PupilSurname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilUpn") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RequestTypeDescription") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Submitted") + .HasColumnType("timestamp without time zone"); + + b.Property("SubmittedByEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("SubmittedByName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WindowId") + .HasColumnType("uuid"); + + b.Property("WithdrawnAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WithdrawnByEmail") + .HasColumnType("text"); + + b.Property("WorkerStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CrmId") + .IsUnique() + .HasFilter("\"CrmId\" IS NOT NULL"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WindowId", "OrganisationUrn"); + + b.ToTable("ChangeRequests"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExerciseType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "ExerciseType") + .IsUnique(); + + b.ToTable("CheckingExercises", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowType") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("KeyStage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("CheckingWindows"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("Included") + .HasColumnType("boolean"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "Name") + .IsUnique(); + + b.ToTable("CheckingWindowDatasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BlockType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenPath") + .HasColumnType("text"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"ValuePlainText\", '')), 'B')", true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValuePlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.HasIndex("ContentId") + .IsUnique(); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentBlockId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentBlockId", "VersionNumber") + .IsUnique(); + + b.ToTable("ContentBlockVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OfficialName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DeadLetterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at_utc"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("reason"); + + b.HasKey("Id"); + + b.HasIndex("DeadLetteredAtUtc") + .HasDatabaseName("ix_queue_dead_letters_dead_lettered_at"); + + b.ToTable("queue_dead_letters", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DevZendeskTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("priority"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_json"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("subject"); + + b.Property("TicketId") + .HasColumnType("bigint") + .HasColumnName("ticket_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("ix_dev_zendesk_outbox_created_at"); + + b.ToTable("dev_zendesk_outbox", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.OrganisationLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Laestab") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LoggedInAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganisationName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LoggedInAtUtc"); + + b.HasIndex("OrganisationUrn", "LoggedInAtUtc"); + + b.ToTable("OrganisationLogins"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("PageName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"Title\", '')), 'B') || setweight(to_tsvector('english', coalesce(\"Subtitle\", '')), 'C')", true); + + b.Property("Segment") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ShowInMenu") + .HasColumnType("boolean"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path") + .IsUnique() + .HasFilter("\"DeletedDate\" IS NULL"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("PageNodes"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BodyPlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("MinorVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("PageNodeId") + .HasColumnType("uuid"); + + b.Property("PublishFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishTo") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"BodyPlainText\", '')), 'D')", true); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("VersionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.HasIndex("PageNodeId", "IsCurrent"); + + b.HasIndex("PageNodeId", "VersionId") + .IsUnique(); + + b.ToTable("PageNodeVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.QueueMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("VisibleAfterUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("visible_after_utc"); + + b.HasKey("Id"); + + b.HasIndex("QueueName", "Status", "VisibleAfterUtc") + .HasDatabaseName("ix_queue_messages_claim"); + + b.ToTable("queue_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.RulesConfigVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ConfigType", "VersionNumber") + .IsUnique(); + + b.ToTable("RulesConfigVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.HasOne("DfE.CheckPerformance.Persistence.Entities.SearchEvent", null) + .WithMany() + .HasForeignKey("SearchEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany() + .HasForeignKey("WindowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("CheckingExercises") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.WindowValidated", "Validated", b1 => + { + b1.Property("CheckingWindowId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("IngressValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("SchemaValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("ValidatedAt") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("CheckingWindowId"); + + b1.ToTable("CheckingWindows"); + + b1.WithOwner() + .HasForeignKey("CheckingWindowId"); + }); + + b.Navigation("Validated"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("Datasets") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", "ContentBlock") + .WithMany("Versions") + .HasForeignKey("ContentBlockId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContentBlock"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PageNode_PageNode_ParentId"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", "PageNode") + .WithMany("Versions") + .HasForeignKey("PageNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PageNode"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("CheckingExercises"); + + b.Navigation("Datasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819144921_AddCheckingExercises.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819144921_AddCheckingExercises.cs new file mode 100644 index 000000000..847507452 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819144921_AddCheckingExercises.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + /// + public partial class AddCheckingExercises : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CheckingExercises", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + CheckingWindowId = table.Column(type: "uuid", nullable: false), + ExerciseType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + StartDate = table.Column(type: "timestamp without time zone", nullable: false), + EndDate = table.Column(type: "timestamp without time zone", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CheckingExercises", x => x.Id); + table.ForeignKey( + name: "FK_CheckingExercises_CheckingWindows_CheckingWindowId", + column: x => x.CheckingWindowId, + principalTable: "CheckingWindows", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CheckingExercises_CheckingWindowId_ExerciseType", + table: "CheckingExercises", + columns: new[] { "CheckingWindowId", "ExerciseType" }, + unique: true); + + // Every window in the database today runs a single pupil-data activity across the + // whole window, so each one becomes one PupilData exercise on its own dates. Nothing + // reads this table yet — the readers arrive in #315 onwards. The NOT EXISTS guard + // makes the backfill idempotent: re-running it skips a window that already has its + // PupilData row, and still gives one to a window that only has other exercise types. + migrationBuilder.Sql(""" + INSERT INTO "CheckingExercises" + ("Id", "CheckingWindowId", "ExerciseType", "StartDate", "EndDate", "SortOrder") + SELECT gen_random_uuid(), w."Id", 'PupilData', w."StartDate", w."EndDate", 0 + FROM "CheckingWindows" w + WHERE NOT EXISTS ( + SELECT 1 FROM "CheckingExercises" e + WHERE e."CheckingWindowId" = w."Id" AND e."ExerciseType" = 'PupilData' + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CheckingExercises"); + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819164322_ReparentDatasetsOntoCheckingExercise.Designer.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819164322_ReparentDatasetsOntoCheckingExercise.Designer.cs new file mode 100644 index 000000000..3cd27d702 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819164322_ReparentDatasetsOntoCheckingExercise.Designer.cs @@ -0,0 +1,1395 @@ +// +using System; +using DfE.CheckPerformanceData.Persistence.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20260819164322_ReparentDatasetsOntoCheckingExercise")] + partial class ReparentDatasetsOntoCheckingExercise + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangedColumns") + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.QueueMetricEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DecisionStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("decision_status"); + + b.Property("LatencyMs") + .HasColumnType("double precision") + .HasColumnName("latency_ms"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at_utc"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("rules_version"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("stage"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_recorded_at"); + + b.HasIndex("QueueName", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_queue_recorded"); + + b.HasIndex("ReferenceNumber", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_reference"); + + b.ToTable("queue_metrics_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("QueryNormalised") + .HasColumnType("text") + .HasColumnName("query_normalised"); + + b.Property("QueryRaw") + .HasColumnType("text") + .HasColumnName("query_raw"); + + b.Property("ResultsBlocks") + .HasColumnType("integer") + .HasColumnName("results_blocks"); + + b.Property("ResultsPages") + .HasColumnType("integer") + .HasColumnName("results_pages"); + + b.Property("ResultsTotal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasColumnName("results_total") + .HasComputedColumnSql("results_pages + results_blocks", true); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("ZeroResults") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("zero_results") + .HasComputedColumnSql("(results_pages + results_blocks) = 0", true); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_events_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_search_events_occurred_at"); + + b.HasIndex("QueryNormalised") + .HasDatabaseName("ix_search_events_query_normalised"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_events_session_id"); + + b.HasIndex("OccurredAtUtc", "QueryNormalised") + .HasDatabaseName("ix_search_events_occurred_at_query_normalised") + .HasFilter("query_normalised IS NOT NULL"); + + b.HasIndex("OccurredAtUtc", "SessionId") + .HasDatabaseName("ix_search_events_occurred_at_session_id"); + + b.HasIndex("ZeroResults", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_zero_results_occurred_at") + .HasFilter("zero_results = true"); + + b.ToTable("search_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Rank") + .HasColumnType("real") + .HasColumnName("rank"); + + b.Property("ResultKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_key"); + + b.Property("ResultKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_kind"); + + b.Property("SearchEventId") + .HasColumnType("bigint") + .HasColumnName("search_event_id"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_event_results_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SearchEventId") + .HasDatabaseName("ix_search_event_results_search_event_id"); + + b.ToTable("search_event_results", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("ReadAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at_utc"); + + b.Property("ReadByAdminSub") + .HasColumnType("text") + .HasColumnName("read_by_admin_sub"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at_utc"); + + b.Property("WhatGot") + .HasColumnType("text") + .HasColumnName("what_got"); + + b.Property("WhatLookingFor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("what_looking_for"); + + b.HasKey("Id"); + + b.HasIndex("IsRead") + .HasDatabaseName("ix_search_messages_is_read"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_messages_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_messages_session_id"); + + b.HasIndex("SubmittedAtUtc") + .HasDatabaseName("ix_search_messages_submitted_at"); + + b.ToTable("search_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.ShareToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("surface"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_share_tokens_token_hash"); + + b.ToTable("share_tokens", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AdminSectionAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("RoleName", "SectionKey") + .IsUnique(); + + b.ToTable("AdminSectionAccesses"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AppLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.ToTable("AppLogs"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmendmentType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CrmId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedRuleId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OutcomeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilFirstname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilId") + .HasColumnType("uuid"); + + b.Property("PupilSurname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilUpn") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RequestTypeDescription") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Submitted") + .HasColumnType("timestamp without time zone"); + + b.Property("SubmittedByEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("SubmittedByName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WindowId") + .HasColumnType("uuid"); + + b.Property("WithdrawnAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WithdrawnByEmail") + .HasColumnType("text"); + + b.Property("WorkerStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CrmId") + .IsUnique() + .HasFilter("\"CrmId\" IS NOT NULL"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WindowId", "OrganisationUrn"); + + b.ToTable("ChangeRequests"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExerciseType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "ExerciseType") + .IsUnique(); + + b.ToTable("CheckingExercises", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowType") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("KeyStage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("CheckingWindows"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingExerciseId") + .HasColumnType("uuid"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("Included") + .HasColumnType("boolean"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckingExerciseId", "Name") + .IsUnique(); + + b.ToTable("CheckingWindowDatasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BlockType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenPath") + .HasColumnType("text"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"ValuePlainText\", '')), 'B')", true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValuePlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.HasIndex("ContentId") + .IsUnique(); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentBlockId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentBlockId", "VersionNumber") + .IsUnique(); + + b.ToTable("ContentBlockVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OfficialName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DeadLetterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at_utc"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("reason"); + + b.HasKey("Id"); + + b.HasIndex("DeadLetteredAtUtc") + .HasDatabaseName("ix_queue_dead_letters_dead_lettered_at"); + + b.ToTable("queue_dead_letters", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DevZendeskTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("priority"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_json"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("subject"); + + b.Property("TicketId") + .HasColumnType("bigint") + .HasColumnName("ticket_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("ix_dev_zendesk_outbox_created_at"); + + b.ToTable("dev_zendesk_outbox", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.OrganisationLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Laestab") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LoggedInAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganisationName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LoggedInAtUtc"); + + b.HasIndex("OrganisationUrn", "LoggedInAtUtc"); + + b.ToTable("OrganisationLogins"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("PageName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"Title\", '')), 'B') || setweight(to_tsvector('english', coalesce(\"Subtitle\", '')), 'C')", true); + + b.Property("Segment") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ShowInMenu") + .HasColumnType("boolean"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path") + .IsUnique() + .HasFilter("\"DeletedDate\" IS NULL"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("PageNodes"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BodyPlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("MinorVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("PageNodeId") + .HasColumnType("uuid"); + + b.Property("PublishFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishTo") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"BodyPlainText\", '')), 'D')", true); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("VersionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.HasIndex("PageNodeId", "IsCurrent"); + + b.HasIndex("PageNodeId", "VersionId") + .IsUnique(); + + b.ToTable("PageNodeVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.QueueMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("VisibleAfterUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("visible_after_utc"); + + b.HasKey("Id"); + + b.HasIndex("QueueName", "Status", "VisibleAfterUtc") + .HasDatabaseName("ix_queue_messages_claim"); + + b.ToTable("queue_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.RulesConfigVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ConfigType", "VersionNumber") + .IsUnique(); + + b.ToTable("RulesConfigVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.HasOne("DfE.CheckPerformance.Persistence.Entities.SearchEvent", null) + .WithMany() + .HasForeignKey("SearchEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany() + .HasForeignKey("WindowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("CheckingExercises") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.WindowValidated", "Validated", b1 => + { + b1.Property("CheckingWindowId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("IngressValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("SchemaValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("ValidatedAt") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("CheckingWindowId"); + + b1.ToTable("CheckingWindows"); + + b1.WithOwner() + .HasForeignKey("CheckingWindowId"); + }); + + b.Navigation("Validated"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", null) + .WithMany("Datasets") + .HasForeignKey("CheckingExerciseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", "ContentBlock") + .WithMany("Versions") + .HasForeignKey("ContentBlockId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContentBlock"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PageNode_PageNode_ParentId"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", "PageNode") + .WithMany("Versions") + .HasForeignKey("PageNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PageNode"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Navigation("Datasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("CheckingExercises"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819164322_ReparentDatasetsOntoCheckingExercise.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819164322_ReparentDatasetsOntoCheckingExercise.cs new file mode 100644 index 000000000..ecc04aaed --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260819164322_ReparentDatasetsOntoCheckingExercise.cs @@ -0,0 +1,128 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + /// + /// A dataset is an input to one exercise, not to the window (#314). Every dataset row in the + /// database today serves its window's single pupil-data activity, so each is pointed at the + /// PupilData exercise the previous migration backfilled for that window. + /// + /// + /// CheckingWindowId is deliberately left in place. The previous release reads datasets through + /// it, so keeping the column and its values is what makes a rollback safe. A follow-up + /// migration drops it once every environment has moved. + /// + public partial class ReparentDatasetsOntoCheckingExercise : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CheckingWindowDatasets_CheckingWindows_CheckingWindowId", + table: "CheckingWindowDatasets"); + + migrationBuilder.DropIndex( + name: "IX_CheckingWindowDatasets_CheckingWindowId_Name", + table: "CheckingWindowDatasets"); + + migrationBuilder.AddColumn( + name: "CheckingExerciseId", + table: "CheckingWindowDatasets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + // A window created between this migration and the one before it has dataset rows but + // no exercise, because the previous release still wrote datasets straight to the + // window. Backfill the same single PupilData exercise that migration did, so those + // rows have a parent. Idempotent: a window that already has one is skipped. + migrationBuilder.Sql(""" + INSERT INTO "CheckingExercises" + ("Id", "CheckingWindowId", "ExerciseType", "StartDate", "EndDate", "SortOrder") + SELECT gen_random_uuid(), w."Id", 'PupilData', w."StartDate", w."EndDate", 0 + FROM "CheckingWindows" w + WHERE NOT EXISTS ( + SELECT 1 FROM "CheckingExercises" e + WHERE e."CheckingWindowId" = w."Id" AND e."ExerciseType" = 'PupilData' + ); + """); + + // Point every existing row at its window's pupil-data exercise. This must run before + // the foreign key below, or the all-zero default would violate it. Restricted to rows + // that have not been repointed already, so re-running it is a no-op. + migrationBuilder.Sql(""" + UPDATE "CheckingWindowDatasets" d + SET "CheckingExerciseId" = e."Id" + FROM "CheckingExercises" e + WHERE e."CheckingWindowId" = d."CheckingWindowId" + AND e."ExerciseType" = 'PupilData' + AND d."CheckingExerciseId" = '00000000-0000-0000-0000-000000000000'; + """); + + // Nothing should be left unpointed after the two statements above. Fail loudly rather + // than let the foreign key below report it as an opaque constraint violation. + migrationBuilder.Sql(""" + DO $$ + DECLARE orphans bigint; + BEGIN + SELECT count(*) INTO orphans FROM "CheckingWindowDatasets" + WHERE "CheckingExerciseId" = '00000000-0000-0000-0000-000000000000'; + + IF orphans > 0 THEN + RAISE EXCEPTION + '% dataset row(s) have no PupilData exercise to hang off.', orphans; + END IF; + END $$; + """); + + migrationBuilder.CreateIndex( + name: "IX_CheckingWindowDatasets_CheckingExerciseId_Name", + table: "CheckingWindowDatasets", + columns: new[] { "CheckingExerciseId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_CheckingWindowDatasets_CheckingExercises_CheckingExerciseId", + table: "CheckingWindowDatasets", + column: "CheckingExerciseId", + principalTable: "CheckingExercises", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CheckingWindowDatasets_CheckingExercises_CheckingExerciseId", + table: "CheckingWindowDatasets"); + + migrationBuilder.DropIndex( + name: "IX_CheckingWindowDatasets_CheckingExerciseId_Name", + table: "CheckingWindowDatasets"); + + // CheckingWindowId was never cleared, so dropping the new column restores exactly the + // shape the previous release reads. + migrationBuilder.DropColumn( + name: "CheckingExerciseId", + table: "CheckingWindowDatasets"); + + migrationBuilder.CreateIndex( + name: "IX_CheckingWindowDatasets_CheckingWindowId_Name", + table: "CheckingWindowDatasets", + columns: new[] { "CheckingWindowId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_CheckingWindowDatasets_CheckingWindows_CheckingWindowId", + table: "CheckingWindowDatasets", + column: "CheckingWindowId", + principalTable: "CheckingWindows", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820081648_BackfillResultsEnquiryExercise.Designer.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820081648_BackfillResultsEnquiryExercise.Designer.cs new file mode 100644 index 000000000..909c4af5f --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820081648_BackfillResultsEnquiryExercise.Designer.cs @@ -0,0 +1,1395 @@ +// +using System; +using DfE.CheckPerformanceData.Persistence.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20260820081648_BackfillResultsEnquiryExercise")] + partial class BackfillResultsEnquiryExercise + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangedColumns") + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.QueueMetricEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DecisionStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("decision_status"); + + b.Property("LatencyMs") + .HasColumnType("double precision") + .HasColumnName("latency_ms"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at_utc"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("rules_version"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("stage"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_recorded_at"); + + b.HasIndex("QueueName", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_queue_recorded"); + + b.HasIndex("ReferenceNumber", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_reference"); + + b.ToTable("queue_metrics_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("QueryNormalised") + .HasColumnType("text") + .HasColumnName("query_normalised"); + + b.Property("QueryRaw") + .HasColumnType("text") + .HasColumnName("query_raw"); + + b.Property("ResultsBlocks") + .HasColumnType("integer") + .HasColumnName("results_blocks"); + + b.Property("ResultsPages") + .HasColumnType("integer") + .HasColumnName("results_pages"); + + b.Property("ResultsTotal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasColumnName("results_total") + .HasComputedColumnSql("results_pages + results_blocks", true); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("ZeroResults") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("zero_results") + .HasComputedColumnSql("(results_pages + results_blocks) = 0", true); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_events_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_search_events_occurred_at"); + + b.HasIndex("QueryNormalised") + .HasDatabaseName("ix_search_events_query_normalised"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_events_session_id"); + + b.HasIndex("OccurredAtUtc", "QueryNormalised") + .HasDatabaseName("ix_search_events_occurred_at_query_normalised") + .HasFilter("query_normalised IS NOT NULL"); + + b.HasIndex("OccurredAtUtc", "SessionId") + .HasDatabaseName("ix_search_events_occurred_at_session_id"); + + b.HasIndex("ZeroResults", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_zero_results_occurred_at") + .HasFilter("zero_results = true"); + + b.ToTable("search_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Rank") + .HasColumnType("real") + .HasColumnName("rank"); + + b.Property("ResultKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_key"); + + b.Property("ResultKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_kind"); + + b.Property("SearchEventId") + .HasColumnType("bigint") + .HasColumnName("search_event_id"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_event_results_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SearchEventId") + .HasDatabaseName("ix_search_event_results_search_event_id"); + + b.ToTable("search_event_results", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("ReadAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at_utc"); + + b.Property("ReadByAdminSub") + .HasColumnType("text") + .HasColumnName("read_by_admin_sub"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at_utc"); + + b.Property("WhatGot") + .HasColumnType("text") + .HasColumnName("what_got"); + + b.Property("WhatLookingFor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("what_looking_for"); + + b.HasKey("Id"); + + b.HasIndex("IsRead") + .HasDatabaseName("ix_search_messages_is_read"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_messages_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_messages_session_id"); + + b.HasIndex("SubmittedAtUtc") + .HasDatabaseName("ix_search_messages_submitted_at"); + + b.ToTable("search_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.ShareToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("surface"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_share_tokens_token_hash"); + + b.ToTable("share_tokens", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AdminSectionAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("RoleName", "SectionKey") + .IsUnique(); + + b.ToTable("AdminSectionAccesses"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AppLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.ToTable("AppLogs"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmendmentType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CrmId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedRuleId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OutcomeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilFirstname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilId") + .HasColumnType("uuid"); + + b.Property("PupilSurname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilUpn") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RequestTypeDescription") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Submitted") + .HasColumnType("timestamp without time zone"); + + b.Property("SubmittedByEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("SubmittedByName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WindowId") + .HasColumnType("uuid"); + + b.Property("WithdrawnAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WithdrawnByEmail") + .HasColumnType("text"); + + b.Property("WorkerStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CrmId") + .IsUnique() + .HasFilter("\"CrmId\" IS NOT NULL"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WindowId", "OrganisationUrn"); + + b.ToTable("ChangeRequests"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExerciseType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "ExerciseType") + .IsUnique(); + + b.ToTable("CheckingExercises", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowType") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("KeyStage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("CheckingWindows"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingExerciseId") + .HasColumnType("uuid"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("Included") + .HasColumnType("boolean"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckingExerciseId", "Name") + .IsUnique(); + + b.ToTable("CheckingWindowDatasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BlockType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenPath") + .HasColumnType("text"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"ValuePlainText\", '')), 'B')", true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValuePlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.HasIndex("ContentId") + .IsUnique(); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentBlockId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentBlockId", "VersionNumber") + .IsUnique(); + + b.ToTable("ContentBlockVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OfficialName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DeadLetterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at_utc"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("reason"); + + b.HasKey("Id"); + + b.HasIndex("DeadLetteredAtUtc") + .HasDatabaseName("ix_queue_dead_letters_dead_lettered_at"); + + b.ToTable("queue_dead_letters", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DevZendeskTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("priority"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_json"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("subject"); + + b.Property("TicketId") + .HasColumnType("bigint") + .HasColumnName("ticket_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("ix_dev_zendesk_outbox_created_at"); + + b.ToTable("dev_zendesk_outbox", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.OrganisationLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Laestab") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LoggedInAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganisationName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LoggedInAtUtc"); + + b.HasIndex("OrganisationUrn", "LoggedInAtUtc"); + + b.ToTable("OrganisationLogins"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("PageName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"Title\", '')), 'B') || setweight(to_tsvector('english', coalesce(\"Subtitle\", '')), 'C')", true); + + b.Property("Segment") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ShowInMenu") + .HasColumnType("boolean"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path") + .IsUnique() + .HasFilter("\"DeletedDate\" IS NULL"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("PageNodes"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BodyPlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("MinorVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("PageNodeId") + .HasColumnType("uuid"); + + b.Property("PublishFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishTo") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"BodyPlainText\", '')), 'D')", true); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("VersionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.HasIndex("PageNodeId", "IsCurrent"); + + b.HasIndex("PageNodeId", "VersionId") + .IsUnique(); + + b.ToTable("PageNodeVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.QueueMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("VisibleAfterUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("visible_after_utc"); + + b.HasKey("Id"); + + b.HasIndex("QueueName", "Status", "VisibleAfterUtc") + .HasDatabaseName("ix_queue_messages_claim"); + + b.ToTable("queue_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.RulesConfigVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ConfigType", "VersionNumber") + .IsUnique(); + + b.ToTable("RulesConfigVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.HasOne("DfE.CheckPerformance.Persistence.Entities.SearchEvent", null) + .WithMany() + .HasForeignKey("SearchEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany() + .HasForeignKey("WindowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("CheckingExercises") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.WindowValidated", "Validated", b1 => + { + b1.Property("CheckingWindowId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("IngressValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("SchemaValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("ValidatedAt") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("CheckingWindowId"); + + b1.ToTable("CheckingWindows"); + + b1.WithOwner() + .HasForeignKey("CheckingWindowId"); + }); + + b.Navigation("Validated"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", null) + .WithMany("Datasets") + .HasForeignKey("CheckingExerciseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", "ContentBlock") + .WithMany("Versions") + .HasForeignKey("ContentBlockId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContentBlock"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PageNode_PageNode_ParentId"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", "PageNode") + .WithMany("Versions") + .HasForeignKey("PageNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PageNode"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Navigation("Datasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("CheckingExercises"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820081648_BackfillResultsEnquiryExercise.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820081648_BackfillResultsEnquiryExercise.cs new file mode 100644 index 000000000..a7141f852 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820081648_BackfillResultsEnquiryExercise.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + /// + /// Gives every existing 16-19 window the ResultsEnquiry exercise it is already behaving as if + /// it had (#317). + /// + /// + /// Data only — no schema change. #313's backfill gave every window a PupilData row and nothing + /// else, and #317 makes the check-your-pupil-data page offer "Report an issue with an exam + /// result" only while a ResultsEnquiry exercise is open. Without this, the option would vanish + /// from every deployed 16-19 window the moment #317 ships: a shipped feature silently withdrawn. + /// + /// The window's own dates are used, which reproduces exactly today's behaviour — the option is + /// offered for the whole outer window. That is the thing #307 exists to fix, so this is + /// deliberately transitional: #319's admin captures the real per-exercise dates, and this only + /// has to hold the line until then. + /// + /// Post16 only. The other window types do not offer an enquiry today, and starting to offer one + /// is a product decision for the admin screens, not for a migration. + /// + /// The NOT EXISTS guard makes it idempotent and, more importantly, non-destructive: a window + /// someone has already configured with real enquiry dates keeps them. + /// + public partial class BackfillResultsEnquiryExercise : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + INSERT INTO "CheckingExercises" + ("Id", "CheckingWindowId", "ExerciseType", "StartDate", "EndDate", "SortOrder") + SELECT gen_random_uuid(), w."Id", 'ResultsEnquiry', w."StartDate", w."EndDate", 1 + FROM "CheckingWindows" w + WHERE w."CheckingWindowType" = 'Post16' + AND NOT EXISTS ( + SELECT 1 FROM "CheckingExercises" e + WHERE e."CheckingWindowId" = w."Id" AND e."ExerciseType" = 'ResultsEnquiry' + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Removes only the rows this migration's shape produces. A row someone edited to real + // enquiry dates no longer matches the window's own dates, so it survives a rollback. + migrationBuilder.Sql(""" + DELETE FROM "CheckingExercises" e + USING "CheckingWindows" w + WHERE e."CheckingWindowId" = w."Id" + AND e."ExerciseType" = 'ResultsEnquiry' + AND w."CheckingWindowType" = 'Post16' + AND e."StartDate" = w."StartDate" + AND e."EndDate" = w."EndDate" + AND e."SortOrder" = 1; + """); + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820094844_MoveValidationStampToCheckingExercise.Designer.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820094844_MoveValidationStampToCheckingExercise.Designer.cs new file mode 100644 index 000000000..9e9ccb186 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820094844_MoveValidationStampToCheckingExercise.Designer.cs @@ -0,0 +1,1392 @@ +// +using System; +using DfE.CheckPerformanceData.Persistence.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20260820094844_MoveValidationStampToCheckingExercise")] + partial class MoveValidationStampToCheckingExercise + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangedColumns") + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.QueueMetricEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DecisionStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("decision_status"); + + b.Property("LatencyMs") + .HasColumnType("double precision") + .HasColumnName("latency_ms"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at_utc"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("rules_version"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("stage"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_recorded_at"); + + b.HasIndex("QueueName", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_queue_recorded"); + + b.HasIndex("ReferenceNumber", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_reference"); + + b.ToTable("queue_metrics_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("QueryNormalised") + .HasColumnType("text") + .HasColumnName("query_normalised"); + + b.Property("QueryRaw") + .HasColumnType("text") + .HasColumnName("query_raw"); + + b.Property("ResultsBlocks") + .HasColumnType("integer") + .HasColumnName("results_blocks"); + + b.Property("ResultsPages") + .HasColumnType("integer") + .HasColumnName("results_pages"); + + b.Property("ResultsTotal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasColumnName("results_total") + .HasComputedColumnSql("results_pages + results_blocks", true); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("ZeroResults") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("zero_results") + .HasComputedColumnSql("(results_pages + results_blocks) = 0", true); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_events_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_search_events_occurred_at"); + + b.HasIndex("QueryNormalised") + .HasDatabaseName("ix_search_events_query_normalised"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_events_session_id"); + + b.HasIndex("OccurredAtUtc", "QueryNormalised") + .HasDatabaseName("ix_search_events_occurred_at_query_normalised") + .HasFilter("query_normalised IS NOT NULL"); + + b.HasIndex("OccurredAtUtc", "SessionId") + .HasDatabaseName("ix_search_events_occurred_at_session_id"); + + b.HasIndex("ZeroResults", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_zero_results_occurred_at") + .HasFilter("zero_results = true"); + + b.ToTable("search_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Rank") + .HasColumnType("real") + .HasColumnName("rank"); + + b.Property("ResultKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_key"); + + b.Property("ResultKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_kind"); + + b.Property("SearchEventId") + .HasColumnType("bigint") + .HasColumnName("search_event_id"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_event_results_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SearchEventId") + .HasDatabaseName("ix_search_event_results_search_event_id"); + + b.ToTable("search_event_results", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("ReadAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at_utc"); + + b.Property("ReadByAdminSub") + .HasColumnType("text") + .HasColumnName("read_by_admin_sub"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at_utc"); + + b.Property("WhatGot") + .HasColumnType("text") + .HasColumnName("what_got"); + + b.Property("WhatLookingFor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("what_looking_for"); + + b.HasKey("Id"); + + b.HasIndex("IsRead") + .HasDatabaseName("ix_search_messages_is_read"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_messages_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_messages_session_id"); + + b.HasIndex("SubmittedAtUtc") + .HasDatabaseName("ix_search_messages_submitted_at"); + + b.ToTable("search_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.ShareToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("surface"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_share_tokens_token_hash"); + + b.ToTable("share_tokens", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AdminSectionAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("RoleName", "SectionKey") + .IsUnique(); + + b.ToTable("AdminSectionAccesses"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AppLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.ToTable("AppLogs"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmendmentType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CrmId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedRuleId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OutcomeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilFirstname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilId") + .HasColumnType("uuid"); + + b.Property("PupilSurname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilUpn") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RequestTypeDescription") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Submitted") + .HasColumnType("timestamp without time zone"); + + b.Property("SubmittedByEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("SubmittedByName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WindowId") + .HasColumnType("uuid"); + + b.Property("WithdrawnAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WithdrawnByEmail") + .HasColumnType("text"); + + b.Property("WorkerStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CrmId") + .IsUnique() + .HasFilter("\"CrmId\" IS NOT NULL"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WindowId", "OrganisationUrn"); + + b.ToTable("ChangeRequests"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExerciseType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "ExerciseType") + .IsUnique(); + + b.ToTable("CheckingExercises", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowType") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("KeyStage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("CheckingWindows"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingExerciseId") + .HasColumnType("uuid"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("Included") + .HasColumnType("boolean"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckingExerciseId", "Name") + .IsUnique(); + + b.ToTable("CheckingWindowDatasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BlockType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenPath") + .HasColumnType("text"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"ValuePlainText\", '')), 'B')", true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValuePlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.HasIndex("ContentId") + .IsUnique(); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentBlockId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentBlockId", "VersionNumber") + .IsUnique(); + + b.ToTable("ContentBlockVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OfficialName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DeadLetterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at_utc"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("reason"); + + b.HasKey("Id"); + + b.HasIndex("DeadLetteredAtUtc") + .HasDatabaseName("ix_queue_dead_letters_dead_lettered_at"); + + b.ToTable("queue_dead_letters", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DevZendeskTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("priority"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_json"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("subject"); + + b.Property("TicketId") + .HasColumnType("bigint") + .HasColumnName("ticket_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("ix_dev_zendesk_outbox_created_at"); + + b.ToTable("dev_zendesk_outbox", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.OrganisationLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Laestab") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LoggedInAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganisationName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LoggedInAtUtc"); + + b.HasIndex("OrganisationUrn", "LoggedInAtUtc"); + + b.ToTable("OrganisationLogins"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("PageName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"Title\", '')), 'B') || setweight(to_tsvector('english', coalesce(\"Subtitle\", '')), 'C')", true); + + b.Property("Segment") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ShowInMenu") + .HasColumnType("boolean"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path") + .IsUnique() + .HasFilter("\"DeletedDate\" IS NULL"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("PageNodes"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BodyPlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("MinorVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("PageNodeId") + .HasColumnType("uuid"); + + b.Property("PublishFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishTo") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"BodyPlainText\", '')), 'D')", true); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("VersionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.HasIndex("PageNodeId", "IsCurrent"); + + b.HasIndex("PageNodeId", "VersionId") + .IsUnique(); + + b.ToTable("PageNodeVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.QueueMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("VisibleAfterUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("visible_after_utc"); + + b.HasKey("Id"); + + b.HasIndex("QueueName", "Status", "VisibleAfterUtc") + .HasDatabaseName("ix_queue_messages_claim"); + + b.ToTable("queue_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.RulesConfigVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ConfigType", "VersionNumber") + .IsUnique(); + + b.ToTable("RulesConfigVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.HasOne("DfE.CheckPerformance.Persistence.Entities.SearchEvent", null) + .WithMany() + .HasForeignKey("SearchEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany() + .HasForeignKey("WindowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("CheckingExercises") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.ExerciseValidated", "Validated", b1 => + { + b1.Property("CheckingExerciseId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("IngressValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("SchemaValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("ValidatedAt") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("CheckingExerciseId"); + + b1.ToTable("CheckingExercises"); + + b1.WithOwner() + .HasForeignKey("CheckingExerciseId"); + }); + + b.Navigation("Validated"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", null) + .WithMany("Datasets") + .HasForeignKey("CheckingExerciseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", "ContentBlock") + .WithMany("Versions") + .HasForeignKey("ContentBlockId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContentBlock"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PageNode_PageNode_ParentId"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", "PageNode") + .WithMany("Versions") + .HasForeignKey("PageNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PageNode"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Navigation("Datasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("CheckingExercises"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820094844_MoveValidationStampToCheckingExercise.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820094844_MoveValidationStampToCheckingExercise.cs new file mode 100644 index 000000000..0def131e6 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820094844_MoveValidationStampToCheckingExercise.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + /// + /// Moves the validation stamp from the checking window down to the checking exercise (#319). + /// A window is no longer validated as a whole: each exercise has its own inputs, on its own + /// dates, so one window-level flag could only ever describe one of them. + /// + /// + /// + /// Nothing is backfilled, on purpose. Both CreateAsync and UpdateAsync + /// wrote Validated unconditionally, so every window on every environment carries a stamp + /// whether or not anything was ever validated. Copying that onto the exercises would mark the + /// whole estate validated on the strength of a value that recorded nothing. Exercises start + /// unvalidated and an admin revalidates — the fail-closed answer, and the only honest one. + /// + /// + /// Written as idempotent SQL rather than the scaffolded AddColumn/DropColumn pair + /// so it no-ops on a database that already has the change, per the reconciliation rule in + /// CLAUDE.md. + /// + /// + public partial class MoveValidationStampToCheckingExercise : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE "CheckingExercises" + ADD COLUMN IF NOT EXISTS "Validated_ValidatedAt" timestamp with time zone NULL, + ADD COLUMN IF NOT EXISTS "Validated_IngressValidationChecksum" character varying(256) NULL, + ADD COLUMN IF NOT EXISTS "Validated_SchemaValidationChecksum" character varying(256) NULL; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "CheckingWindows" + DROP COLUMN IF EXISTS "Validated_ValidatedAt", + DROP COLUMN IF EXISTS "Validated_IngressValidationChecksum", + DROP COLUMN IF EXISTS "Validated_SchemaValidationChecksum"; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE "CheckingWindows" + ADD COLUMN IF NOT EXISTS "Validated_ValidatedAt" timestamp with time zone NULL, + ADD COLUMN IF NOT EXISTS "Validated_IngressValidationChecksum" character varying(256) NULL, + ADD COLUMN IF NOT EXISTS "Validated_SchemaValidationChecksum" character varying(256) NULL; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "CheckingExercises" + DROP COLUMN IF EXISTS "Validated_ValidatedAt", + DROP COLUMN IF EXISTS "Validated_IngressValidationChecksum", + DROP COLUMN IF EXISTS "Validated_SchemaValidationChecksum"; + """); + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820135315_AddDatasetSourceFile.Designer.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820135315_AddDatasetSourceFile.Designer.cs new file mode 100644 index 000000000..2ae791d0d --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820135315_AddDatasetSourceFile.Designer.cs @@ -0,0 +1,1406 @@ +// +using System; +using DfE.CheckPerformanceData.Persistence.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20260820135315_AddDatasetSourceFile")] + partial class AddDatasetSourceFile + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangedColumns") + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.QueueMetricEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DecisionStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("decision_status"); + + b.Property("LatencyMs") + .HasColumnType("double precision") + .HasColumnName("latency_ms"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at_utc"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("rules_version"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("stage"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_recorded_at"); + + b.HasIndex("QueueName", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_queue_recorded"); + + b.HasIndex("ReferenceNumber", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_reference"); + + b.ToTable("queue_metrics_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("QueryNormalised") + .HasColumnType("text") + .HasColumnName("query_normalised"); + + b.Property("QueryRaw") + .HasColumnType("text") + .HasColumnName("query_raw"); + + b.Property("ResultsBlocks") + .HasColumnType("integer") + .HasColumnName("results_blocks"); + + b.Property("ResultsPages") + .HasColumnType("integer") + .HasColumnName("results_pages"); + + b.Property("ResultsTotal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasColumnName("results_total") + .HasComputedColumnSql("results_pages + results_blocks", true); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("ZeroResults") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("zero_results") + .HasComputedColumnSql("(results_pages + results_blocks) = 0", true); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_events_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_search_events_occurred_at"); + + b.HasIndex("QueryNormalised") + .HasDatabaseName("ix_search_events_query_normalised"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_events_session_id"); + + b.HasIndex("OccurredAtUtc", "QueryNormalised") + .HasDatabaseName("ix_search_events_occurred_at_query_normalised") + .HasFilter("query_normalised IS NOT NULL"); + + b.HasIndex("OccurredAtUtc", "SessionId") + .HasDatabaseName("ix_search_events_occurred_at_session_id"); + + b.HasIndex("ZeroResults", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_zero_results_occurred_at") + .HasFilter("zero_results = true"); + + b.ToTable("search_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Rank") + .HasColumnType("real") + .HasColumnName("rank"); + + b.Property("ResultKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_key"); + + b.Property("ResultKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_kind"); + + b.Property("SearchEventId") + .HasColumnType("bigint") + .HasColumnName("search_event_id"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_event_results_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SearchEventId") + .HasDatabaseName("ix_search_event_results_search_event_id"); + + b.ToTable("search_event_results", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("ReadAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at_utc"); + + b.Property("ReadByAdminSub") + .HasColumnType("text") + .HasColumnName("read_by_admin_sub"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at_utc"); + + b.Property("WhatGot") + .HasColumnType("text") + .HasColumnName("what_got"); + + b.Property("WhatLookingFor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("what_looking_for"); + + b.HasKey("Id"); + + b.HasIndex("IsRead") + .HasDatabaseName("ix_search_messages_is_read"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_messages_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_messages_session_id"); + + b.HasIndex("SubmittedAtUtc") + .HasDatabaseName("ix_search_messages_submitted_at"); + + b.ToTable("search_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.ShareToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("surface"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_share_tokens_token_hash"); + + b.ToTable("share_tokens", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AdminSectionAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("RoleName", "SectionKey") + .IsUnique(); + + b.ToTable("AdminSectionAccesses"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AppLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.ToTable("AppLogs"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmendmentType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CrmId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedRuleId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OutcomeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilFirstname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilId") + .HasColumnType("uuid"); + + b.Property("PupilSurname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilUpn") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RequestTypeDescription") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Submitted") + .HasColumnType("timestamp without time zone"); + + b.Property("SubmittedByEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("SubmittedByName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WindowId") + .HasColumnType("uuid"); + + b.Property("WithdrawnAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WithdrawnByEmail") + .HasColumnType("text"); + + b.Property("WorkerStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CrmId") + .IsUnique() + .HasFilter("\"CrmId\" IS NOT NULL"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WindowId", "OrganisationUrn"); + + b.ToTable("ChangeRequests"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExerciseType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "ExerciseType") + .IsUnique(); + + b.ToTable("CheckingExercises", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowType") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("KeyStage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TurnaroundCommitment") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("CheckingWindows"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingExerciseId") + .HasColumnType("uuid"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("Included") + .HasColumnType("boolean"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Required") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SourceFile") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CheckingExerciseId", "Name") + .IsUnique(); + + b.ToTable("CheckingWindowDatasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BlockType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenPath") + .HasColumnType("text"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"ValuePlainText\", '')), 'B')", true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValuePlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.HasIndex("ContentId") + .IsUnique(); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentBlockId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentBlockId", "VersionNumber") + .IsUnique(); + + b.ToTable("ContentBlockVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OfficialName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DeadLetterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at_utc"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("reason"); + + b.HasKey("Id"); + + b.HasIndex("DeadLetteredAtUtc") + .HasDatabaseName("ix_queue_dead_letters_dead_lettered_at"); + + b.ToTable("queue_dead_letters", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DevZendeskTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("priority"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_json"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("subject"); + + b.Property("TicketId") + .HasColumnType("bigint") + .HasColumnName("ticket_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("ix_dev_zendesk_outbox_created_at"); + + b.ToTable("dev_zendesk_outbox", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.OrganisationLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Laestab") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LoggedInAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganisationName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LoggedInAtUtc"); + + b.HasIndex("OrganisationUrn", "LoggedInAtUtc"); + + b.ToTable("OrganisationLogins"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("PageName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"Title\", '')), 'B') || setweight(to_tsvector('english', coalesce(\"Subtitle\", '')), 'C')", true); + + b.Property("Segment") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ShowInMenu") + .HasColumnType("boolean"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path") + .IsUnique() + .HasFilter("\"DeletedDate\" IS NULL"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("PageNodes"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BodyPlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("MinorVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("PageNodeId") + .HasColumnType("uuid"); + + b.Property("PublishFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishTo") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"BodyPlainText\", '')), 'D')", true); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("VersionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.HasIndex("PageNodeId", "IsCurrent"); + + b.HasIndex("PageNodeId", "VersionId") + .IsUnique(); + + b.ToTable("PageNodeVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.QueueMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("VisibleAfterUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("visible_after_utc"); + + b.HasKey("Id"); + + b.HasIndex("QueueName", "Status", "VisibleAfterUtc") + .HasDatabaseName("ix_queue_messages_claim"); + + b.ToTable("queue_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.RulesConfigVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ConfigType", "VersionNumber") + .IsUnique(); + + b.ToTable("RulesConfigVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.HasOne("DfE.CheckPerformance.Persistence.Entities.SearchEvent", null) + .WithMany() + .HasForeignKey("SearchEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany() + .HasForeignKey("WindowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("CheckingExercises") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.ExerciseValidated", "Validated", b1 => + { + b1.Property("CheckingExerciseId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("IngressValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("SchemaValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("ValidatedAt") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("CheckingExerciseId"); + + b1.ToTable("CheckingExercises"); + + b1.WithOwner() + .HasForeignKey("CheckingExerciseId"); + }); + + b.Navigation("Validated"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", null) + .WithMany("Datasets") + .HasForeignKey("CheckingExerciseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", "ContentBlock") + .WithMany("Versions") + .HasForeignKey("ContentBlockId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContentBlock"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PageNode_PageNode_ParentId"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", "PageNode") + .WithMany("Versions") + .HasForeignKey("PageNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PageNode"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Navigation("Datasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("CheckingExercises"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820135315_AddDatasetSourceFile.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820135315_AddDatasetSourceFile.cs new file mode 100644 index 000000000..d63311caa --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260820135315_AddDatasetSourceFile.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + /// + /// Adds the SOURCE tag a dataset stamps on its records and whether the slot must be filled, and + /// gives every existing results-enquiry exercise the upload slots for its source files (#324). + /// + /// + /// SourceFile is the exact analogue of Included: Included stamps inclusion by file of origin, + /// SourceFile stamps provenance by file of origin. Null on every pupil-data dataset, which is + /// why it is nullable and backfills nothing on its own. Required defaults to true so every + /// existing slot keeps today's rule — the exercise is not validatable until the slot is filled. + /// + /// The data half matters more than the columns. Dataset slots are only reconciled when a window + /// is saved through WindowService, so without this every results-enquiry exercise already on a + /// deployed environment would show "no ingress files to load" until an admin happened to + /// re-save its window — and nobody could upload the results files the enquiry journey needs. + /// Only empty slots are created, so an admin who has uploaded files keeps them, and the NOT + /// EXISTS guard makes it idempotent. + /// + public partial class AddDatasetSourceFile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Required", + table: "CheckingWindowDatasets", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "SourceFile", + table: "CheckingWindowDatasets", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + // One empty slot per source file, named by the tag it stamps. The tags are verbatim + // from AB#296999 and are a data contract with the ingestion pipeline — they must stay + // in step with ResultsFileTags. Only the main file is required: the late, revised and + // retention files land weeks apart and one may never land, so requiring them would mean + // an exercise that can never be validated. KS2 is absent on purpose: it has no feed. + migrationBuilder.Sql(""" + INSERT INTO "CheckingWindowDatasets" + ("Id", "CheckingExerciseId", "CheckingWindowId", "Name", "IngressFile", + "IngressFileChecksum", "SchemaFile", "SchemaFileChecksum", "Included", + "SourceFile", "Required", "SortOrder") + SELECT gen_random_uuid(), e."Id", w."Id", s.tag, '', '', '', '', NULL, s.tag, + s.sort = 0, s.sort + FROM "CheckingExercises" e + JOIN "CheckingWindows" w ON w."Id" = e."CheckingWindowId" + JOIN LATERAL ( + SELECT t.tag, t.sort + FROM (VALUES + ('Post16', '16to19_MAIN', 0), + ('Post16', '16to19_LR1', 1), + ('Post16', '16to19_LR2', 2), + ('Post16', '16to19_Revised', 3), + ('Post16', '16to19_Retention', 4), + ('KS4June', 'KS4_MAIN', 0), + ('KS4June', 'KS4_LR1', 1), + ('KS4June', 'KS4_LR2', 2), + ('KS4June', 'KS4_Revised', 3), + ('KS4Autumn', 'KS4_MAIN', 0), + ('KS4Autumn', 'KS4_LR1', 1), + ('KS4Autumn', 'KS4_LR2', 2), + ('KS4Autumn', 'KS4_Revised', 3) + ) AS t(window_type, tag, sort) + WHERE t.window_type = w."CheckingWindowType" + ) s ON TRUE + WHERE e."ExerciseType" = 'ResultsEnquiry' + AND NOT EXISTS ( + SELECT 1 FROM "CheckingWindowDatasets" d + WHERE d."CheckingExerciseId" = e."Id" AND d."Name" = s.tag + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Only slots nobody has uploaded to are removed. A slot holding a file is data an admin + // put there, and a rollback must not throw it away. + migrationBuilder.Sql(""" + DELETE FROM "CheckingWindowDatasets" d + USING "CheckingExercises" e + WHERE d."CheckingExerciseId" = e."Id" + AND e."ExerciseType" = 'ResultsEnquiry' + AND d."IngressFile" = '' + AND d."SchemaFile" = ''; + """); + + migrationBuilder.DropColumn( + name: "Required", + table: "CheckingWindowDatasets"); + + migrationBuilder.DropColumn( + name: "SourceFile", + table: "CheckingWindowDatasets"); + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs index 35112de5c..e20c8f3d0 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs @@ -602,6 +602,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ChangeRequests"); }); + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExerciseType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "ExerciseType") + .IsUnique(); + + b.ToTable("CheckingExercises", (string)null); + }); + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => { b.Property("Id") @@ -668,6 +700,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasDefaultValueSql("gen_random_uuid()"); + b.Property("CheckingExerciseId") + .HasColumnType("uuid"); + b.Property("CheckingWindowId") .HasColumnType("uuid"); @@ -689,6 +724,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("Required") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + b.Property("SchemaFile") .IsRequired() .HasMaxLength(255) @@ -702,9 +742,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SortOrder") .HasColumnType("integer"); + b.Property("SourceFile") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + b.HasKey("Id"); - b.HasIndex("CheckingWindowId", "Name") + b.HasIndex("CheckingExerciseId", "Name") .IsUnique(); b.ToTable("CheckingWindowDatasets"); @@ -1256,11 +1300,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => { - b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.WindowValidated", "Validated", b1 => + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("CheckingExercises") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.ExerciseValidated", "Validated", b1 => { - b1.Property("CheckingWindowId") + b1.Property("CheckingExerciseId") .ValueGeneratedOnAdd() .HasColumnType("uuid"); @@ -1277,12 +1327,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.Property("ValidatedAt") .HasColumnType("timestamp with time zone"); - b1.HasKey("CheckingWindowId"); + b1.HasKey("CheckingExerciseId"); - b1.ToTable("CheckingWindows"); + b1.ToTable("CheckingExercises"); b1.WithOwner() - .HasForeignKey("CheckingWindowId"); + .HasForeignKey("CheckingExerciseId"); }); b.Navigation("Validated"); @@ -1290,9 +1340,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => { - b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", null) .WithMany("Datasets") - .HasForeignKey("CheckingWindowId") + .HasForeignKey("CheckingExerciseId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); @@ -1328,11 +1378,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("PageNode"); }); - modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => { b.Navigation("Datasets"); }); + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("CheckingExercises"); + }); + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => { b.Navigation("Versions"); diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs index dfa290be5..324724cf5 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs @@ -4,6 +4,9 @@ using DfE.CheckPerformanceData.Application.LandingPage; using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Persistence.Contexts; +// Aliased, not imported: WindowManagement also declares a CheckingWindowDto, which would make the +// LandingPage one ambiguous here. +using CheckingExerciseDto = DfE.CheckPerformanceData.Application.WindowManagement.CheckingExerciseDto; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -43,7 +46,28 @@ public async Task GetCheckingWindowAsync(Guid windowId) => await dbContext.CheckingWindows .AsNoTracking() .Where(w => w.Id == windowId) - .Select(w => new CheckingWindowDto { EndDate = w.EndDate, Title = w.Title, KeyStage = w.KeyStage, CheckingWindowType = w.CheckingWindowType, StartDate = w.StartDate, TurnaroundCommitment = w.TurnaroundCommitment }) + .Select(w => new CheckingWindowDto + { + EndDate = w.EndDate, + Title = w.Title, + KeyStage = w.KeyStage, + CheckingWindowType = w.CheckingWindowType, + StartDate = w.StartDate, + TurnaroundCommitment = w.TurnaroundCommitment, + // #315: ICheckingExerciseService answers "is this exercise open" from these rows, + // so every read path that reaches Web has to carry them. + Exercises = w.CheckingExercises + .OrderBy(e => e.SortOrder) + .Select(e => new CheckingExerciseDto + { + Id = e.Id, + ExerciseType = e.ExerciseType, + StartDate = e.StartDate, + EndDate = e.EndDate, + SortOrder = e.SortOrder + }) + .ToList() + }) .SingleAsync(); public async Task GetPupilAsync(Guid windowId, string laestab, Guid pupilId) @@ -52,7 +76,7 @@ public async Task GetPupilAsync(Guid windowId, string laestab, Guid pu return ToPupilDto(pupils.Single(p => p.Id == pupilId)); } - public async Task> SearchPupilsAsync(Guid windowId, string laestab, string urn, string query, PupilFilter filter, Guid? excludeId = null) + public async Task> SearchPupilsAsync(Guid windowId, string laestab, string urn, string query, PupilFilter filter, Guid? excludeId = null, IReadOnlySet? cypmdIdAllowList = null) { // urn is retained on the signature for callers but is unused: the UPN-based exclusion // query it served was removed in 3f9efadf, which moved conflict detection onto pupil Id. @@ -73,6 +97,12 @@ public async Task> SearchPupilsAsync(Guid wind if (excludeId.HasValue) pupils = pupils.Where(p => p.Id != excludeId.Value); + // Applied here rather than after the cap below: ten pupils who hold no results would + // otherwise crowd out the one who does. The set carries its own comparer (the results + // client builds it case-insensitively), so Contains is asked, never a re-implementation. + if (cypmdIdAllowList is not null) + pupils = pupils.Where(p => cypmdIdAllowList.Contains(p.Cypmd_Id)); + return pupils .OrderBy(p => p.Surname).ThenBy(p => p.Firstname) .Take(10) @@ -93,7 +123,8 @@ private async Task GetSchoolPupilsWithWindowTypeAsync(Gu // The blob's record shape depends on the window type, so the window is resolved first. var window = await GetCheckingWindowAsync(windowId); - var pupils = await pupilDataBlobClient.GetPupilsAsync(windowId, laestab, window.CheckingWindowType) ?? []; + var pupils = await pupilDataBlobClient.GetPupilsAsync( + windowId, CheckingExerciseType.PupilData, laestab, window.CheckingWindowType) ?? []; var entry = new SchoolPupilsCacheEntry(pupils, window.CheckingWindowType); cache.Set(key, entry, new MemoryCacheEntryOptions { SlidingExpiration = CacheSlidingExpiry }); return entry; diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs index 8e64a6604..8527e7329 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs @@ -1,6 +1,10 @@ using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.LandingPage; +using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Persistence.Contexts; +// Aliased, not imported: WindowManagement also declares a CheckingWindowDto, which would make the +// LandingPage one ambiguous here. +using CheckingExerciseDto = DfE.CheckPerformanceData.Application.WindowManagement.CheckingExerciseDto; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -28,7 +32,20 @@ public async Task> GetOpenWindowsAsync(DateTime now, str w.CheckingWindowType, w.Title, w.TurnaroundCommitment, - w.Id + w.Id, + // #315: the landing page cannot tell whether a Post16 window's results enquiry is + // running from the window's own dates — only the exercise rows say that. + Exercises = w.CheckingExercises + .OrderBy(e => e.SortOrder) + .Select(e => new CheckingExerciseDto + { + Id = e.Id, + ExerciseType = e.ExerciseType, + StartDate = e.StartDate, + EndDate = e.EndDate, + SortOrder = e.SortOrder + }) + .ToList() }) .ToListAsync(cancellationToken); @@ -57,7 +74,10 @@ public async Task> GetOpenWindowsAsync(DateTime now, str Title = w.Title, TurnaroundCommitment = w.TurnaroundCommitment, Id = w.Id, - HasPupilData = await pupilDataBlobClient.HasPupilDataAsync(w.Id, laestab) + // #316: "has pupil data" asks about the pupil-data exercise's prefix specifically. + HasPupilData = await pupilDataBlobClient.HasPupilDataAsync( + w.Id, CheckingExerciseType.PupilData, laestab), + Exercises = w.Exercises }); } diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs index 96a950165..bba517857 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs @@ -2,7 +2,6 @@ using DfE.CheckPerformanceData.Persistence.Contexts; using DfE.CheckPerformanceData.Persistence.Entities; using Microsoft.EntityFrameworkCore; -using WindowValidated = DfE.CheckPerformanceData.Persistence.Entities.WindowValidated; namespace DfE.CheckPerformanceData.Persistence.Repositories; @@ -24,20 +23,39 @@ await dbContext.CheckingWindows IngressFileChecksum = w.IngressFileChecksum, SchemaFile = w.SchemaFile, SchemaFileChecksum = w.SchemaFileChecksum, - Validated = w.Validated != null, - ValidatedAt = (w.Validated != null ? w.Validated.ValidatedAt : null), - Datasets = w.Datasets - .OrderBy(d => d.SortOrder) - .Select(d => new CheckingWindowDatasetDto + Exercises = w.CheckingExercises + .OrderBy(e => e.SortOrder) + .Select(e => new CheckingExerciseDto { - Id = d.Id, - Name = d.Name, - IngressFile = d.IngressFile, - IngressFileChecksum = d.IngressFileChecksum, - SchemaFile = d.SchemaFile, - SchemaFileChecksum = d.SchemaFileChecksum, - Included = d.Included, - SortOrder = d.SortOrder + Id = e.Id, + ExerciseType = e.ExerciseType, + StartDate = e.StartDate, + EndDate = e.EndDate, + SortOrder = e.SortOrder, + // #319: the validation stamp lives on the exercise now. The checksums say + // which files it was taken over, so a stamp left behind by a since-replaced + // ingress file reads as stale rather than as validated. + ValidatedAt = e.Validated != null ? e.Validated.ValidatedAt : null, + ValidatedIngressChecksum = + e.Validated != null ? e.Validated.IngressValidationChecksum : string.Empty, + ValidatedSchemaChecksum = + e.Validated != null ? e.Validated.SchemaValidationChecksum : string.Empty, + Datasets = e.Datasets + .OrderBy(d => d.SortOrder) + .Select(d => new CheckingWindowDatasetDto + { + Id = d.Id, + Name = d.Name, + IngressFile = d.IngressFile, + IngressFileChecksum = d.IngressFileChecksum, + SchemaFile = d.SchemaFile, + SchemaFileChecksum = d.SchemaFileChecksum, + Included = d.Included, + SourceFile = d.SourceFile, + Required = d.Required, + SortOrder = d.SortOrder + }) + .ToList() }) .ToList() }) @@ -60,20 +78,39 @@ await dbContext.CheckingWindows IngressFileChecksum = w.IngressFileChecksum, SchemaFile = w.SchemaFile, SchemaFileChecksum = w.SchemaFileChecksum, - Validated = w.Validated != null, - ValidatedAt = (w.Validated != null ? w.Validated.ValidatedAt : null), - Datasets = w.Datasets - .OrderBy(d => d.SortOrder) - .Select(d => new CheckingWindowDatasetDto + Exercises = w.CheckingExercises + .OrderBy(e => e.SortOrder) + .Select(e => new CheckingExerciseDto { - Id = d.Id, - Name = d.Name, - IngressFile = d.IngressFile, - IngressFileChecksum = d.IngressFileChecksum, - SchemaFile = d.SchemaFile, - SchemaFileChecksum = d.SchemaFileChecksum, - Included = d.Included, - SortOrder = d.SortOrder + Id = e.Id, + ExerciseType = e.ExerciseType, + StartDate = e.StartDate, + EndDate = e.EndDate, + SortOrder = e.SortOrder, + // #319: the validation stamp lives on the exercise now. The checksums say + // which files it was taken over, so a stamp left behind by a since-replaced + // ingress file reads as stale rather than as validated. + ValidatedAt = e.Validated != null ? e.Validated.ValidatedAt : null, + ValidatedIngressChecksum = + e.Validated != null ? e.Validated.IngressValidationChecksum : string.Empty, + ValidatedSchemaChecksum = + e.Validated != null ? e.Validated.SchemaValidationChecksum : string.Empty, + Datasets = e.Datasets + .OrderBy(d => d.SortOrder) + .Select(d => new CheckingWindowDatasetDto + { + Id = d.Id, + Name = d.Name, + IngressFile = d.IngressFile, + IngressFileChecksum = d.IngressFileChecksum, + SchemaFile = d.SchemaFile, + SchemaFileChecksum = d.SchemaFileChecksum, + Included = d.Included, + SourceFile = d.SourceFile, + Required = d.Required, + SortOrder = d.SortOrder + }) + .ToList() }) .ToList() }) @@ -82,9 +119,10 @@ await dbContext.CheckingWindows public async Task UpdateAsync(CheckingWindowDto window, CancellationToken cancellationToken) { // Loaded and mutated rather than Update(new CheckingWindow{...}) — a detached overwrite - // would leave the window's dataset rows untracked and strand them. + // would leave the window's exercise and dataset rows untracked and strand them. CheckingWindow entity = await dbContext.CheckingWindows - .Include(w => w.Datasets) + .Include(w => w.CheckingExercises) + .ThenInclude(e => e.Datasets) .SingleAsync(w => w.Id == window.Id, cancellationToken); dbContext.Entry(entity).CurrentValues.SetValues(new @@ -101,40 +139,92 @@ public async Task UpdateAsync(CheckingWindowDto window, CancellationToken cancel window.SchemaFileChecksum }); - entity.Validated = new WindowValidated { ValidatedAt = window.ValidatedAt ?? DateTime.UtcNow }; - - SyncDatasets(entity, window.Datasets); + SyncExercises(entity, window.Exercises); await dbContext.SaveChangesAsync(cancellationToken); } - // Datasets are keyed by Name within a window: existing rows are updated in place so their - // Ids (and any files already uploaded against them) survive, new ones are added, and rows - // no longer wanted (e.g. after a window type change) are removed. - private static void SyncDatasets(CheckingWindow entity, List wanted) + // Exercises are keyed by type within a window (the unique index), datasets by name within an + // exercise: existing rows are updated in place so their Ids — and any files already uploaded + // against them — survive, new ones are added, and rows no longer wanted are removed. + private void SyncExercises(CheckingWindow entity, List wanted) { if (wanted.Count == 0) { return; } - foreach (CheckingWindowDatasetDto dto in wanted) + foreach (CheckingExerciseDto dto in wanted) { - CheckingWindowDataset? existing = entity.Datasets.SingleOrDefault(d => d.Name == dto.Name); + CheckingExercise? existing = + entity.CheckingExercises.SingleOrDefault(e => e.ExerciseType == dto.ExerciseType); if (existing is null) { - entity.Datasets.Add(new CheckingWindowDataset + existing = new CheckingExercise { CheckingWindowId = entity.Id, - Name = dto.Name, - IngressFile = dto.IngressFile, - IngressFileChecksum = dto.IngressFileChecksum, - SchemaFile = dto.SchemaFile, - SchemaFileChecksum = dto.SchemaFileChecksum, - Included = dto.Included, + ExerciseType = dto.ExerciseType, + StartDate = dto.StartDate, + EndDate = dto.EndDate, SortOrder = dto.SortOrder + }; + entity.CheckingExercises.Add(existing); + } + else + { + // #319: an exercise's dates are editable now, so an existing row has to take them. + // Before the wizard captured them nothing could change an exercise's dates, and + // this loop only ever reconciled datasets. + dbContext.Entry(existing).CurrentValues.SetValues(new + { + dto.StartDate, + dto.EndDate, + dto.SortOrder }); + } + + existing.Validated = StampFor(dto); + + SyncDatasets(entity, existing, dto.Datasets); + } + + foreach (CheckingExercise stale in entity.CheckingExercises + .Where(e => wanted.All(x => x.ExerciseType != e.ExerciseType)) + .ToList()) + { + entity.CheckingExercises.Remove(stale); + } + } + + // Null when the exercise has never validated. Written from the DTO rather than invented here: + // the old window-level stamp was set unconditionally on every create and update, so it said + // nothing at all about whether anything had been validated. + private static ExerciseValidated? StampFor(CheckingExerciseDto dto) => + dto.ValidatedAt is null + ? null + : new ExerciseValidated + { + ValidatedAt = dto.ValidatedAt.Value, + IngressValidationChecksum = dto.ValidatedIngressChecksum, + SchemaValidationChecksum = dto.ValidatedSchemaChecksum + }; + + private static void SyncDatasets( + CheckingWindow window, CheckingExercise exercise, List wanted) + { + if (wanted.Count == 0) + { + return; + } + + foreach (CheckingWindowDatasetDto dto in wanted) + { + CheckingWindowDataset? existing = exercise.Datasets.SingleOrDefault(d => d.Name == dto.Name); + + if (existing is null) + { + exercise.Datasets.Add(NewDataset(window, dto)); continue; } @@ -144,16 +234,39 @@ private static void SyncDatasets(CheckingWindow entity, List wanted.All(x => x.Name != d.Name)).ToList()) + foreach (CheckingWindowDataset stale in exercise.Datasets + .Where(d => wanted.All(x => x.Name != d.Name)) + .ToList()) { - entity.Datasets.Remove(stale); + exercise.Datasets.Remove(stale); } } + // The legacy CheckingWindowId column is still written, though nothing reads it: it is what + // makes a rollback to the previous release safe. The follow-up ticket that drops the column + // drops this too. + private static CheckingWindowDataset NewDataset(CheckingWindow window, CheckingWindowDatasetDto dto) => + new() + { + CheckingWindowId = window.Id, + Name = dto.Name, + IngressFile = dto.IngressFile, + IngressFileChecksum = dto.IngressFileChecksum, + SchemaFile = dto.SchemaFile, + SchemaFileChecksum = dto.SchemaFileChecksum, + Included = dto.Included, + SourceFile = dto.SourceFile, + Required = dto.Required, + SortOrder = dto.SortOrder + }; + public async Task CreateAsync(CheckingWindowDto window, CancellationToken cancellationToken) { var entity = new CheckingWindow { + // The id is assigned here rather than by the database default, because the legacy + // CheckingWindowId stamped onto each dataset row below needs it before the save. + Id = window.Id == Guid.Empty ? Guid.NewGuid() : window.Id, StartDate = window.StartDate, EndDate = window.EndDate, KeyStage = window.KeyStage, @@ -163,25 +276,24 @@ public async Task CreateAsync(CheckingWindowDto window, Cance IngressFile = window.IngressFile, IngressFileChecksum = window.IngressFileChecksum, SchemaFile = window.SchemaFile, - SchemaFileChecksum = window.SchemaFileChecksum, - Validated = new WindowValidated() { ValidatedAt = window.ValidatedAt ?? DateTime.UtcNow }, - // A window is born with the dataset slots its type requires. - Datasets = (window.Datasets.Count > 0 - ? window.Datasets - : WindowDatasets.DefaultsFor(window.CheckingWindowType).ToList()) - .Select(d => new CheckingWindowDataset - { - Name = d.Name, - IngressFile = d.IngressFile, - IngressFileChecksum = d.IngressFileChecksum, - SchemaFile = d.SchemaFile, - SchemaFileChecksum = d.SchemaFileChecksum, - Included = d.Included, - SortOrder = d.SortOrder - }) - .ToList() + SchemaFileChecksum = window.SchemaFileChecksum }; + // A window is born with its exercises, each holding the dataset slots its type requires. + // WindowService supplies a pupil-data exercise when the caller names none. + foreach (CheckingExerciseDto dto in window.Exercises.OrderBy(e => e.SortOrder)) + { + entity.CheckingExercises.Add(new CheckingExercise + { + ExerciseType = dto.ExerciseType, + StartDate = dto.StartDate, + EndDate = dto.EndDate, + SortOrder = dto.SortOrder, + Datasets = dto.Datasets.Select(d => NewDataset(entity, d)).ToList(), + Validated = StampFor(dto) + }); + } + await dbContext.CheckingWindows.AddAsync(entity, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); @@ -198,8 +310,33 @@ public async Task CreateAsync(CheckingWindowDto window, Cance IngressFileChecksum = entity.IngressFileChecksum, SchemaFile = entity.SchemaFile, SchemaFileChecksum = entity.SchemaFileChecksum, - Validated = entity.Validated != null, - ValidatedAt = entity.Validated?.ValidatedAt + Exercises = entity.CheckingExercises + .OrderBy(e => e.SortOrder) + .Select(e => new CheckingExerciseDto + { + Id = e.Id, + ExerciseType = e.ExerciseType, + StartDate = e.StartDate, + EndDate = e.EndDate, + SortOrder = e.SortOrder, + Datasets = e.Datasets + .OrderBy(d => d.SortOrder) + .Select(d => new CheckingWindowDatasetDto + { + Id = d.Id, + Name = d.Name, + IngressFile = d.IngressFile, + IngressFileChecksum = d.IngressFileChecksum, + SchemaFile = d.SchemaFile, + SchemaFileChecksum = d.SchemaFileChecksum, + Included = d.Included, + SourceFile = d.SourceFile, + Required = d.Required, + SortOrder = d.SortOrder + }) + .ToList() + }) + .ToList() }; } } \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs b/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs index 375627033..a7417889e 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs @@ -8,7 +8,8 @@ namespace DfE.CheckPerformanceData.Persistence.Seeding; public static class SeedCheckingWindows { // A KS4-style window ingests one supplier file; a Post16 window ingests two (included + - // non-included), so each window is seeded with the dataset slots its type requires. + // non-included), so each pupil-data exercise is seeded with the dataset slots its window type + // requires. The results-enquiry exercise reads the school results file and has no slots. private static List DatasetsFor(CheckingWindowType type) => type == CheckingWindowType.Post16 ? @@ -18,45 +19,97 @@ private static List DatasetsFor(CheckingWindowType type) ] : [new CheckingWindowDataset { Name = "pupils", Included = null, SortOrder = 0 }]; + // A window's exercises must cover exactly its outer StartDate/EndDate — that union rule is what + // lets the landing page keep deciding card visibility from the outer pair alone. Single-activity + // window types get one PupilData exercise across the whole window; Post16 splits, with results + // enquiry running far longer than pupil data checking (7 Oct - 31 Mar against 7 Oct - 18 Oct in + // the real calendar). See docs/16-19-window-model.md. + private static List ExercisesFor( + CheckingWindowType type, DateTime startDate, DateTime endDate) => + type == CheckingWindowType.Post16 + ? + [ + new CheckingExercise + { + ExerciseType = CheckingExerciseType.PupilData, + StartDate = startDate, + // 14 days from a start of yesterday, which is the same fortnight the KS4 + // windows run for. Results enquiry then carries on to the window's own end. + EndDate = startDate.AddDays(14).Date.AddHours(17), + SortOrder = 0, + Datasets = DatasetsFor(CheckingWindowType.Post16) + }, + new CheckingExercise + { + ExerciseType = CheckingExerciseType.ResultsEnquiry, + StartDate = startDate, + EndDate = endDate, + SortOrder = 1 + } + ] + : + [ + new CheckingExercise + { + ExerciseType = CheckingExerciseType.PupilData, + StartDate = startDate, + EndDate = endDate, + SortOrder = 0, + Datasets = DatasetsFor(type) + } + ]; + public static async Task ExecuteSeed(IPortalDbContext dbContext, Guid openKs4WindowId, Guid closedKs4WindowId, Guid post16WindowId) { await dbContext.ChangeRequests.ExecuteDeleteAsync(); await dbContext.CheckingWindows.ExecuteDeleteAsync(); + var openKs4Start = DateTime.Now.AddDays(-1); + var openKs4End = DateTime.Now.AddDays(+13).Date.AddHours(17); + var openKs4JuneWindow = new CheckingWindow { Id = openKs4WindowId, - StartDate = DateTime.Now.AddDays(-1), - EndDate = DateTime.Now.AddDays(+13).Date.AddHours(17), + StartDate = openKs4Start, + EndDate = openKs4End, KeyStage = KeyStages.KS4, CheckingWindowType = CheckingWindowType.KS4June, Title = "Key Stage 4 June", TurnaroundCommitment = "updated in the Autumn", - Datasets = DatasetsFor(CheckingWindowType.KS4June) + CheckingExercises = ExercisesFor(CheckingWindowType.KS4June, openKs4Start, openKs4End) }; + var closedKs4Start = DateTime.Now.AddYears(-1).AddDays(-1); + var closedKs4End = DateTime.Now.AddYears(-1).AddDays(+13).Date.AddHours(17); + var closedKs4JuneWindow = new CheckingWindow { Id = closedKs4WindowId, - StartDate = DateTime.Now.AddYears(-1).AddDays(-1), - EndDate = DateTime.Now.AddYears(-1).AddDays(+13).Date.AddHours(17), + StartDate = closedKs4Start, + EndDate = closedKs4End, KeyStage = KeyStages.KS4, CheckingWindowType = CheckingWindowType.KS4June, Title = "KS4 June", TurnaroundCommitment = "updated in the Autumn", - Datasets = DatasetsFor(CheckingWindowType.KS4June) + CheckingExercises = ExercisesFor(CheckingWindowType.KS4June, closedKs4Start, closedKs4End) }; + // The outer end date runs out to the results-enquiry exercise, because the window's dates + // are the union of its exercises. The window is open for longer than it used to be locally; + // that is the multi-exercise shape, and nothing reads the exercise rows yet. + var post16Start = DateTime.Now.AddDays(-1); + var post16End = DateTime.Now.AddDays(+180).Date.AddHours(17); + var openPost16Window = new CheckingWindow { Id = post16WindowId, - StartDate = DateTime.Now.AddDays(-1), - EndDate = DateTime.Now.AddDays(+13).Date.AddHours(17), + StartDate = post16Start, + EndDate = post16End, KeyStage = KeyStages.Post16, CheckingWindowType = CheckingWindowType.Post16, Title = "16 to 19", TurnaroundCommitment = "updated in the Spring", - Datasets = DatasetsFor(CheckingWindowType.Post16) + CheckingExercises = ExercisesFor(CheckingWindowType.Post16, post16Start, post16End) }; await dbContext.CheckingWindows.AddRangeAsync( diff --git a/src/DfE.CheckPerformanceData.Web/Common/ClosedExerciseGuard.cs b/src/DfE.CheckPerformanceData.Web/Common/ClosedExerciseGuard.cs new file mode 100644 index 000000000..4ce5cdd43 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Common/ClosedExerciseGuard.cs @@ -0,0 +1,50 @@ +using DfE.CheckPerformanceData.Domain.Enums; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Common; + +/// +/// #318: what a user is told when they reach a journey for a checking exercise that has closed. +/// The option list on Check your pupil data is presentation only — a bookmarked URL or a tab left +/// open across the closing date still posts — so every entry point rejects, and every rejection +/// lands back on Check your pupil data with a reason rather than on a 404. +/// +/// +/// Wording lives in Web beside NextStepLabels: it is on-screen copy, not domain knowledge. +/// Whether an exercise is open is never decided here — that is +/// 's single job. +/// +public static class ClosedExerciseGuard +{ + /// TempData slot read by _ClosedExerciseBanner on Check your pupil data. + public const string TempDataKey = "ClosedExerciseMessage"; + + /// + /// The message for a rejected entry into . Closed removes actions, + /// never content, so each message says what the user can still do. + /// + public static string MessageFor(CheckingExerciseType exercise) => exercise switch + { + CheckingExerciseType.PupilData => + "The deadline for requesting changes to your pupil data has passed. " + + "You can still view and download your data.", + CheckingExerciseType.ResultsEnquiry => + "The deadline for reporting an issue with your results has passed. " + + "You can still view and download your data.", + // No default, matching CheckingExerciseBlobPaths: a new exercise type must be given its own + // wording rather than silently borrowing another exercise's. ClosedExerciseGuardTests pins + // that every member of the enum has a message, so this cannot reach a user. + _ => throw new ArgumentOutOfRangeException( + nameof(exercise), exercise, "No closed-exercise message for this checking exercise.") + }; + + /// + /// Rejects the request: stashes the reason and sends the user back to Check your pupil data. + /// + public static RedirectToActionResult RedirectExerciseClosed( + this Controller controller, Guid windowId, CheckingExerciseType exercise) + { + controller.TempData[TempDataKey] = MessageFor(exercise); + return controller.RedirectToAction("Index", "CheckYourPupilData", new { windowId }); + } +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs index 833e7c468..3d77317e9 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs @@ -3,6 +3,9 @@ using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.Journey; using DfE.CheckPerformanceData.Application.RequestSubmission; +using DfE.CheckPerformanceData.Application.ResultsEnquiry; +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Web.Common; using DfE.CheckPerformanceData.Web.Controllers.Journey; using DfE.CheckPerformanceData.Web.Session; using Microsoft.AspNetCore.Mvc; @@ -17,6 +20,7 @@ public sealed class AmendmentRequestsController( IBulkSubmissionService bulkService, ICheckYourPupilDataService checkYourPupilDataService, IQuestionFlowService flowService, + ICheckingExerciseService checkingExercises, IJourneyViewModelBuilder viewModelBuilder) : Controller { private const string BulkSubmittedRefsKey = "BulkSubmittedRefs"; @@ -24,14 +28,18 @@ public sealed class AmendmentRequestsController( private async Task BuildIndexViewModelAsync(Guid windowId) { var result = await service.GetAmendmentRequestsAsync(windowId); - var deadline = result.WindowEndDate; // Re-check the boxes that were selected before going into the bulk review (kept in session). var selected = HttpContext.Session.GetBulkSelection(windowId).ToHashSet(StringComparer.Ordinal); return new AmendmentRequestsViewModel { WindowId = windowId, WindowTitle = result.WindowTitle, - DeadlineText = $"{deadline.ToString("htt").ToLower()} on {deadline:dddd d MMMM yyyy}", + Deadlines = result.Deadlines.Select(d => new ExerciseDeadlineViewModel + { + Exercise = d.Exercise, + EndDate = d.EndDate, + IsOpen = d.IsOpen + }).ToList(), Rows = result.Rows.Select(r => new AmendmentRequestRowViewModel { PupilName = r.PupilName, @@ -148,13 +156,18 @@ public async Task BulkConfirmation(Guid windowId) return RedirectToAction(nameof(Index), new { windowId }); var window = await checkYourPupilDataService.GetCheckingWindowAsync(windowId); - var deadline = window.EndDate; + // #320: the pupil-data exercise's end, never the outer window's. The banner below invites + // the school to request another amendment, and that journey shuts when pupil data shuts — + // on a 16-19 window the outer end is the results-enquiry close, months later. + var deadline = checkingExercises.EndDateFor(window.Exercises, CheckingExerciseType.PupilData); return View("BulkConfirmation", new BulkConfirmationViewModel { WindowId = windowId, ReferenceNumbers = references, - WindowCloseLabel = $"{deadline.ToString("htt").ToLower()} on {deadline:dddd d MMMM yyyy}" + WindowCloseLabel = deadline is null + ? null + : $"{deadline.Value.ToString("htt").ToLower()} on {deadline.Value:dddd d MMMM yyyy}" }); } @@ -173,6 +186,17 @@ public async Task Edit(Guid windowId, string referenceNumber, boo if (journey is null) return RedirectToAction(nameof(Index), "AmendmentRequests", new { windowId }); + // #318, product decision 2026-08-20: a draft saved while the exercise was open cannot be + // reopened once it has closed. Blocking the resume rather than the submit keeps the gate in + // one place and stops a user editing a request that could never be sent. The draft itself + // stays listed and readable on Amendment Requests — closed removes actions, never content. + if (journey.SelectedWhatToChange is { } draftChange && journey.CheckingWindow is not null) + { + var draftExercise = WhatToChangeCheckingExerciseMap.CheckingExerciseFor(draftChange); + if (!checkingExercises.IsOpen(journey.CheckingWindow.Exercises, draftExercise)) + return this.RedirectExerciseClosed(windowId, draftExercise); + } + HttpContext.Session.SetRequestState(windowId, journey); await analytics.TrackSafeAsync(new DraftResumedEvent diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs index 8f276a474..e576767af 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs @@ -1,4 +1,5 @@ using DfE.CheckPerformanceData.Domain.Enums; +using DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; using DfE.CheckPerformanceData.Web.Extensions; namespace DfE.CheckPerformanceData.Web.Controllers.AmendmentRequests; @@ -7,11 +8,38 @@ public sealed class AmendmentRequestsViewModel { public required Guid WindowId { get; init; } public required string WindowTitle { get; init; } - public required string DeadlineText { get; init; } + + /// + /// One sentence per checking exercise the window runs (#320). The grid is deliberately left + /// unsplit — both populations share one table and one bulk submit — but they do not share a + /// deadline, so each is stated. + /// + public required IReadOnlyList Deadlines { get; init; } + public required IReadOnlyList Rows { get; init; } public required IReadOnlyList SubmittedRows { get; init; } } +/// One exercise's deadline sentence on the amendment requests page. +public sealed class ExerciseDeadlineViewModel +{ + public required CheckingExerciseType Exercise { get; init; } + public required DateTime EndDate { get; init; } + public required bool IsOpen { get; init; } + + public string ExerciseLabel => ExerciseLabels.For(Exercise); + + // Checking-window dates are UK wall-clock values rather than UTC instants, so they are + // formatted as they stand and never routed through LondonTime. + public string DeadlineText => + $"{EndDate.ToString("htt").ToLowerInvariant()} on {EndDate:dddd d MMMM yyyy}"; + + /// Past tense once the exercise has closed, matching the check-your-pupil-data page. + public string Sentence => IsOpen + ? $"Submit your {ExerciseLabel.ToLowerInvariant()} requests by {DeadlineText}" + : $"The deadline for {ExerciseLabel.ToLowerInvariant()} requests passed at {DeadlineText}"; +} + public sealed class SubmittedRequestRowViewModel { public required string PupilName { get; init; } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/BulkConfirmationViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/BulkConfirmationViewModel.cs index 04ffa299e..e39d41174 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/BulkConfirmationViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/BulkConfirmationViewModel.cs @@ -4,5 +4,9 @@ public sealed class BulkConfirmationViewModel { public required Guid WindowId { get; init; } public required IReadOnlyList ReferenceNumbers { get; init; } - public required string WindowCloseLabel { get; init; } + /// + /// The pupil-data exercise's close, formatted for display. Null when the window runs no pupil + /// data checking — the "you still have until" banner is then dropped rather than left blank. + /// + public required string? WindowCloseLabel { get; init; } } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataController.cs index 628c68d70..df03750d4 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataController.cs @@ -2,6 +2,9 @@ using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Application.LandingPage; +// Aliased, not imported: WindowManagement also declares a CheckingWindowDto, which would make the +// LandingPage one ambiguous here. +using ICheckingExerciseService = DfE.CheckPerformanceData.Application.WindowManagement.ICheckingExerciseService; using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.Analytics; using DfE.CheckPerformanceData.Web.Session; @@ -9,8 +12,11 @@ namespace DfE.CheckPerformanceData.Web.Controllers.CheckYourPupilData; -public sealed class CheckYourPupilDataController(ICheckYourPupilDataService checkYourPupilDataService, TimeProvider timeProvider, - ICurrentUserService currentUserService, IAnalyticsService analytics) : Controller +// #317: this controller no longer holds a TimeProvider. Every "is it open" question on this page +// goes through ICheckingExerciseService, which owns the only clock in that path. +public sealed class CheckYourPupilDataController(ICheckYourPupilDataService checkYourPupilDataService, + ICurrentUserService currentUserService, IAnalyticsService analytics, + INextStepsService nextSteps, ICheckingExerciseService checkingExercises) : Controller { private const int PageSize = 10; private const int MaxSearchLength = 100; @@ -95,15 +101,14 @@ public async Task DownloadNonIncluded(Guid windowId) [Route("CheckYourPupilData/{windowId}/nextstep")] public async Task NextStep(Guid windowId, CheckYourPupilDataViewModel viewModel) { - // AB#296648: the results-enquiry option is 16-19 only, and the rule is re-derived from the - // window here rather than trusted from the post. Not rendering the radio is a UI courtesy; a - // hand-crafted post must not start a journey for a key stage with no results data and no flow - // config behind it, so it is rejected exactly as an unanswered question would be. + // #317: the allowed options are re-derived from the window's open exercises here rather + // than trusted from the post. Not rendering an option is a UI courtesy; a hand-crafted post + // must not start a journey for an exercise that is shut, or that this window does not run + // at all, so it is rejected exactly as an unanswered question would be. var window = await checkYourPupilDataService.GetCheckingWindowAsync(windowId); - var optionAllowed = viewModel.SelectedNextStep != NextSteps.ResultsEnquiry - || OffersResultsEnquiry(window.CheckingWindowType); + var allowed = nextSteps.GetAvailableSteps(window.Exercises); - if (viewModel.SelectedNextStep is null || !optionAllowed) + if (viewModel.SelectedNextStep is null || !allowed.Contains(viewModel.SelectedNextStep.Value)) { ModelState.AddModelError(nameof(CheckYourPupilDataViewModel.SelectedNextStep), "Select what you would like to do"); await analytics.TrackSafeAsync(new ValidationErrorEvent { ErrorCount = 1, ErrorCodes = [ValidationErrorCoding.NoSelection], ErrorFields = [nameof(CheckYourPupilDataViewModel.SelectedNextStep)] }); @@ -139,7 +144,6 @@ private async Task BuildIndexModelAsync( if (!string.IsNullOrEmpty(nonIncludedSearch)) await analytics.TrackSafeAsync(new PupilDataSearchResultsEvent { ResultCount = nonIncludedTotal, ActiveTab = "nonIncluded" }); - var now = timeProvider.GetLocalNow().DateTime; var journey = HttpContext.Session.GetRequestState(windowId); List sections = @@ -178,30 +182,21 @@ private async Task BuildIndexModelAsync( { SelectedNextStep = journey.SelectedNextStep, WindowId = windowId.ToString(), - WindowEndDate = window.EndDate.ToString("dddd d MMMM yyyy"), - WindowEndTime = window.EndDate.ToString("htt").ToLower(), WindowTitle = window.Title, Sections = sections, // 16-19 stacks both populations in one "Pupils" tab, because there the tab axis is // dataset (the other 16-19 import files become sibling tabs later), not inclusion. SectionsAsTabs = window.CheckingWindowType != CheckingWindowType.Post16, - IsWindowOpen = window.StartDate <= now && now <= window.EndDate, - ShowResultsEnquiryOption = OffersResultsEnquiry(window.CheckingWindowType), + // #317: the options are whatever the open exercises offer, for any number of exercises. + AvailableNextSteps = nextSteps.GetAvailableSteps(window.Exercises), + // The deadline sentence is about pupil data specifically, so it takes that exercise's + // own dates. On a multi-exercise window the outer EndDate is months later. + PupilDataEndDate = checkingExercises.EndDateFor(window.Exercises, CheckingExerciseType.PupilData), + IsPupilDataOpen = checkingExercises.IsOpen(window.Exercises, CheckingExerciseType.PupilData), OrganisationName = currentUserService.OrganisationName }; } - /// - /// Whether a window type has a results-enquiry journey. AB#296648: 16-19 only — the other key - /// stages have neither results data nor an IncorrectGrade_* flow config. - /// - /// PARKED: becomes an ICheckingExerciseService.OpenCheckingExercises check when the - /// checking-exercise model lands (docs/16-19-window-model.md) and results enquiry gets its own - /// dates. - /// - private static bool OffersResultsEnquiry(CheckingWindowType windowType) => - windowType == CheckingWindowType.Post16; - private static int TotalPages(int count) => (int)Math.Ceiling(count / (double)PageSize); } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataViewModel.cs index f8e85cbbf..016ae638d 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/CheckYourPupilDataViewModel.cs @@ -15,21 +15,36 @@ public sealed class CheckYourPupilDataViewModel /// public required bool SectionsAsTabs { get; init; } - public required string WindowEndDate { get; init; } - public required string WindowEndTime { get; init; } public required string WindowTitle { get; init; } public NextSteps? SelectedNextStep { get; init; } - public required bool IsWindowOpen { get; init; } /// - /// AB#296648: whether to offer "Report an issue with an exam result" alongside the amend/confirm - /// options. 16-19 only for now — no other key stage has results data or a flow config behind it. + /// Next-step options for the exercises open right now (#317), from + /// . Empty means render no form at all — the tables, the search + /// and the downloads stay, because a closed exercise removes actions, never content. /// - /// PARKED: visibility moves to ICheckingExerciseService.OpenCheckingExercises when the - /// checking-exercise model lands (docs/16-19-window-model.md), at which point results enquiry - /// gets its own dates and this stops being a straight window-type test. Not defaulted from the - /// request — the POST re-derives it so a hand-crafted post cannot bypass the rule. + /// Never defaulted from the request: the POST re-derives this so a hand-crafted post cannot + /// start a journey for an exercise that is shut. /// - public bool ShowResultsEnquiryOption { get; init; } + public required IReadOnlyList AvailableNextSteps { get; init; } + + /// + /// The pupil-data exercise's own end date, from EndDateFor(PupilData) — never the outer + /// window's, which on a multi-exercise window is months later and would promise a deadline the + /// school does not have. Null when the window has no pupil-data exercise, in which case there + /// is no deadline sentence to show. + /// + /// Like every checking-window date this is a UK wall-clock value, not a UTC instant, so it is + /// formatted as-is and never routed through LondonTime. + /// + public DateTime? PupilDataEndDate { get; init; } + + /// + /// Whether the pupil-data exercise is open, from ICheckingExerciseService.IsOpen. Drives + /// the tense of the deadline sentence. The comparison against the clock belongs to that service + /// alone, so the view must not re-derive this from . + /// + public bool IsPupilDataOpen { get; init; } + public required string OrganisationName { get; init; } } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/NextStepLabels.cs b/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/NextStepLabels.cs new file mode 100644 index 000000000..a0d0d2e20 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/CheckYourPupilData/NextStepLabels.cs @@ -0,0 +1,22 @@ +using DfE.CheckPerformanceData.Application.CheckYourPupilData; + +namespace DfE.CheckPerformanceData.Web.Controllers.CheckYourPupilData; + +/// +/// The on-screen wording for each next-step option. Presentation copy, so it lives in Web — which +/// exercise offers which option is the Application layer's business (). +/// +/// +/// The label doubles as a button caption when only one option survives, so it is written as an +/// instruction the user can act on rather than as a noun phrase. +/// +public static class NextStepLabels +{ + public static string For(NextSteps step) => step switch + { + NextSteps.RequestChange => "Request an amendment to pupil data", + NextSteps.Confirm => "Confirm pupil data is correct", + NextSteps.ResultsEnquiry => "Report an issue with an exam result", + _ => step.ToString() + }; +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs index d9317cc8c..6b53fe418 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs @@ -3,6 +3,9 @@ using DfE.CheckPerformanceData.Application.Journey; using DfE.CheckPerformanceData.Application.Notify; using DfE.CheckPerformanceData.Application.RequestSubmission; +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; +using DfE.CheckPerformanceData.Web.Common; using DfE.CheckPerformanceData.Web.Controllers.ViewModels; using Microsoft.AspNetCore.Mvc; @@ -13,12 +16,20 @@ public sealed class ConfirmCorrectController( ICheckYourPupilDataService service, IJourneyValidationService journeyService, IRequestService requestService, + ICheckingExerciseService checkingExercises, IAnalyticsService analytics) : Controller { + // #318: confirming the data is correct is a pupil-data-checking action, so it closes with that + // exercise even while the outer window (and any other exercise on it) is still running. + private const CheckingExerciseType Exercise = CheckingExerciseType.PupilData; + [HttpGet] public async Task Index(Guid windowId) { var window = await service.GetCheckingWindowAsync(windowId); + if (!checkingExercises.IsOpen(window.Exercises, Exercise)) + return this.RedirectExerciseClosed(windowId, Exercise); + var confirmVw = new ConfirmCorrectViewModel(windowId, window.EndDate.ToString("htt 'on' dddd d MMMM")); return View(confirmVw); } @@ -27,6 +38,9 @@ public async Task Index(Guid windowId) public async Task Confirm(Guid windowId) { var window = await service.GetCheckingWindowAsync(windowId); + if (!checkingExercises.IsOpen(window.Exercises, Exercise)) + return this.RedirectExerciseClosed(windowId, Exercise); + var reference = journeyService.GenerateReference(window.CheckingWindowType); await requestService.ConfirmDataCorrectAsync(windowId, reference, window.EndDate, EmailSubstitutions.From(window)); diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs b/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs index c88fda086..d570ba465 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs @@ -79,7 +79,8 @@ public async Task SubmitAsync( IPupilRecord? matchedPupil = null; if (windowId is not null && laestab is not null) { - var pupils = await _pupilBlob.GetPupilsAsync(resolvedWindowId, laestab, CheckingWindowType.KS4June); + var pupils = await _pupilBlob.GetPupilsAsync( + resolvedWindowId, CheckingExerciseType.PupilData, laestab, CheckingWindowType.KS4June); if (pupils is not null) { if (pupilUpn is not null) diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs index a62e1969d..29212e7ca 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs @@ -8,6 +8,7 @@ using DfE.CheckPerformanceData.Application.Notify; using DfE.CheckPerformanceData.Application.RequestSubmission; using DfE.CheckPerformanceData.Application.ResultsEnquiry; +using DfE.CheckPerformanceData.Application.WindowManagement; using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.FileStorage; using DfE.CheckPerformanceData.Web.Session; @@ -30,6 +31,7 @@ public sealed class JourneyController( IStudentResultsClient studentResultsClient, IGradeReferenceClient gradeReferenceClient, IRequestNotificationService requestNotificationService, + ICheckingExerciseService checkingExerciseService, ILogger logger) : Controller { internal static string FieldName(string questionId) => $"q_{questionId.Replace("-", "_")}"; @@ -161,7 +163,7 @@ await analytics.TrackSafeAsync(new ValidationErrorEvent // a results enquiry and a pupil-data amendment may legitimately coexist for the same pupil. var isResultsEnquiry = journey.SelectedWhatToChange is { } whatToChange && WhatToChangeCheckingExerciseMap.CheckingExerciseFor(whatToChange) - == WhatToChangeCheckingExerciseMap.ResultsEnquiry; + == CheckingExerciseType.ResultsEnquiry; if (page.PupilKey != JourneyPage.MatchKey && !isResultsEnquiry) { @@ -884,7 +886,9 @@ public async Task DownloadEvidence(Guid windowId, string storedFi if (!Guid.TryParse(storedFileName, out _)) return NotFound(); var journey = HttpContext.Session.GetRequestState(windowId); - if (!IsSessionReady(journey)) return NotFound(); + // #318 AC: no gated path returns 404. The link is an ordinary browser navigation, so the + // redirect renders the explanation like every other rejected entry point. + if (!IsSessionReady(journey)) return RedirectToCheckYourData(windowId); var fileAnswer = journey.QuestionAnswers.Values .SelectMany(a => a.FileValues ?? []) @@ -1140,12 +1144,43 @@ public IActionResult Confirmation(Guid windowId) // ── Private helpers ──────────────────────────────────────────────────── - private static bool IsSessionReady(RequestState journey) => + /// + /// #318: the one gate every journey action already runs. It now also requires the journey's + /// own checking exercise to be open, so a bookmarked URL or a tab left open across the closing + /// date cannot post into a shut journey. The exercise is derived from + /// rather than stored: a stored copy can + /// disagree with the journey's own change type, and adding an exercise type never has to touch + /// this method again. + /// + private bool IsSessionReady(RequestState journey) => journey.SelectedWhatToChange is not null && - journey.CheckingWindow is not null; + journey.CheckingWindow is not null && + !IsExerciseClosed(journey); + + // True only when the journey is otherwise complete and its exercise has closed — the one + // rejection reason worth explaining to the user. + private bool IsExerciseClosed(RequestState journey) => + journey.SelectedWhatToChange is { } change && + journey.CheckingWindow is not null && + !checkingExerciseService.IsOpen( + journey.CheckingWindow.Exercises, + WhatToChangeCheckingExerciseMap.CheckingExerciseFor(change)); - private RedirectToActionResult RedirectToCheckYourData(Guid windowId) => - RedirectToAction("Index", "CheckYourPupilData", new { windowId }); + /// + /// Every bounce out of the journey. A closed exercise is explained on the page the user lands + /// on; the other reasons (no session, no flow config) are silent, because a session that was + /// never started has nothing to tell the user. + /// + private RedirectToActionResult RedirectToCheckYourData(Guid windowId) + { + var journey = HttpContext.Session.GetRequestState(windowId); + if (IsExerciseClosed(journey)) + return this.RedirectExerciseClosed( + windowId, + WhatToChangeCheckingExerciseMap.CheckingExerciseFor(journey.SelectedWhatToChange!.Value)); + + return RedirectToAction("Index", "CheckYourPupilData", new { windowId }); + } private RedirectToActionResult RedirectToJourneyAction(QuestionFlowConfig config, Guid windowId, string pageId) { diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs index 7420da247..e71a5dbcf 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs @@ -235,6 +235,7 @@ public PupilSearchViewModel BuildPupilSearchVm( PageId = pageId, Title = title, Filter = page.PupilFilter ?? PupilFilter.Included, + RequireResults = page.RequireResults, ExcludePupilId = excludeId, SelectedPupilId = existingId, SelectedPupilLabel = existingLabel, diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs index 7fe18ff54..85802dc3b 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs @@ -9,6 +9,10 @@ public sealed class PupilSearchViewModel public string Title { get; set; } = string.Empty; public PupilFilter Filter { get; set; } public Guid? ExcludePupilId { get; set; } + + /// Ask the suggestions endpoint for students who hold results only. See + /// . + public bool RequireResults { get; set; } public string? SelectedPupilId { get; set; } public string? SelectedPupilLabel { get; set; } public string? Hint { get; set; } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/PupilSuggestionsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/PupilSuggestionsController.cs index 39615d434..f0f47c226 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/PupilSuggestionsController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/PupilSuggestionsController.cs @@ -7,12 +7,16 @@ namespace DfE.CheckPerformanceData.Web.Controllers; public sealed class PupilSuggestionsController(ICheckYourPupilDataService service) : Controller { [Route("/pupils/suggestions")] - public async Task Suggestions(Guid windowId, string? query, PupilFilter filter, Guid? excludePupilId) + // requireResults is set by the PupilSearch page when its flow config asks for it, and limits + // the search to students the school holds a result for. It is a search restriction only — + // never a permission — so a caller that omits or forges it can still reach no pupil the + // signed-in school's own file does not already contain. + public async Task Suggestions(Guid windowId, string? query, PupilFilter filter, Guid? excludePupilId, bool requireResults = false) { if (string.IsNullOrWhiteSpace(query) || query.Length < 2 || query.Length > 100) return Json(Array.Empty()); - var suggestions = await service.GetPupilSuggestionsAsync(windowId, query, filter, excludePupilId); + var suggestions = await service.GetPupilSuggestionsAsync(windowId, query, filter, excludePupilId, requireResults); return Json(suggestions.Select(s => new { id = s.Id, label = s.Label })); } } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ResultIssue/ResultIssueController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ResultIssue/ResultIssueController.cs index 6bce014e5..a88e72622 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ResultIssue/ResultIssueController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ResultIssue/ResultIssueController.cs @@ -3,7 +3,10 @@ using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Application.Journey; using DfE.CheckPerformanceData.Application.ResultsEnquiry; +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.Analytics; +using DfE.CheckPerformanceData.Web.Common; using DfE.CheckPerformanceData.Web.Session; using Microsoft.AspNetCore.Mvc; @@ -18,8 +21,14 @@ public sealed class ResultIssueController( IQuestionFlowService flowService, ILateResultsAvailability lateResults, ICurrentUserService currentUser, + ICheckingExerciseService checkingExercises, IAnalyticsService analytics) : Controller { + // #318: this is the results-enquiry entry point, so it gates on that exercise rather than on + // the outer window. A 16-19 window runs results enquiry on its own dates, and was previously + // reachable here for as long as the window itself was open. + private const CheckingExerciseType Exercise = CheckingExerciseType.ResultsEnquiry; + /// /// Entered when the school does not yet hold a second-late-results row. Deliberately NOT the /// flow's firstPageId — whether the guidance is shown depends on the school's data at this @@ -30,16 +39,26 @@ public sealed class ResultIssueController( private const string SelectionRequired = "Select what issue with the results you need to report"; [Route("/{windowId:guid}/ResultIssue")] - public IActionResult Index(Guid windowId) + public async Task Index(Guid windowId) + { + var window = await service.GetCheckingWindowAsync(windowId); + if (!checkingExercises.IsOpen(window.Exercises, Exercise)) + return this.RedirectExerciseClosed(windowId, Exercise); + // Never pre-selects: the confirmation page's "Report another issue" link lands here, and the // AC is that nothing carries over from the previous enquiry. - => View(new ResultIssueViewModel { WindowId = windowId }); + return View(new ResultIssueViewModel { WindowId = windowId }); + } [HttpPost] [ValidateAntiForgeryToken] [Route("/{windowId:guid}/ResultIssue")] public async Task Confirm(Guid windowId, ResultIssueViewModel vm, CancellationToken ct = default) { + var window = await service.GetCheckingWindowAsync(windowId); + if (!checkingExercises.IsOpen(window.Exercises, Exercise)) + return this.RedirectExerciseClosed(windowId, Exercise); + // Fail closed on anything that is not the one option this ticket renders, so a forged or // sibling-ticket value cannot start a journey with no flow behind it. if (vm.IssueType != ResultIssueViewModel.IncorrectGrade) @@ -54,8 +73,6 @@ await analytics.TrackSafeAsync(new ValidationErrorEvent return View("Index", new ResultIssueViewModel { WindowId = windowId }); } - var window = await service.GetCheckingWindowAsync(windowId); - var config = await flowService.GetConfigAsync( Application.CheckYourPupilData.WhatToChange.IncorrectGrade, window.CheckingWindowType); if (config is null) diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/IngressFolderBrowseViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/IngressFolderBrowseViewModel.cs index 5fe6cdcae..061611dcd 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/IngressFolderBrowseViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/IngressFolderBrowseViewModel.cs @@ -1,3 +1,5 @@ +using DfE.CheckPerformanceData.Domain.Enums; + namespace DfE.CheckPerformanceData.Web.Controllers.ViewModels; public class IngressFolderBrowseViewModel @@ -28,4 +30,13 @@ public class IngressFolderBrowseViewModel /// Human label for the page heading, e.g. "Included pupils". public string DatasetLabel { get; init; } = "Pupils"; + + /// + /// The checking exercise that consumes this dataset (#319). Part of every link on the page, + /// because a dataset name is only unique within one exercise. + /// + public CheckingExerciseType Exercise { get; init; } + + /// Route prefix shared by every link and the form action on this page. + public string BaseUrl => $"/admin/windows/{WindowId}/{Exercise}/ingress-file/{Dataset}"; } \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ExerciseDatesItem.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ExerciseDatesItem.cs new file mode 100644 index 000000000..53d0e59b9 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ExerciseDatesItem.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; + +/// +/// One checking exercise's own date range (#319). Both ends are on a single page: a window with one +/// exercise is then one date page rather than the two the window-level steps used to take, which is +/// what keeps a single-exercise window no harder to create than it was. +/// +/// +/// The window's own StartDate/EndDate is derived from these as the union and is never typed, so +/// there is no window-level date step for these to disagree with. +/// +public sealed class ExerciseDatesItem : AdminPage +{ + public CheckingExerciseType ExerciseType { get; set; } + + /// Human label for the heading, e.g. "Pupil data checking". + public string ExerciseLabel { get; set; } = string.Empty; + + [Required(ErrorMessage = "Start date can not be empty")] + public DateTime? StartDate { get; set; } + + [Range(0, 23, ErrorMessage = "Start hour must be between 0 and 23")] + public int StartHour { get; set; } + + [Range(0, 59, ErrorMessage = "Start minute must be between 0 and 59")] + public int StartMinute { get; set; } + + [Required(ErrorMessage = "End date can not be empty")] + public DateTime? EndDate { get; set; } + + [Range(0, 23, ErrorMessage = "End hour must be between 0 and 23")] + public int EndHour { get; set; } + + [Range(0, 59, ErrorMessage = "End minute must be between 0 and 59")] + public int EndMinute { get; set; } + + public DateTime? StartDateTime => StartDate?.Date.AddHours(StartHour).AddMinutes(StartMinute); + public DateTime? EndDateTime => EndDate?.Date.AddHours(EndHour).AddMinutes(EndMinute); + + /// New exercises open at midnight and close at 17:00; the admin can change both. + public const int DefaultStartHour = 0; + public const int DefaultEndHour = 17; +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ExercisesItem.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ExercisesItem.cs new file mode 100644 index 000000000..86505eb22 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ExercisesItem.cs @@ -0,0 +1,23 @@ +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; + +/// +/// The "which checking exercises does this window run?" step (#319). Every +/// is offered, pre-ticked from the window type's defaults, so a +/// new member of the enum appears here without this page being touched. +/// +public sealed class ExercisesItem : AdminPage +{ + /// Every exercise type, in display order. + public IReadOnlyList All { get; set; } = []; + + /// The ticked ones. Bound from the checkboxes on post. + public List Selected { get; set; } = []; + + /// + /// Exercises that already hold ingress files. Unticking one throws those files away, so the + /// page warns rather than doing it silently. + /// + public IReadOnlyList WithFiles { get; set; } = []; +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ValidationViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ValidationViewModel.cs index ea6ed89c2..57d8066c4 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ValidationViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/ValidationViewModel.cs @@ -6,6 +6,9 @@ public class ValidationViewModel : AdminPage { public string? StreamUrl { get; set; } + /// Which checking exercise this run belongs to, e.g. "Pupil data checking" (#319). + public string ExerciseLabel { get; set; } = string.Empty; + public ProcessingResult? ProcessingResult { get; set; } private bool ValidateOnly { get; set; } = true; diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs index c72cdaf41..87be0ba2e 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs @@ -30,34 +30,61 @@ public string TurnaroundCommitmentLink get => $"{BaseEditUrl}/turnaround-commitment"; } public bool IsOpen { get; set; } = false; + // #319: derived from the exercises as their union, so there is no Change link — the outer pair + // is never typed. To move a window's dates, move an exercise's. public required DateTime StartDate { get; set; } - public string StartDateLink { - get => $"{BaseEditUrl}/start-date"; - } public required DateTime EndDate { get; set; } - public string EndDateLink { - get => $"{BaseEditUrl}/end-date"; - } public required KeyStages KeyStage { get; set; } public required CheckingWindowType CheckingWindowType { get; set; } public string CheckingWindowTypeLink { get => $"{BaseEditUrl}/checking-window-type"; } - //data - /// One row pair per ingress dataset. A Post16 window has two (included + - /// non-included); every other type has one. - public IReadOnlyList Datasets { get; set; } = []; + + /// + /// One section per checking exercise (#319). Each carries its own dates, its own ingress and + /// schema files, and its own validation state — a window is no longer validated as a whole. + /// + public IReadOnlyList Exercises { get; set; } = []; + + public string ExercisesLink => $"{BaseEditUrl}/exercises"; public string? OutputPath { get; set; } - public bool ValidationSuccess { get; set; } = false; - public DateTime? ValidationDate { get; set; } public bool IsPublished { get; set; } = false; public Guid? PublishedId { get; set; } - - // Every dataset must have both files — a Post16 window is not validatable until both the - // included and non-included CSV/schema pairs are chosen, because they ingest in one run. - private bool HasRequiredFiles => Datasets.Count > 0 && Datasets.All(d => d.IsComplete); +} + +/// One checking exercise on the window summary page. +public sealed class ExerciseSummarySection +{ + public required Guid WindowId { get; init; } + public required CheckingExerciseType ExerciseType { get; init; } + public required string Label { get; init; } + public required DateTime StartDate { get; init; } + public required DateTime EndDate { get; init; } + + /// One row pair per ingress dataset. A Post16 pupil-data exercise has two (included + + /// non-included); every other type has one. An exercise with no ingress step yet has none. + public IReadOnlyList Datasets { get; init; } = []; + + /// Validated, against the files it currently holds. + public bool IsValidated { get; init; } + + public DateTime? ValidatedAt { get; init; } + + /// Validated once, but not against the files it holds now — a stale stamp. + public bool IsStale { get; init; } + + public string DatesLink => $"/admin/windows/{WindowId}/exercises/{ExerciseType}/dates"; + public string ValidateLink => $"/admin/windows/{WindowId}/{ExerciseType}/validate"; + + // Every REQUIRED dataset must have both files — a Post16 pupil-data exercise is not validatable + // until both the included and non-included CSV/schema pairs are chosen, because they ingest in + // one run. An exercise with no complete dataset at all has nothing to validate. Optional slots + // (#324) may be empty: a results file that has not been delivered yet must not hold up the ones + // that have. + private bool HasRequiredFiles => + Datasets.Any(d => d.IsComplete) && Datasets.Where(d => d.Required).All(d => d.IsComplete); private bool HasValidDates { @@ -65,26 +92,27 @@ private bool HasValidDates { var today = DateTime.UtcNow.Date; - return StartDate.Date >= today - && EndDate.Date >= today - && EndDate.Date >= StartDate.Date; + return EndDate.Date >= today && EndDate.Date >= StartDate.Date; } } public bool IsValidatable => HasValidDates && HasRequiredFiles; - } public sealed class DatasetSummaryRow { public required Guid WindowId { get; init; } + public required CheckingExerciseType Exercise { get; init; } public required string Name { get; init; } public required string Label { get; init; } public string? IngressFile { get; init; } public string? SchemaFile { get; init; } - public string IngressFileLink => $"/admin/windows/{WindowId}/ingress-file/{Name}"; - public string SchemaFileLink => $"/admin/windows/{WindowId}/schema-file/{Name}"; + /// The exercise cannot be validated until this slot holds both files (#324). + public bool Required { get; init; } = true; + + public string IngressFileLink => $"/admin/windows/{WindowId}/{Exercise}/ingress-file/{Name}"; + public string SchemaFileLink => $"/admin/windows/{WindowId}/{Exercise}/schema-file/{Name}"; public bool IsComplete => !string.IsNullOrWhiteSpace(IngressFile) && !string.IsNullOrWhiteSpace(SchemaFile); diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WhatToChangeController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WhatToChangeController.cs index 707557a84..6890d7c7c 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WhatToChangeController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WhatToChangeController.cs @@ -1,7 +1,10 @@ using DfE.CheckPerformanceData.Application.Analytics; using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.Journey; +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.Analytics; +using DfE.CheckPerformanceData.Web.Common; using DfE.CheckPerformanceData.Web.Session; using Microsoft.AspNetCore.Mvc; @@ -10,11 +13,21 @@ namespace DfE.CheckPerformanceData.Web.Controllers; public sealed class WhatToChangeController( ICheckYourPupilDataService service, IQuestionFlowService flowService, + ICheckingExerciseService checkingExercises, IAnalyticsService analytics) : Controller { + // #318: every WhatToChange option belongs to pupil data checking, so both actions gate on that + // one exercise. The option list on Check your pupil data is presentation only — a bookmarked + // URL still reaches here after the exercise closes. + private const CheckingExerciseType Exercise = CheckingExerciseType.PupilData; + [Route("/WhatToChange/{windowId}")] - public IActionResult Index(Guid windowId) + public async Task Index(Guid windowId) { + var window = await service.GetCheckingWindowAsync(windowId); + if (!checkingExercises.IsOpen(window.Exercises, Exercise)) + return this.RedirectExerciseClosed(windowId, Exercise); + var journey = HttpContext.Session.GetRequestState(windowId); return View(new WhatToChangeViewModel { @@ -28,6 +41,10 @@ public IActionResult Index(Guid windowId) [Route("/WhatToChange/{windowId}")] public async Task Confirm(Guid windowId, WhatToChangeViewModel vm) { + var window = await service.GetCheckingWindowAsync(windowId); + if (!checkingExercises.IsOpen(window.Exercises, Exercise)) + return this.RedirectExerciseClosed(windowId, Exercise); + if (vm.SelectedWhatToChange == null) { ModelState.AddModelError(nameof(WhatToChangeViewModel.SelectedWhatToChange), "Select what pupil data you would like to change"); @@ -35,8 +52,6 @@ public async Task Confirm(Guid windowId, WhatToChangeViewModel vm return View("Index", new WhatToChangeViewModel { WindowId = windowId, SelectedWhatToChange = null }); } - var window = await service.GetCheckingWindowAsync(windowId); - HttpContext.Session.SaveRequestState(windowId, s => { s.SelectedWhatToChange = vm.SelectedWhatToChange; diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CheckingWindowDraft.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CheckingWindowDraft.cs index 9a2587884..69d3f5143 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CheckingWindowDraft.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CheckingWindowDraft.cs @@ -1,3 +1,4 @@ +using DfE.CheckPerformanceData.Application.WindowManagement; using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; using Microsoft.AspNetCore.Mvc; @@ -5,45 +6,94 @@ namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; public sealed class CheckingWindowDraft : AdminPage -{ +{ public string? Title { get; set; } public string TitleLink(IUrlHelper url) => url.Action("New", "Title")!; - public DateTime? StartDate { get; set; } - public string StartDateLink(IUrlHelper url) => url.Action("New", "StartDate")!; - public DateTime? EndDate { get; set; } - public string EndDateLink(IUrlHelper url) => url.Action("New", "EndDate")!; public CheckingWindowType? CheckingWindowType { get; set; } public string CheckingWindowTypeLink(IUrlHelper url) => url.Action("New", "WindowType")!; public KeyStages? KeyStage { get; set; } public string KeyStageLink(IUrlHelper url) => url.Action("New", "KeyStage")!; + /// + /// The exercises this window will run, each with its own dates (#319). The window's own + /// StartDate/EndDate is derived from these — the wizard has no window-level date step, so the + /// outer pair and the exercises can never disagree. + /// + public List Exercises { get; set; } = []; + + public string ExercisesLink(IUrlHelper url) => url.Action("New", "Exercises")!; + + /// Earliest exercise start. Null until at least one exercise has its dates. + public DateTime? StartDate => + Exercises.All(e => e.StartDate.HasValue) && Exercises.Count > 0 + ? Exercises.Min(e => e.StartDate!.Value) + : null; + + /// Latest exercise end. Null until at least one exercise has its dates. + public DateTime? EndDate => + Exercises.All(e => e.EndDate.HasValue) && Exercises.Count > 0 + ? Exercises.Max(e => e.EndDate!.Value) + : null; + + /// The first exercise still missing its dates, or null when all are complete. + public ExerciseDraft? FirstUndatedExercise => + Exercises.OrderBy(e => e.SortOrder).FirstOrDefault(e => !e.IsDated); + public bool IsValid { get { - if (IsEmpty || StartDate < DateTime.UtcNow.Date || EndDate < StartDate) + if (IsEmpty || Exercises.Count == 0 || FirstUndatedExercise is not null) return false; - return true; + // Each exercise must be a sane range in its own right. The outer pair is their union, + // so checking the parts is what makes the whole right. + return Exercises.All(e => + e.StartDate!.Value >= DateTime.UtcNow.Date && e.EndDate!.Value >= e.StartDate!.Value); } } - - public bool IsEmpty => - Title == null && !StartDate.HasValue && !EndDate.HasValue && !CheckingWindowType.HasValue && !KeyStage.HasValue; - - public string NextController(IUrlHelper url) => ( - Title is null, - !StartDate.HasValue, - !EndDate.HasValue, - !CheckingWindowType.HasValue, - !KeyStage.HasValue - ) switch - { - (true, _, _, _, _) => url.Action("New", "Title")!, - (false, true, _, _, _) => url.Action("New", "StartDate")!, - (false, _, true, _, _) => url.Action("New", "EndDate")!, - (false, _, _, true, _) => url.Action("New", "WindowType")!, - (false, _, _, _, true) => url.Action("New", "KeyStage")!, - _ => url.Action("New", "CreateCheckingWindow")! - }; + + public bool IsEmpty => + Title == null && !CheckingWindowType.HasValue && !KeyStage.HasValue && Exercises.Count == 0; + + /// + /// The next unanswered step. The exercise step comes after the window type, because the type + /// decides which exercises start ticked; the per-exercise date pages then follow one at a time. + /// + public string NextController(IUrlHelper url) + { + if (Title is null) return url.Action("New", "Title")!; + if (!CheckingWindowType.HasValue) return url.Action("New", "WindowType")!; + if (!KeyStage.HasValue) return url.Action("New", "KeyStage")!; + if (Exercises.Count == 0) return url.Action("New", "Exercises")!; + + ExerciseDraft? undated = FirstUndatedExercise; + return undated is null + ? url.Action("New", "CreateCheckingWindow")! + : url.Action("New", "ExerciseDates", new { exercise = undated.ExerciseType })!; + } + + /// The draft's exercises as DTOs, ready for . + public List ToExerciseDtos() => + Exercises + .OrderBy(e => e.SortOrder) + .Select(e => new CheckingExerciseDto + { + ExerciseType = e.ExerciseType, + StartDate = e.StartDate!.Value, + EndDate = e.EndDate!.Value, + SortOrder = e.SortOrder + }) + .ToList(); +} + +/// One ticked checking exercise and its dates, while the window is still a draft. +public sealed class ExerciseDraft +{ + public CheckingExerciseType ExerciseType { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int SortOrder { get; set; } + + public bool IsDated => StartDate.HasValue && EndDate.HasValue; } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CreateCheckingWindowController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CreateCheckingWindowController.cs index 03d9009e9..87ed1ac02 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CreateCheckingWindowController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/CreateCheckingWindowController.cs @@ -42,28 +42,40 @@ public async Task Post(CancellationToken cancellationToken) return BadRequest("Invalid data"); } - CheckingWindowDto checkingWindowDto = new CheckingWindowDto() + CheckingWindowDto checkingWindowDto = new CheckingWindowDto() { Title = draft.Title!, + // Derived from the exercises, never typed by the admin (#319). CreateAsync re-derives + // them anyway; they are set here because the DTO requires them. StartDate = draft.StartDate!.Value, EndDate = draft.EndDate!.Value, CheckingWindowType = draft.CheckingWindowType!.Value, KeyStage = draft.KeyStage!.Value, + Exercises = draft.ToExerciseDtos() }; CheckingWindowDto window = await windowService.CreateAsync(checkingWindowDto, cancellationToken); - - CreateWindowContainer(window.Id.ToString()); + + if (!CreateWindowContainer(window.Id.ToString())) + { + return Problem("App storage is not configured."); + } + return RedirectToAction("Index", "Summary", new { id = window.Id }); } - private void CreateWindowContainer(string id) + // False when there is no app storage client to create the window's blob container with. The + // caller surfaces that rather than carrying on: every later step of the wizard writes into + // that container, so a window without one is not usable. + private bool CreateWindowContainer(string id) { - if (!blobClients.TryGetValue("app", out var appBlobClient)) + if (!blobClients.TryGetValue("app", out BlobServiceClient? appBlobClient)) { logger.LogWarning("App storage client is not configured"); - Problem("App storage is not configured."); + return false; } + appBlobClient.CreateBlobContainer(id); + return true; } } \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/DatasetLabels.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/DatasetLabels.cs index a2d7475db..bae0a25f8 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/DatasetLabels.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/DatasetLabels.cs @@ -1,14 +1,30 @@ +using DfE.CheckPerformanceData.Application.ResultsEnquiry; using DfE.CheckPerformanceData.Application.WindowManagement; namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; /// Display names for the window dataset slots shown in the admin wizard. +/// +/// A results dataset is named by the tag it stamps (#324), and the tag is the supplier's own file +/// name — so it is shown as well as the plain-English label. An admin matching six delivered files +/// to six upload slots needs the supplier's name to do it, and a label alone would leave them +/// guessing which of three late-results files is which. +/// public static class DatasetLabels { public static string For(string datasetName) => datasetName switch { WindowDatasets.Included => "Included pupils", WindowDatasets.NonIncluded => "Non-included pupils", + ResultsFileTags.Post16Main => $"Main results ({ResultsFileTags.Post16Main})", + ResultsFileTags.Post16LateResults1 => $"Late results 1 ({ResultsFileTags.Post16LateResults1})", + ResultsFileTags.Post16LateResults2 => $"Late results 2 ({ResultsFileTags.Post16LateResults2})", + ResultsFileTags.Post16Revised => $"Revised results ({ResultsFileTags.Post16Revised})", + ResultsFileTags.Post16Retention => $"Retention ({ResultsFileTags.Post16Retention})", + ResultsFileTags.Ks4Main => $"Main results ({ResultsFileTags.Ks4Main})", + ResultsFileTags.Ks4LateResults1 => $"Late results 1 ({ResultsFileTags.Ks4LateResults1})", + ResultsFileTags.Ks4LateResults2 => $"Late results 2 ({ResultsFileTags.Ks4LateResults2})", + ResultsFileTags.Ks4Revised => $"Revised results ({ResultsFileTags.Ks4Revised})", _ => "Pupils" }; } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/EndDateController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/EndDateController.cs deleted file mode 100644 index 0c8336949..000000000 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/EndDateController.cs +++ /dev/null @@ -1,128 +0,0 @@ -using DfE.CheckPerformanceData.Application.WindowManagement; -using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; -using DfE.CheckPerformanceData.Web.Extensions; -using Microsoft.AspNetCore.Mvc; - -namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; - -public sealed class EndDateController(IWindowService windowService): Controller -{ - private const string PageView = "~/Views/WindowAdmin/EndDate.cshtml"; - - [HttpGet("admin/windows/end-date")] - public async Task New(CancellationToken cancellationToken) - { - CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); - - if (draft == null) - { - return BadRequest("No draft data"); - } - - WindowDateEditItem model = new WindowDateEditItem() - { - WindowId = Guid.Empty, - DateValue = draft.EndDate, - // New windows default to closing at 17:00; the admin can change it. - Hour = draft.EndDate?.Hour ?? DefaultEndHour, - Minute = draft.EndDate?.Minute ?? 0, - PostUrl = Url.Action("Submit", "EndDate"), - CancelUrl = Url.Action("Index", "CancelCreation") - }; - - return View(PageView, model); - } - - [HttpGet("admin/windows/{id:guid}/end-date")] - public async Task Edit(Guid id, CancellationToken cancellationToken) - { - CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); - - if (window is null) - { - return NotFound(); - } - - WindowDateEditItem model = new WindowDateEditItem() - { - WindowId = window.Id, - DateValue = window.EndDate, - Hour = window.EndDate.Hour, - Minute = window.EndDate.Minute, - PostUrl = Url.Action("Update", "EndDate", new { id = window.Id}), - CancelUrl = Url.Action("Index", "Summary", new { id = window.Id}) - }; - - return View(PageView, model); - } - - [HttpPost("admin/windows/end-date")] - [ValidateAntiForgeryToken] - public async Task Submit(WindowDateEditItem model, CancellationToken cancellationToken) - { - CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); - - if (draft == null) - { - return BadRequest("No draft data"); - } - - if (ModelState.IsValid) - { - DateValidation(model.DateTimeValue, null); - } - - if (!ModelState.IsValid) - { - return View(PageView, model); - } - - draft.EndDate = model.DateTimeValue; - HttpContext.Session.SetObject("CheckingWindowDraft", draft); - - return Redirect(draft.NextController(Url)); - } - - [HttpPost("admin/windows/{id:guid}/end-date")] - [ValidateAntiForgeryToken] - public async Task Update(Guid id, WindowDateEditItem model, CancellationToken cancellationToken) - { - CheckingWindowDto window = await windowService.GetByIdAsync(id, cancellationToken); - - if (ModelState.IsValid) - { - DateValidation(model.DateTimeValue, window); - } - - if (!ModelState.IsValid) - { - return View(PageView, model); - } - - if (id != model.WindowId) - { - return BadRequest(); - } - - window.EndDate = model.DateTimeValue!.Value; - await windowService.UpdateAsync(window, cancellationToken); - - return RedirectToAction("Index", "Summary", new { id }); - } - - public void DateValidation(DateTime? value, CheckingWindowDto? windowDto) - { - if (value < DateTime.UtcNow.Date) - { - ModelState.AddModelError(nameof(WindowDateEditItem.DateValue), "End date can not occur in the past."); - } - - if (windowDto != null && value < windowDto.StartDate) - { - ModelState.AddModelError(nameof(WindowDateEditItem.DateValue), - $"End date can not occur before the start date ({windowDto.StartDate:dd MM yyyy HH:mm})."); - } - } - - private const int DefaultEndHour = 17; -} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExerciseDatesController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExerciseDatesController.cs new file mode 100644 index 000000000..44df1302d --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExerciseDatesController.cs @@ -0,0 +1,170 @@ +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; +using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; +using DfE.CheckPerformanceData.Web.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; + +/// +/// One checking exercise's own dates (#319). There is no window-level date step any more: the +/// window's StartDate/EndDate is the union of these, derived in +/// , so the two cannot disagree. +/// +public sealed class ExerciseDatesController(IWindowService windowService) : Controller +{ + private const string PageView = "~/Views/WindowAdmin/ExerciseDates.cshtml"; + + [HttpGet("admin/windows/exercises/{exercise}/dates")] + public IActionResult New(CheckingExerciseType exercise) + { + CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); + + if (draft == null) + { + return BadRequest("No draft data"); + } + + ExerciseDraft? target = draft.Exercises.SingleOrDefault(e => e.ExerciseType == exercise); + + if (target is null) + { + return NotFound(); + } + + return View(PageView, Model(Guid.Empty, exercise, target.StartDate, target.EndDate, + Url.Action("Submit", "ExerciseDates", new { exercise }), + Url.Action("Index", "CancelCreation"))); + } + + [HttpPost("admin/windows/exercises/{exercise}/dates")] + [ValidateAntiForgeryToken] + public IActionResult Submit(CheckingExerciseType exercise, ExerciseDatesItem model) + { + CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); + + if (draft == null) + { + return BadRequest("No draft data"); + } + + ExerciseDraft? target = draft.Exercises.SingleOrDefault(e => e.ExerciseType == exercise); + + if (target is null) + { + return NotFound(); + } + + Decorate(model, exercise, Url.Action("Submit", "ExerciseDates", new { exercise }), + Url.Action("Index", "CancelCreation")); + + if (ModelState.IsValid) + { + Validate(model); + } + + if (!ModelState.IsValid) + { + return View(PageView, model); + } + + target.StartDate = model.StartDateTime; + target.EndDate = model.EndDateTime; + HttpContext.Session.SetObject("CheckingWindowDraft", draft); + + return Redirect(draft.NextController(Url)); + } + + [HttpGet("admin/windows/{id:guid}/exercises/{exercise}/dates")] + public async Task Edit(Guid id, CheckingExerciseType exercise, CancellationToken cancellationToken) + { + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + CheckingExerciseDto? target = window?.FindExercise(exercise); + + if (target is null) + { + return NotFound(); + } + + return View(PageView, Model(id, exercise, target.StartDate, target.EndDate, + Url.Action("Update", "ExerciseDates", new { id, exercise }), + Url.Action("Index", "Summary", new { id }))); + } + + [HttpPost("admin/windows/{id:guid}/exercises/{exercise}/dates")] + [ValidateAntiForgeryToken] + public async Task Update( + Guid id, CheckingExerciseType exercise, ExerciseDatesItem model, CancellationToken cancellationToken) + { + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + CheckingExerciseDto? target = window?.FindExercise(exercise); + + if (window is null || target is null) + { + return NotFound(); + } + + Decorate(model, exercise, Url.Action("Update", "ExerciseDates", new { id, exercise }), + Url.Action("Index", "Summary", new { id })); + + if (ModelState.IsValid) + { + Validate(model); + } + + if (!ModelState.IsValid) + { + return View(PageView, model); + } + + target.StartDate = model.StartDateTime!.Value; + target.EndDate = model.EndDateTime!.Value; + + // UpdateAsync re-derives the window's outer pair from every exercise, so moving one + // exercise's end past the window's own end widens the window rather than being rejected. + await windowService.UpdateAsync(window, cancellationToken); + + return RedirectToAction("Index", "Summary", new { id }); + } + + private void Validate(ExerciseDatesItem model) + { + if (model.StartDateTime < DateTime.UtcNow.Date) + { + ModelState.AddModelError(nameof(ExerciseDatesItem.StartDate), "Start date can not occur in the past."); + } + + if (model.EndDateTime < model.StartDateTime) + { + ModelState.AddModelError(nameof(ExerciseDatesItem.EndDate), "End date can not occur before the start date."); + } + } + + private static ExerciseDatesItem Model( + Guid windowId, CheckingExerciseType exercise, DateTime? start, DateTime? end, + string? postUrl, string? cancelUrl) => + new() + { + WindowId = windowId, + ExerciseType = exercise, + ExerciseLabel = ExerciseLabels.For(exercise), + StartDate = start, + StartHour = start?.Hour ?? ExerciseDatesItem.DefaultStartHour, + StartMinute = start?.Minute ?? 0, + EndDate = end, + EndHour = end?.Hour ?? ExerciseDatesItem.DefaultEndHour, + EndMinute = end?.Minute ?? 0, + PostUrl = postUrl, + CancelUrl = cancelUrl + }; + + // The label and the urls are not posted back, so a redisplayed page has to be given them again. + private static void Decorate( + ExerciseDatesItem model, CheckingExerciseType exercise, string? postUrl, string? cancelUrl) + { + model.ExerciseType = exercise; + model.ExerciseLabel = ExerciseLabels.For(exercise); + model.PostUrl = postUrl; + model.CancelUrl = cancelUrl; + } +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExerciseLabels.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExerciseLabels.cs new file mode 100644 index 000000000..fbff491a1 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExerciseLabels.cs @@ -0,0 +1,14 @@ +using DfE.CheckPerformanceData.Domain.Enums; +using DfE.CheckPerformanceData.Web.Extensions; + +namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; + +/// +/// Display names for checking exercises in the admin wizard (#319). Read from the enum's own +/// [Display] attribute rather than a table here, so a new exercise type is labelled the moment it +/// is declared — the same reason the wizard lists the enum instead of a hand-kept set. +/// +public static class ExerciseLabels +{ + public static string For(CheckingExerciseType exercise) => exercise.GetDisplayName(); +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExercisesController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExercisesController.cs new file mode 100644 index 000000000..68e415381 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ExercisesController.cs @@ -0,0 +1,160 @@ +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; +using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; +using DfE.CheckPerformanceData.Web.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; + +/// +/// "Which checking exercises does this window run?" (#319). Every +/// is listed, pre-ticked from the window type's defaults, so a new member of the enum surfaces here +/// with no change to this controller — while a single-exercise window is still one Continue. +/// +public sealed class ExercisesController(IWindowService windowService) : Controller +{ + private const string PageView = "~/Views/WindowAdmin/Exercises.cshtml"; + private const string NothingSelected = "Select at least one checking exercise"; + + [HttpGet("admin/windows/exercises")] + public IActionResult New() + { + CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); + + if (draft == null) + { + return BadRequest("No draft data"); + } + + // Pre-ticked from the type on the first visit; on a revisit the admin's own choice wins, + // otherwise coming back to change one box would silently reset the others. + List selected = draft.Exercises.Count > 0 + ? draft.Exercises.OrderBy(e => e.SortOrder).Select(e => e.ExerciseType).ToList() + : DefaultsFor(draft.CheckingWindowType); + + return View(PageView, new ExercisesItem + { + All = AllExercises, + Selected = selected, + PostUrl = Url.Action("Submit", "Exercises"), + CancelUrl = Url.Action("Index", "CancelCreation") + }); + } + + [HttpPost("admin/windows/exercises")] + [ValidateAntiForgeryToken] + public IActionResult Submit(ExercisesItem model) + { + CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); + + if (draft == null) + { + return BadRequest("No draft data"); + } + + if (model.Selected.Count == 0) + { + ModelState.AddModelError(nameof(ExercisesItem.Selected), NothingSelected); + return View(PageView, Redisplay(model, Url.Action("Submit", "Exercises"), Url.Action("Index", "CancelCreation"))); + } + + // Dates already given for an exercise that is still ticked survive, so changing the tick + // list does not send the admin back through date pages they have already filled in. + draft.Exercises = model.Selected + .Distinct() + .OrderBy(WindowExercises.SortOrderFor) + .Select(type => draft.Exercises.SingleOrDefault(e => e.ExerciseType == type) + ?? new ExerciseDraft { ExerciseType = type }) + .Select(e => + { + e.SortOrder = WindowExercises.SortOrderFor(e.ExerciseType); + return e; + }) + .ToList(); + + HttpContext.Session.SetObject("CheckingWindowDraft", draft); + + return Redirect(draft.NextController(Url)); + } + + [HttpGet("admin/windows/{id:guid}/exercises")] + public async Task Edit(Guid id, CancellationToken cancellationToken) + { + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + + if (window is null) + { + return NotFound(); + } + + return View(PageView, new ExercisesItem + { + WindowId = id, + All = AllExercises, + Selected = window.Exercises.OrderBy(e => e.SortOrder).Select(e => e.ExerciseType).ToList(), + WithFiles = window.Exercises.Where(e => e.Datasets.Any(d => d.IsComplete)) + .Select(e => e.ExerciseType).ToList(), + PostUrl = Url.Action("Update", "Exercises", new { id }), + CancelUrl = Url.Action("Index", "Summary", new { id }) + }); + } + + [HttpPost("admin/windows/{id:guid}/exercises")] + [ValidateAntiForgeryToken] + public async Task Update(Guid id, ExercisesItem model, CancellationToken cancellationToken) + { + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + + if (window is null) + { + return NotFound(); + } + + if (model.Selected.Count == 0) + { + ModelState.AddModelError(nameof(ExercisesItem.Selected), NothingSelected); + return View(PageView, Redisplay(model, Url.Action("Update", "Exercises", new { id }), + Url.Action("Index", "Summary", new { id }), id, window)); + } + + List wanted = model.Selected.Distinct().OrderBy(WindowExercises.SortOrderFor).ToList(); + + // A newly ticked exercise starts on the window's own dates. That is a placeholder the admin + // then edits, not an answer — but it means the window is never left holding an exercise with + // no dates at all, which the union that derives the outer pair could not survive. + window.Exercises = wanted + .Select(type => window.FindExercise(type) ?? new CheckingExerciseDto + { + ExerciseType = type, + StartDate = window.StartDate, + EndDate = window.EndDate, + SortOrder = WindowExercises.SortOrderFor(type) + }) + .ToList(); + + await windowService.UpdateAsync(window, cancellationToken); + + return RedirectToAction("Index", "Summary", new { id }); + } + + private static IReadOnlyList AllExercises => + Enum.GetValues().OrderBy(WindowExercises.SortOrderFor).ToList(); + + private static List DefaultsFor(CheckingWindowType? type) => + type is null ? [] : WindowExercises.DefaultsFor(type.Value).ToList(); + + private static ExercisesItem Redisplay( + ExercisesItem model, string? postUrl, string? cancelUrl, Guid windowId = default, + CheckingWindowDto? window = null) => new() + { + WindowId = windowId, + All = AllExercises, + Selected = model.Selected, + WithFiles = window is null + ? [] + : window.Exercises.Where(e => e.Datasets.Any(d => d.IsComplete)) + .Select(e => e.ExerciseType).ToList(), + PostUrl = postUrl, + CancelUrl = cancelUrl + }; +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/IngressFileController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/IngressFileController.cs index 0751668c9..f4e9ffcc6 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/IngressFileController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/IngressFileController.cs @@ -3,6 +3,7 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.Controllers.ViewModels; using Microsoft.AspNetCore.Mvc; @@ -12,8 +13,9 @@ public sealed class IngressFileController(ILogger logger, IWindowService windowService, IReadOnlyDictionary blobClients) : Controller { - [HttpGet("admin/windows/{id:guid}/ingress-file/{dataset}")] - public async Task Index(Guid id, string dataset, CancellationToken cancellationToken) + // #319: the route names the exercise — see the note on SchemaController. + [HttpGet("admin/windows/{id:guid}/{exercise}/ingress-file/{dataset}")] + public async Task Index(Guid id, CheckingExerciseType exercise, string dataset, CancellationToken cancellationToken) { if (!blobClients.TryGetValue("ingress", out var ingressBlobClient)) { @@ -35,18 +37,19 @@ public async Task Index(Guid id, string dataset, CancellationToke Folders = containers, Files = [], Dataset = dataset, - DatasetLabel = DatasetLabels.For(dataset) + DatasetLabel = DatasetLabels.For(dataset), + Exercise = exercise }; return View("~/Views/WindowAdmin/IngressFile.cshtml", model); } - [HttpGet("admin/windows/{id:guid}/ingress-file/{dataset}/browse")] - public async Task Browse(Guid id, string dataset, string container, string? path, CancellationToken cancellationToken) + [HttpGet("admin/windows/{id:guid}/{exercise}/ingress-file/{dataset}/browse")] + public async Task Browse(Guid id, CheckingExerciseType exercise, string dataset, string container, string? path, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(container)) { - return RedirectToAction(nameof(Index), new { id, dataset }); + return RedirectToAction(nameof(Index), new { id, exercise, dataset }); } if (!blobClients.TryGetValue("ingress", out var ingressBlobClient)) @@ -93,7 +96,8 @@ public async Task Browse(Guid id, string dataset, string containe Folders = folders, Files = files, Dataset = dataset, - DatasetLabel = DatasetLabels.For(dataset) + DatasetLabel = DatasetLabels.For(dataset), + Exercise = exercise }; return View("~/Views/WindowAdmin/IngressFile.cshtml", model); @@ -117,22 +121,22 @@ public async Task Browse(Guid id, string dataset, string containe return trimmedPath[..(lastSlashIndex + 1)]; } - [HttpPost("admin/windows/{id:guid}/ingress-file/{dataset}")] + [HttpPost("admin/windows/{id:guid}/{exercise}/ingress-file/{dataset}")] [RequestSizeLimit(100_000_000)] [ValidateAntiForgeryToken] - public async Task Select(Guid id, string dataset, string selectedFile, CancellationToken cancellationToken) + public async Task Select(Guid id, CheckingExerciseType exercise, string dataset, string selectedFile, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(selectedFile)) { ModelState.AddModelError(nameof(selectedFile), "Select an ingress file"); - return RedirectToAction(nameof(Index), new { id, dataset }); + return RedirectToAction(nameof(Index), new { id, exercise, dataset }); } int separatorIndex = selectedFile.IndexOf('/'); if (separatorIndex <= 0 || separatorIndex == selectedFile.Length - 1) { ModelState.AddModelError(nameof(selectedFile), "Select an ingress file"); - return RedirectToAction(nameof(Index), new { id, dataset }); + return RedirectToAction(nameof(Index), new { id, exercise, dataset }); } string sourceContainer = selectedFile[..separatorIndex]; @@ -189,7 +193,8 @@ await destinationBlob.UploadAsync( }, cancellationToken); - CheckingWindowDatasetDto? target = window.Datasets.SingleOrDefault(d => d.Name == dataset); + CheckingWindowDatasetDto? target = + window.FindExercise(exercise)?.Datasets.SingleOrDefault(d => d.Name == dataset); if (target is null) { diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SchemaController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SchemaController.cs index 30af11124..47d8f21b1 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SchemaController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SchemaController.cs @@ -2,6 +2,7 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.Common; using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; using Microsoft.AspNetCore.Mvc; @@ -17,8 +18,11 @@ public class SchemaController( private const string PageView = "~/Views/WindowAdmin/Schema.cshtml"; - [HttpGet("admin/windows/{id:guid}/schema-file/{dataset}")] - public async Task Index(Guid id, string dataset, CancellationToken cancellationToken) + // #319: the route names the exercise. A dataset belongs to the exercise that consumes it, + // and dataset names are only unique within one — "pupils" could belong to either once a + // second exercise gains slots. + [HttpGet("admin/windows/{id:guid}/{exercise}/schema-file/{dataset}")] + public async Task Index(Guid id, CheckingExerciseType exercise, string dataset, CancellationToken cancellationToken) { CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); @@ -27,7 +31,7 @@ public async Task Index(Guid id, string dataset, CancellationToke return NotFound(); } - CheckingWindowDatasetDto? target = window.Datasets.SingleOrDefault(d => d.Name == dataset); + CheckingWindowDatasetDto? target = FindDataset(window, exercise, dataset); if (target is null) { @@ -40,14 +44,14 @@ public async Task Index(Guid id, string dataset, CancellationToke SchemaFile = target.SchemaFile, Dataset = target.Name, DatasetLabel = DatasetLabels.For(target.Name), - PostUrl = Url.Action("Submit", "Schema", new { id = window.Id, dataset = target.Name }), + PostUrl = Url.Action("Submit", "Schema", new { id = window.Id, exercise, dataset = target.Name }), }; return View(PageView, model); } - [HttpPost("admin/windows/{id:guid}/schema-file/{dataset}")] + [HttpPost("admin/windows/{id:guid}/{exercise}/schema-file/{dataset}")] [ValidateAntiForgeryToken] - public async Task Submit(Guid id, string dataset, SchemaItem model, CancellationToken cancellationToken) + public async Task Submit(Guid id, CheckingExerciseType exercise, string dataset, SchemaItem model, CancellationToken cancellationToken) { if (id != model.WindowId) { @@ -105,7 +109,7 @@ await destinationBlob.UploadAsync( }, cancellationToken); - CheckingWindowDatasetDto? target = window.Datasets.SingleOrDefault(d => d.Name == dataset); + CheckingWindowDatasetDto? target = FindDataset(window, exercise, dataset); if (target is null) { @@ -127,4 +131,7 @@ await destinationBlob.UploadAsync( return RedirectToAction("Index", "Summary", new { id }); } + private static CheckingWindowDatasetDto? FindDataset( + CheckingWindowDto window, CheckingExerciseType exercise, string dataset) => + window.FindExercise(exercise)?.Datasets.SingleOrDefault(d => d.Name == dataset); } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/StartDateController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/StartDateController.cs deleted file mode 100644 index 399ce5206..000000000 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/StartDateController.cs +++ /dev/null @@ -1,128 +0,0 @@ -using DfE.CheckPerformanceData.Application.WindowManagement; -using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; -using DfE.CheckPerformanceData.Web.Extensions; -using Microsoft.AspNetCore.Mvc; - -namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; - -public sealed class StartDateController(IWindowService windowService): Controller -{ - private const string PageView = "~/Views/WindowAdmin/StartDate.cshtml"; - - [HttpGet("admin/windows/{id:guid}/start-date")] - public async Task Edit(Guid id, CancellationToken cancellationToken) - { - CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); - - if (window is null) - { - return NotFound(); - } - - WindowDateEditItem model = new WindowDateEditItem() - { - WindowId = window.Id, - DateValue = window.StartDate, - Hour = window.StartDate.Hour, - Minute = window.StartDate.Minute, - PostUrl = Url.Action("Update", "StartDate", new { id = window.Id}), - CancelUrl = Url.Action("Index", "Summary", new { id = window.Id}) - }; - - return View(PageView, model); - } - - [HttpGet("admin/windows/start-date")] - public async Task New(CancellationToken cancellationToken) - { - CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); - - if (draft == null) - { - return BadRequest("No draft data"); - } - - WindowDateEditItem model = new WindowDateEditItem() - { - WindowId = Guid.Empty, - DateValue = draft.StartDate, - // New windows default to opening at midnight; the admin can change it. - Hour = draft.StartDate?.Hour ?? DefaultStartHour, - Minute = draft.StartDate?.Minute ?? 0, - PostUrl = Url.Action("Submit", "StartDate"), - CancelUrl = Url.Action("Index", "CancelCreation") - - }; - - return View(PageView, model); - } - - [HttpPost("admin/windows/start-date")] - [ValidateAntiForgeryToken] - public async Task Submit(WindowDateEditItem model, CancellationToken cancellationToken) - { - CheckingWindowDraft? draft = HttpContext.Session.GetObject("CheckingWindowDraft"); - - if (draft == null) - { - return BadRequest("No draft data"); - } - - if (ModelState.IsValid) - { - DateValidation(model.DateTimeValue, null); - } - - if (!ModelState.IsValid) - { - return View(PageView, model); - } - - draft.StartDate = model.DateTimeValue; - HttpContext.Session.SetObject("CheckingWindowDraft", draft); - - return Redirect(draft.NextController(Url)); - } - - [HttpPost("admin/windows/{id:guid}/start-date")] - [ValidateAntiForgeryToken] - public async Task Update(Guid id, WindowDateEditItem model, CancellationToken cancellationToken) - { - CheckingWindowDto window = await windowService.GetByIdAsync(id, cancellationToken); - - if (ModelState.IsValid) - { - DateValidation(model.DateTimeValue, window); - } - - if (!ModelState.IsValid) - { - return View(PageView, model); - } - - if (id != model.WindowId) - { - return BadRequest(); - } - - window.StartDate = model.DateTimeValue!.Value; - await windowService.UpdateAsync(window, cancellationToken); - - return RedirectToAction("Index", "Summary", new { id = id }); - } - - public void DateValidation(DateTime? value, CheckingWindowDto? windowDto) - { - if (value < DateTime.UtcNow.Date) - { - ModelState.AddModelError(nameof(WindowDateEditItem.DateValue), "Start date can not occur in the past."); - } - - if (windowDto != null && value > windowDto.EndDate) - { - ModelState.AddModelError(nameof(WindowDateEditItem.DateValue), $"Start date can not occur after the end date ({windowDto.EndDate:dd MM yyyy HH:mm})."); - } - } - - private const int DefaultStartHour = 0; -} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs index 8eab8f5a2..3bd9e8a92 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs @@ -10,7 +10,12 @@ public sealed class SummaryController(IWindowService windowService): Controller [HttpGet("admin/windows/summary/{id:guid}")] public async Task Index(Guid id, CancellationToken cancellationToken) { - CheckingWindowDto w = await windowService.GetByIdAsync(id, cancellationToken); + CheckingWindowDto? w = await windowService.GetByIdAsync(id, cancellationToken); + if (w is null) + { + return NotFound(); + } + WindowEditItem vm = new WindowEditItem { WindowId = w.Id, @@ -20,18 +25,36 @@ public async Task Index(Guid id, CancellationToken cancellationTo EndDate = w.EndDate, KeyStage = w.KeyStage, CheckingWindowType = w.CheckingWindowType, - Datasets = w.Datasets - .OrderBy(d => d.SortOrder) - .Select(d => new DatasetSummaryRow + // #319: one section per checking exercise, each with its own dates, files and + // validation state. There is no window-level validate button any more — an exercise + // validates on its own, and a window is usable while another is still unvalidated. + Exercises = w.Exercises + .OrderBy(e => e.SortOrder) + .Select(e => new ExerciseSummarySection { WindowId = w.Id, - Name = d.Name, - Label = DatasetLabels.For(d.Name), - IngressFile = d.IngressFile, - SchemaFile = d.SchemaFile + ExerciseType = e.ExerciseType, + Label = ExerciseLabels.For(e.ExerciseType), + StartDate = e.StartDate, + EndDate = e.EndDate, + IsValidated = e.IsValidated, + ValidatedAt = e.ValidatedAt, + IsStale = e.ValidatedAt is not null && !e.IsValidated, + Datasets = e.Datasets + .OrderBy(d => d.SortOrder) + .Select(d => new DatasetSummaryRow + { + WindowId = w.Id, + Exercise = e.ExerciseType, + Name = d.Name, + Label = DatasetLabels.For(d.Name), + IngressFile = d.IngressFile, + SchemaFile = d.SchemaFile, + Required = d.Required + }) + .ToList() }) - .ToList(), - PostUrl = Url.Action("Index", "ValidateWindow", new {id = w.Id}) + .ToList() }; return View("~/Views/WindowAdmin/Summary.cshtml", vm); } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/TitleController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/TitleController.cs index a8f408a87..6b85982c0 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/TitleController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/TitleController.cs @@ -74,7 +74,7 @@ public async Task Update(Guid id, WindowTitleEditItem model, Canc return BadRequest(); } - CheckingWindowDto window = await windowService.GetByIdAsync(id, cancellationToken); + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); if (window is null) { return NotFound(); diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ValidateWindowController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ValidateWindowController.cs index 7f0e22801..890266fae 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ValidateWindowController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/ValidateWindowController.cs @@ -1,90 +1,130 @@ using System.Runtime.CompilerServices; using System.Text; using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Infrastructure.Ingress; using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; using Microsoft.AspNetCore.Mvc; namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; +/// +/// Runs one checking exercise's ingress + schema pair and, on a clean finish, stamps that exercise +/// validated (#319). +/// +/// +/// The route names the exercise rather than looping over every exercise in one run. A loop would +/// have to emit several terminal progress events down a stream whose client expects one, and would +/// stop an admin revalidating a single exercise after replacing one of its files. Running one at a +/// time keeps each run exactly the shape the processor and the progress stream already handle, and +/// is what makes "a window is usable while another exercise is still unvalidated" true rather than +/// merely allowed. +/// public class ValidateWindowController(IWindowService windowService, ICsvSchemaFileProcessor processor): Controller { private const string PageView = "~/Views/WindowAdmin/Validate.cshtml"; - [HttpGet("admin/windows/{id:guid}/validate")] - public IActionResult Index(Guid id) + [HttpGet("admin/windows/{id:guid}/{exercise}/validate")] + public IActionResult Index(Guid id, CheckingExerciseType exercise) { - ValidationViewModel model = new ValidationViewModel - { - WindowId = id, - StreamUrl = Url.Action(nameof(Stream), "ValidateWindow", new { id }), - PostUrl = Url.Action(nameof(Validate), "ValidateWindow", new { id }), - }; - - return View(PageView, model); + return View(PageView, Model(id, exercise)); } // Live progress stream (step 1-7). EventSource can only issue GET, so validation runs here; // the client opens this on demand from the Start button rather than on page load. - [HttpGet("admin/windows/{id:guid}/validate/stream")] - public IResult Stream(Guid id, CancellationToken cancellationToken) + [HttpGet("admin/windows/{id:guid}/{exercise}/validate/stream")] + public IResult Stream(Guid id, CheckingExerciseType exercise, CancellationToken cancellationToken) { - return Results.ServerSentEvents(Run(id, cancellationToken), eventType: "progress"); + return Results.ServerSentEvents(Run(id, exercise, cancellationToken), eventType: "progress"); } // No-JS fallback: run to completion and render the final summary. - [HttpPost("admin/windows/{id:guid}/validate")] - public async Task Validate(Guid id, CancellationToken cancellationToken) + [HttpPost("admin/windows/{id:guid}/{exercise}/validate")] + public async Task Validate(Guid id, CheckingExerciseType exercise, CancellationToken cancellationToken) { ValidationProgress? last = null; - await foreach (ValidationProgress progress in Run(id, cancellationToken)) + await foreach (ValidationProgress progress in Run(id, exercise, cancellationToken)) { last = progress; } - ValidationViewModel model = new ValidationViewModel - { - WindowId = id, - StreamUrl = Url.Action(nameof(Stream), "ValidateWindow", new { id }), - PostUrl = Url.Action(nameof(Validate), "ValidateWindow", new { id }), - ProcessingResult = last is null - ? null - : new ProcessingResult(last.RecordsRead, last.FilesWritten, last.ErrorCount, new StringBuilder(last.Message), last.SchoolSummary), - }; + ValidationViewModel model = Model(id, exercise); + model.ProcessingResult = last is null + ? null + : new ProcessingResult(last.RecordsRead, last.FilesWritten, last.ErrorCount, new StringBuilder(last.Message), last.SchoolSummary); return View(PageView, model); } - // Drives the processor and, on a clean finish, marks the window validated before the terminal - // event reaches the caller. + private ValidationViewModel Model(Guid id, CheckingExerciseType exercise) => new() + { + WindowId = id, + ExerciseLabel = ExerciseLabels.For(exercise), + StreamUrl = Url.Action(nameof(Stream), "ValidateWindow", new { id, exercise }), + PostUrl = Url.Action(nameof(Validate), "ValidateWindow", new { id, exercise }), + CancelUrl = Url.Action("Index", "Summary", new { id }) + }; + + // Drives the processor for one exercise and, on a clean finish, stamps that exercise validated + // before the terminal event reaches the caller. private async IAsyncEnumerable Run( Guid id, + CheckingExerciseType exercise, [EnumeratorCancellation] CancellationToken cancellationToken) { - CheckingWindowDto window = await windowService.GetByIdAsync(id, cancellationToken); + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + + if (window is null) + { + yield return new ValidationProgress( + Phase: "error", + Message: "This checking window no longer exists.", + RecordsRead: 0, RecordsProcessed: 0, FilesWritten: 0, ErrorCount: 1, + IsComplete: true, IsError: true); + yield break; + } + + CheckingExerciseDto? target = window.FindExercise(exercise); + + if (target is null) + { + yield return new ValidationProgress( + Phase: "error", + Message: $"This window does not run {ExerciseLabels.For(exercise)}.", + RecordsRead: 0, RecordsProcessed: 0, FilesWritten: 0, ErrorCount: 1, + IsComplete: true, IsError: true); + yield break; + } - // A Post16 window supplies two datasets (included + non-included); every other type one. - // They are ingested in a single run so both populations land in one blob per school. - IReadOnlyList datasets = window.Datasets - .OrderBy(d => d.SortOrder) + // A Post16 pupil-data exercise supplies two datasets (included + non-included) and a + // results enquiry supplies one per source file in the results feed; every other pupil-data + // exercise supplies a single dataset. They are ingested in a single run so every population + // lands in one blob per school — which is why a run is per exercise and not per dataset. + IReadOnlyList datasets = target.DatasetsToIngest .Select(d => new IngressDataset( d.Name, d.IngressFile, d.IngressFileChecksum, d.SchemaFile, d.SchemaFileChecksum, - d.Included)) + d.Included, + d.SourceFile)) .ToList(); await foreach (ValidationProgress progress in processor.ProcessAsync( window.Id, + exercise, datasets, cancellationToken: cancellationToken)) { if (progress is { IsComplete: true, IsError: false }) { - window.Validated = true; - window.ValidatedAt = DateTime.UtcNow; + // Stamped with the checksums of the files this run actually read, so replacing one + // afterwards leaves a stamp the summary page can show as stale rather than as a + // clean bill of health for data nobody validated. + target.ValidatedAt = DateTime.UtcNow; + target.ValidatedIngressChecksum = target.CurrentIngressChecksum; + target.ValidatedSchemaChecksum = target.CurrentSchemaChecksum; await windowService.UpdateAsync(window, cancellationToken); } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/WindowTypeController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/WindowTypeController.cs index 925cf91db..cc67ced01 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/WindowTypeController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/WindowTypeController.cs @@ -78,7 +78,11 @@ public async Task Submit(WindowTypeItem model, CancellationToken [ValidateAntiForgeryToken] public async Task Update(Guid id, WindowTypeItem model, CancellationToken cancellationToken) { - CheckingWindowDto window = await windowService.GetByIdAsync(id, cancellationToken); + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + if (window is null) + { + return NotFound(); + } if (model.WindowType == null) { diff --git a/src/DfE.CheckPerformanceData.Web/Data/QuestionFlows/IncorrectGrade_Post16.json b/src/DfE.CheckPerformanceData.Web/Data/QuestionFlows/IncorrectGrade_Post16.json index 2343a4f0a..bc517e36e 100644 --- a/src/DfE.CheckPerformanceData.Web/Data/QuestionFlows/IncorrectGrade_Post16.json +++ b/src/DfE.CheckPerformanceData.Web/Data/QuestionFlows/IncorrectGrade_Post16.json @@ -54,9 +54,10 @@ "id": "select-student-cohort", "type": "PupilSearch", "pupilFilter": "All", + "requireResults": true, "pupilKey": "primary", "title": "What is the name of one of the students from the affected cohort?", - "subheading": "Start typing to search by name, ULN, CYPMD ID or date of birth", + "subheading": "Start typing to search by name, ULN, CYPMD ID or date of birth. You can only search for students who have results.", "validationFailure": "Enter the name of the student with an incorrect grade", "nextPageId": "select-result" }, @@ -64,9 +65,10 @@ "id": "select-student-single", "type": "PupilSearch", "pupilFilter": "All", + "requireResults": true, "pupilKey": "primary", "title": "What is the name of the student with an incorrect grade?", - "subheading": "Start typing to search by name, ULN, CYPMD ID or date of birth", + "subheading": "Start typing to search by name, ULN, CYPMD ID or date of birth. You can only search for students who have results.", "validationFailure": "Enter the name of the student with an incorrect grade", "nextPageId": "select-result" }, diff --git a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs index d1e93623c..96041f7fd 100644 --- a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs +++ b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs @@ -41,7 +41,8 @@ public static async Task ExecuteSeedAsync( var windowId = DevDataSeeder.KeyStage4JuneCheckingWindowId; // Seeded change requests are KS4-only; the window above is the KS4 June dev window. - var pupils = await pupilClient.GetPupilsAsync(windowId, Laestab, CheckingWindowType.KS4June); + var pupils = await pupilClient.GetPupilsAsync( + windowId, CheckingExerciseType.PupilData, Laestab, CheckingWindowType.KS4June); if (pupils is null || pupils.Count == 0) return; var included = pupils diff --git a/src/DfE.CheckPerformanceData.Web/Seeding/SeedPupilData.cs b/src/DfE.CheckPerformanceData.Web/Seeding/SeedPupilData.cs index 9313d7fd8..15c212d8f 100644 --- a/src/DfE.CheckPerformanceData.Web/Seeding/SeedPupilData.cs +++ b/src/DfE.CheckPerformanceData.Web/Seeding/SeedPupilData.cs @@ -48,7 +48,7 @@ public static async Task ExecuteSeedAsync(IPupilDataBlobClient client) pupils.AddRange(GeneratePupils(PupilsPerGroup, includedPincl: false, NonIncludedIndexOffset, windowId, school)); if (pupils.Count > 0) - await client.UploadPupilsAsync(windowId, school.Laestab, pupils); + await client.UploadPupilsAsync(windowId, CheckingExerciseType.PupilData, school.Laestab, pupils); } } @@ -70,7 +70,8 @@ public static async Task ExecutePost16SeedAsync(IPupilDataBlobClient client) pupils.AddRange(GeneratePost16Pupils(PupilsPerGroup, included: false, NonIncludedIndexOffset, school)); if (pupils.Count > 0) - await client.UploadPupilsAsync(DevDataSeeder.Post16CheckingWindowId, school.Laestab, pupils); + await client.UploadPupilsAsync( + DevDataSeeder.Post16CheckingWindowId, CheckingExerciseType.PupilData, school.Laestab, pupils); } } diff --git a/src/DfE.CheckPerformanceData.Web/Seeding/SeedStudentResults.cs b/src/DfE.CheckPerformanceData.Web/Seeding/SeedStudentResults.cs index c5246ff1d..06748c61f 100644 --- a/src/DfE.CheckPerformanceData.Web/Seeding/SeedStudentResults.cs +++ b/src/DfE.CheckPerformanceData.Web/Seeding/SeedStudentResults.cs @@ -30,9 +30,11 @@ public static class SeedStudentResults private const string StudentC = "500003"; public static async Task ExecuteSeedAsync(IStudentResultsClient client) - => await client.UploadResultsAsync(DevDataSeeder.Post16CheckingWindowId, Laestab, Results); + => await client.UploadResultsAsync(DevDataSeeder.Post16CheckingWindowId, Laestab, All); - private static readonly StudentResultRecord[] Results = + private static IReadOnlyList All => [.. FigmaResults, .. GeneratedResults()]; + + private static readonly StudentResultRecord[] FigmaResults = [ // Student A holds the same qualification twice, distinguished only by session — the case the // ticket calls out as the reason the result search cannot key on QAN alone. @@ -81,4 +83,62 @@ public static async Task ExecuteSeedAsync(IStudentResultsClient client) SyllabusCode = "1BS0", Session = "S2024", Grade = "2", SourceFile = ResultsFileTags.Post16Main } ]; + + // ── The rest of the school ─────────────────────────────────────────────── + // + // The student search on a results enquiry lists only students who hold a result, so a seed of + // three students leaves a manual tester unable to exercise a common-surname search, the ten + // suggestion cap, or anything else the picker does. This spreads results across both + // populations — every third included student and every fifth non-included one — which is + // roughly a quarter of the school. + // + // Deliberately NOT every student: the search restriction and the result page's empty state are + // both only visible when some students hold nothing. + // + // Qualifications come from the seeded grade reference, so the revised-grade picker can always + // list grades. Sessions and grades vary with the student so two suggestions never read alike. + private static IEnumerable GeneratedResults() + { + // SeedPupilData: 120 included students from index 0, then 120 non-included from index 200. + var students = Enumerable.Range(0, 120).Where(i => i % 3 == 0 && i > 2) + .Concat(Enumerable.Range(200, 120).Where(i => i % 5 == 0)); + + foreach (var (index, position) in students.Select((n, i) => (n, i))) + { + var cypmdId = $"5{(index + 1):D5}"; + + // One qualification each, plus a second for every third student so the "which of these + // is wrong?" choice is a real one rather than a formality. + yield return Row(cypmdId, Catalogue[position % Catalogue.Length], position); + + if (position % 3 == 0) + yield return Row(cypmdId, Catalogue[(position + 1) % Catalogue.Length], position + 1); + } + } + + private static StudentResultRecord Row(string cypmdId, Qualification qualification, int position) => new() + { + CypmdId = cypmdId, + Qan = qualification.Qan, + QualificationName = qualification.Name, + SyllabusCode = qualification.SyllabusCode, + Session = position % 4 == 0 ? "S2023" : "S2024", + Grade = qualification.Grades[position % qualification.Grades.Length], + // No LR2 row anywhere in the seed — see the class summary. + SourceFile = position % 3 == 0 ? ResultsFileTags.Post16LateResults1 : ResultsFileTags.Post16Main + }; + + private sealed record Qualification(string Qan, string Name, string SyllabusCode, string[] Grades); + + // Every QAN here is in Web/Data/GradeReference/grade-reference.json, and the grades are drawn + // from that file's pass grades for the qualification. + private static readonly Qualification[] Catalogue = + [ + new("6037116X", "GCSE (9-1) Bus. Studs:Single", "1BS0", ["4", "5", "6", "7"]), + new("60181576", "GCSE (9-1) French", "1FR0", ["3", "5", "6", "8"]), + new("60180882", "GCSE (9-1) Art&Des : Fine Art", "1AD0", ["5", "6", "7", "9"]), + new("60370683", "Pearson BTEC L1/L2 Tech Award in Sport", "31525H", ["P1", "M1", "M2", "D1"]), + new("10025480", "OCR Level 3 FSMQ: Additional Maths", "6993", ["A", "B", "C", "D"]), + new("50034157", "IBO Level 3 International Baccalaureate Diploma", "IBDP", ["24B", "25B", "26B", "27B"]) + ]; } diff --git a/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/BulkConfirmation.cshtml b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/BulkConfirmation.cshtml index 62dd04ce5..85d91ebe8 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/BulkConfirmation.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/BulkConfirmation.cshtml @@ -21,9 +21,12 @@

We have sent you an email to confirm that you have successfully submitted your requests.

- - You still have until @Model.WindowCloseLabel to request any further amendments if you need to. - +@if (Model.WindowCloseLabel is not null) +{ + + You still have until @Model.WindowCloseLabel to request any further amendments if you need to. + +}

Request another amendment diff --git a/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml index 73d90f062..ed0dd7fe8 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml @@ -26,8 +26,14 @@ Amendment requests

Amendment request summary

+@* #320: one line per checking exercise. The grid stays unsplit — both populations share one table + and one bulk submit — but they do not share a deadline: on a 16-19 window pupil data checking + shuts months before results enquiry does, so the outer window's end date was right for neither. *@
-

Submit all @Model.WindowTitle requests by @Model.DeadlineText

+ @foreach (var deadline in Model.Deadlines) + { +

@deadline.Sentence

+ }
@{ @@ -137,7 +143,19 @@
-

You can edit your @Model.WindowTitle requests any time before @Model.DeadlineText

+ @foreach (var deadline in Model.Deadlines.Where(d => d.IsOpen)) + { +

+ You can edit your @deadline.ExerciseLabel.ToLowerInvariant() requests any time + before @deadline.DeadlineText +

+ } + @if (Model.Deadlines.All(d => !d.IsOpen)) + { + @* Every exercise has closed, so nothing here can still be edited. Saying "you can + edit these" would be flatly untrue — #318 blocks the resume. *@ +

This window is closed. You can still view your requests.

+ }
diff --git a/src/DfE.CheckPerformanceData.Web/Views/CheckYourPupilData/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/CheckYourPupilData/Index.cshtml index 8c174c259..42fb97bf4 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/CheckYourPupilData/Index.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/CheckYourPupilData/Index.cshtml @@ -1,3 +1,4 @@ +@using DfE.CheckPerformanceData.Application.CheckYourPupilData @using DfE.CheckPerformanceData.Web.Controllers.CheckYourPupilData @model DfE.CheckPerformanceData.Web.Controllers.CheckYourPupilData.CheckYourPupilDataViewModel @@ -9,6 +10,8 @@ Back +@await Html.PartialAsync("_ClosedExerciseBanner") + @await Component.InvokeAsync("EditableTitle", new { key = "check-pupil-data-title", @@ -18,8 +21,27 @@ }) -

Review your pupil data by checking the tables below. You can also download the data as a CSV - file. You must request any changes to pupil data before @Model.WindowEndTime on @Model.WindowEndDate

+@{ + // #317: the deadline is the pupil-data exercise's own end date, never the outer window's — on a + // multi-exercise window the outer date is months later and would promise slack the school does + // not have. Checking-window dates are UK wall-clock values rather than UTC instants, so they + // are formatted as they stand and never routed through LondonTime. + var pupilDataDeadline = Model.PupilDataEndDate is { } end + ? $"{end:htt}".ToLowerInvariant() + $" on {end:dddd d MMMM yyyy}" + : null; +} + +

+ Review your pupil data by checking the tables below. You can also download the data as a CSV file. + @if (pupilDataDeadline is not null && Model.IsPupilDataOpen) + { + @:You must request any changes to pupil data before @pupilDataDeadline + } + else if (pupilDataDeadline is not null) + { + @:The window for requesting changes to pupil data closed at @pupilDataDeadline + } +

Download all @@ -61,7 +83,28 @@ } -@if (Model.IsWindowOpen) +@* #317: the form offers whatever the open exercises offer. Nothing open means no form at all — + the tables, the search and the downloads above are untouched, because a closed exercise removes + actions, never content. *@ +@if (Model.AvailableNextSteps.Count == 0) +{ +

This window is closed for changes. You can still review and download your + pupil data above.

+} +else if (Model.AvailableNextSteps.Count == 1) +{ + @* A radio group with a single choice is a poor pattern and contradicts the "select one option" + hint, so the one surviving option becomes the button itself. The value still posts through a + hidden field, so NextStep re-derives and re-checks it exactly as it does for the radios. *@ + var only = Model.AvailableNextSteps[0]; + +
+ @Html.AntiForgeryToken() + + @NextStepLabels.For(only) + +} +else {
@Html.AntiForgeryToken() @@ -79,18 +122,18 @@ Select one option. - Request an amendment to pupil data - - @if (Model.ShowResultsEnquiryOption) + @* "Confirm pupil data is correct" is the alternative to doing anything, so it reads + last, after the "or" divider — the GDS pattern, and the order this page has always + rendered. Every other option keeps the service's exercise-sort order. *@ + @foreach (var step in Model.AvailableNextSteps.Where(s => s != NextSteps.Confirm)) + { + @NextStepLabels.For(step) + } + @if (Model.AvailableNextSteps.Contains(NextSteps.Confirm)) { - @* AB#296648: the 16-19 way in to a results enquiry, per the agreed decision in - docs/16-19-window-model.md. The full "Review exam results" tabbed page belongs - to the entry-point ticket; this radio is the model-aligned route until then. *@ - Report an issue with an exam result - + or + @NextStepLabels.For(NextSteps.Confirm) } - or - Confirm pupil data is correct diff --git a/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml index 2833dcef5..1cb311754 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml @@ -85,6 +85,7 @@ else (function () { var windowId = '@Model.WindowId'; var filter = '@Model.Filter.ToString().ToLower()'; + var requireResults = @(Model.RequireResults ? "true" : "false"); var excludePupilId = '@(Model.ExcludePupilId?.ToString() ?? "")'; var hasError = @(hasError ? "true" : "false"); var initialLabel = @Json.Serialize(Model.SelectedPupilLabel ?? ""); @@ -115,11 +116,19 @@ else // (AB#295434) — see _Autocomplete.cshtml for the full rationale. defaultValue: initialLabel, tAssistiveHint: function () { return assistiveHint; }, + // On a restricted search the component's default "No results found" reads as "you + // typed it wrong", when the student may simply hold no results. Saying which it is + // here is the only place the user sees it — the hint above is gone once they type. + // FLAGGED: copy needs content sign-off. + tNoResults: function () { + return requireResults ? 'No students found with results' : 'No results found'; + }, source: function (query, populateResults) { var url = '/pupils/suggestions?windowId=' + windowId + '&query=' + encodeURIComponent(query) + '&filter=' + filter; if (excludePupilId) url += '&excludePupilId=' + excludePupilId; + if (requireResults) url += '&requireResults=true'; fetch(url) .then(function (r) { return r.json(); }) diff --git a/src/DfE.CheckPerformanceData.Web/Views/Journey/ResultSearch.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Journey/ResultSearch.cshtml index 6abbba896..6794794ee 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Journey/ResultSearch.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Journey/ResultSearch.cshtml @@ -40,6 +40,36 @@ else enhanceSelectElement hides the select, moves its id to "{id}-select" and gives the new input the original id, so the

+ Search for a different student +

+} +else +{ @Html.AntiForgeryToken() @@ -101,6 +131,7 @@ else Continue +} @section Scripts {