From f23836e531ccd49f1f26122ba39914bf16c32122 Mon Sep 17 00:00:00 2001 From: GG-DfE Date: Fri, 7 Aug 2026 10:37:39 +0100 Subject: [PATCH 1/7] Building the page layout --- ...stablishmentAmendmentRequestsController.cs | 26 ++++++ ...EstablishmentAmendmentRequestsViewModel.cs | 26 ++++++ .../Index.cshtml | 80 +++++++++++++++++++ .../Views/Shared/_Layout.cshtml | 15 ++-- 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs create mode 100644 src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs create mode 100644 src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs new file mode 100644 index 00000000..1c368362 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs @@ -0,0 +1,26 @@ +using DfE.CheckPerformanceData.Web.Controllers.ViewModels; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Controllers; + +public class EstablishmentAmendmentRequestsController: Controller +{ + [Route("/establishment-amendment-requests")] + public async Task Index() + { + var viewModel = new EstablishmentAmendmentRequestsViewModel + { + ActiveWindows = new List() + { + new ActiveWindow() { WindowTitle = "dave", DeadlineText = "test"} + }, + Rows = new List + { + new AmendmentItem { PupilName = "Row 1", ReferenceNumber = "Seedx001", RequestType = "Remove", Status = "Submitted", WindowName = "Window 1", DateSubmitted = "2023-01-01", WindowId = "1", WindowIsOpen = false}, + new AmendmentItem { PupilName = "Row 2", ReferenceNumber = "Seedx002", RequestType = "Delete", Status = "Withdraw", WindowName = "Window 1", DateSubmitted = "2023-01-01", WindowId = "2", WindowIsOpen = true} + + } + }; + return View(viewModel); + } +} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs new file mode 100644 index 00000000..0c72d6bb --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs @@ -0,0 +1,26 @@ +namespace DfE.CheckPerformanceData.Web.Controllers.ViewModels; + +public sealed class EstablishmentAmendmentRequestsViewModel +{ + public List ActiveWindows { get; init; } + public List Rows { get; init; } +} + +public sealed class ActiveWindow +{ + public string WindowId { get; init; } + public string WindowTitle { get; init; } + public string DeadlineText { get; init; } +} + +public sealed class AmendmentItem { + + public string PupilName { get; init; } + public string ReferenceNumber { get; init; } + public string RequestType { get; init; } + public string Status { get; init; } + public string WindowName { get; init; } + public string DateSubmitted { get; init; } + public string WindowId { get; init; } + public bool WindowIsOpen { get; init; } +} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml new file mode 100644 index 00000000..0a911c74 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml @@ -0,0 +1,80 @@ +@model DfE.CheckPerformanceData.Web.Controllers.ViewModels.EstablishmentAmendmentRequestsViewModel + +Amendment requests +

Summary of requests, decisions and issues

+ +@if (Model.ActiveWindows != null && Model.ActiveWindows.Any()) +{ +
+ @foreach (var window in Model.ActiveWindows) + { +

+ Submit all @window.WindowTitle requests by @window.DeadlineText. +

+ } +
+} + + + + + + + + + + + + + + + @if (Model.Rows == null || Model.Rows.Count == 0) + { + + + + } + else + { + foreach (var row in Model.Rows) + { + + + + + + + + + } + } + +
Pupil nameReference numberRequest typeStatusWindow nameDate submittedAction
+ There are no amendment requests for your establishment. +
@row.PupilName@row.ReferenceNumber@row.RequestType + @* *@ + + @row.Status + + @row.WindowName@row.DateSubmitted + +
\ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml index 9dee799f..18eaa7d2 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml @@ -98,13 +98,18 @@
  • Guidance
  • - @if (User.Identity is { IsAuthenticated: true } && selectedWindowId is not null) + @if (User.Identity is { IsAuthenticated: true }) { + @if (selectedWindowId is not null) + { +
  • + Pupils +
  • + } +
  • - Pupils -
  • -
  • - Amendment requests + Amendment requests
  • } @* Sign in / Sign out cluster. From c4b949c6a48e24805a312f18fd2a365cff5a9f0e Mon Sep 17 00:00:00 2001 From: GG-DfE Date: Tue, 11 Aug 2026 23:22:24 +0100 Subject: [PATCH 2/7] adding in new view for ammendments across the est --- docs/LocalDevelopement/dsi.json | 4 +- .../IUrnAmendmentRequestsService.cs | 6 +++ .../AmendmentRequests/SubmittedRequestData.cs | 1 + .../UrnAmendmentRequestDto.cs | 15 ++++++ .../UrnAmendmentRequestsResult.cs | 14 ++++++ .../UrnAmendmentRequestsService.cs | 48 +++++++++++++++++++ .../DependencyManager.cs | 1 + .../RequestSubmission/IRequestRepository.cs | 1 + .../WindowManagement/IWindowService.cs | 9 ++++ .../WindowManagement/WindowService.cs | 6 --- .../Repositories/RequestRepository.cs | 20 ++++++++ ...stablishmentAmendmentRequestsController.cs | 31 ++++++++---- .../Index.cshtml | 32 +++++-------- 13 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 src/DfE.CheckPerformanceData.Application/AmendmentRequests/IUrnAmendmentRequestsService.cs create mode 100644 src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestDto.cs create mode 100644 src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsResult.cs create mode 100644 src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs diff --git a/docs/LocalDevelopement/dsi.json b/docs/LocalDevelopement/dsi.json index 49de9578..ad86c0f6 100644 --- a/docs/LocalDevelopement/dsi.json +++ b/docs/LocalDevelopement/dsi.json @@ -125,7 +125,7 @@ "responses": [ { "uuid": "aea874b6-6d35-4fce-8e4b-2ec5d1deaa5d", - "body": "{\n \"access_token\": \"eeyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"id_token\": \"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600\n}", + "body": "{\n \"access_token\": \"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"id_token\": \"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600\n}", "latency": 0, "statusCode": 200, "label": "", @@ -309,7 +309,7 @@ "id": "97jr", "name": "User4to16", "documentation": "", - "value": "[{\n \"id\": \"mockoon-org-456\",\n \"name\": \"Mockoon Organisation 4 - 16\",\n \"establishmentNumber\": \"4070\",\n \"localAuthority\": {\n \"code\": \"860\"\n },\n \"legalName\": \"Mockoon Legal Name\",\n \"urn\": \"136774\",\n \"uid\": \"UID1234\",\n \"upin\": \"upin\",\n \"ukprn\": \"ukprn\",\n \"EstablishmentNumber\": \"est1234\",\n \"address\": \"Acia Lane\",\n \"telephone\": \"0123456888\",\n \"statutoryLowAge\": 4,\n \"statutoryHighAge\": 16, \n \"legacyId\": \"Leg123\",\n \"companyRegistrationNumber\": \"CRN123\",\n \"role\": {\n \"id\": 0,\n \"name\": \"End User\"\n }\n }\n ]" + "value": "[{\n \"id\": \"mockoon-org-456\",\n \"name\": \"Mockoon Organisation 4 - 16\",\n \"establishmentNumber\": \"4070\",\n \"localAuthority\": {\n \"code\": \"860\"\n },\n \"legalName\": \"Mockoon Legal Name\",\n \"urn\": \"142313\",\n \"uid\": \"UID1234\",\n \"upin\": \"upin\",\n \"ukprn\": \"ukprn\",\n \"EstablishmentNumber\": \"est1234\",\n \"address\": \"Acia Lane\",\n \"telephone\": \"0123456888\",\n \"statutoryLowAge\": 4,\n \"statutoryHighAge\": 16, \n \"legacyId\": \"Leg123\",\n \"companyRegistrationNumber\": \"CRN123\",\n \"role\": {\n \"id\": 0,\n \"name\": \"End User\"\n }\n }\n ]" }, { "uuid": "808588ff-4654-49b4-93d0-56cb4731b561", diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/IUrnAmendmentRequestsService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/IUrnAmendmentRequestsService.cs new file mode 100644 index 00000000..01b8c64d --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/IUrnAmendmentRequestsService.cs @@ -0,0 +1,6 @@ +namespace DfE.CheckPerformanceData.Application.AmendmentRequests; + +public interface IUrnAmendmentRequestsService +{ + Task GetAllSubmittedAmendmentRequestsAsync(CancellationToken cancellationToken); +} diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/SubmittedRequestData.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/SubmittedRequestData.cs index f7dd0579..42eab672 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/SubmittedRequestData.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/SubmittedRequestData.cs @@ -11,4 +11,5 @@ public sealed class SubmittedRequestData public required string ReferenceNumber { get; init; } public required RequestStatus Status { get; init; } public required DateTime Submitted { get; init; } + public Guid WindowId { get; init; } } diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestDto.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestDto.cs new file mode 100644 index 00000000..94cccf02 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestDto.cs @@ -0,0 +1,15 @@ +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.Application.AmendmentRequests; + +public class UrnAmendmentRequestDto +{ + public required string PupilName { get; init; } + public required RequestType RequestType { get; init; } + public required string RequestTypeDescription { get; init; } + public required string ReferenceNumber { get; init; } + public required RequestStatus Status { get; init; } + public required DateTime Submitted { get; init; } + public string WindowName { get; init; } + public Guid WindowId { get; init; } +} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsResult.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsResult.cs new file mode 100644 index 00000000..a1ac3fa1 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsResult.cs @@ -0,0 +1,14 @@ +namespace DfE.CheckPerformanceData.Application.AmendmentRequests; + +public sealed class UrnAmendmentRequestsResult +{ + public required IReadOnlyList OpenWindows { get; init; } + public required IReadOnlyList SubmittedRows { get; init; } +} + +public class OpenWindow +{ + public string WindowName { get; init; } + public Guid WindowId { get; init; } + public DateTime WindowEndDate { get; init; } +} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs new file mode 100644 index 00000000..5b9fbb41 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs @@ -0,0 +1,48 @@ +using DfE.CheckPerformanceData.Application.CurrentUser; +using DfE.CheckPerformanceData.Application.RequestSubmission; +using DfE.CheckPerformanceData.Application.WindowManagement; + +namespace DfE.CheckPerformanceData.Application.AmendmentRequests; + +public sealed class UrnAmendmentRequestsService( + IRequestRepository requestRepository, + IWindowRepository windowRepository, + ICurrentUserService currentUserService) : IUrnAmendmentRequestsService +{ + public async Task GetAllSubmittedAmendmentRequestsAsync(CancellationToken cancellationToken) + { + + long urn = long.Parse(currentUserService.OrganisationUrn); + IReadOnlyList submitted = await requestRepository.GetAllSubmittedRequestsAsync(urn); + List allWindows = await windowRepository.GetAllWindowsAsync(cancellationToken); + List currentOpenWindows = allWindows + .Where(w => w.IsOpen) + .Select(w => new OpenWindow + { + WindowId = w.Id, + WindowName = w.Title, + WindowEndDate = w.EndDate + }) + .ToList(); + + return new UrnAmendmentRequestsResult + { + OpenWindows = currentOpenWindows, + SubmittedRows = submitted.Select(r => new UrnAmendmentRequestDto + { + PupilName = PupilNameFormatter.Format(r.PupilFirstname, r.PupilSurname), + RequestType = r.RequestType, + RequestTypeDescription = r.RequestTypeDescription, + ReferenceNumber = r.ReferenceNumber, + Status = r.Status, + Submitted = r.Submitted, + WindowId = r.WindowId, + WindowName = allWindows.Find(w => w.Id == r.WindowId)?.Title + }).ToList() + }; + } + + +} + + diff --git a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs index 8388bfd3..e7c224e5 100644 --- a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs +++ b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs @@ -64,6 +64,7 @@ public static IServiceCollection AddApplicationDependencies(this IServiceCollect // format check rather than throwing anywhere. services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs index 0a87b089..8ef02f80 100644 --- a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs @@ -12,6 +12,7 @@ public interface IRequestRepository Task UpsertAsync(ChangeRequestData data); Task> GetAmendmentRequestsAsync(Guid windowId, long organisationUrn); Task> GetSubmittedRequestsAsync(Guid windowId, long organisationUrn); + Task> GetAllSubmittedRequestsAsync(long organisationUrn); Task GetAmendmentRequestAsync(Guid windowId, long organisationUrn, string referenceNumber); Task GetConfirmDataCorrectAsync(Guid windowId, long organisationUrn, string referenceNumber); diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs index 8054ade1..7b55497f 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs @@ -30,6 +30,15 @@ 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 + { + DateTimeOffset now = DateTime.UtcNow; + return (StartDate <= now.DateTime && now.DateTime <= EndDate); + + } set; } public bool IsOpen { get; set; } public string TurnaroundCommitment { get; set; } = string.Empty; diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs index 320a8634..e0f0f01f 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs @@ -6,14 +6,8 @@ public class WindowService(IWindowRepository windowRepository, TimeProvider time { public async Task GetAllDataAsync(CancellationToken cancellationToken) { - DateTimeOffset now = timeProvider.GetLocalNow(); List windows = await windowRepository.GetAllWindowsAsync(cancellationToken); - foreach (CheckingWindowDto window in windows) - { - window.IsOpen = window.StartDate <= now.DateTime && now.DateTime <= window.EndDate; - } - return new PageResult { Windows = windows diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs index 43f0bc41..4449fa2b 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs @@ -249,6 +249,26 @@ await db.ChangeRequests .ToListAsync(); public async Task GetAmendmentRequestAsync(Guid windowId, long organisationUrn, string referenceNumber) => + public async Task> GetAllSubmittedRequestsAsync( + long organisationUrn) => + await db.ChangeRequests + .Where(r => r.OrganisationUrn == organisationUrn) + .OrderByDescending(r => r.Submitted) + .Select(r => new SubmittedRequestData + { + PupilFirstname = r.PupilFirstname, + PupilSurname = r.PupilSurname, + RequestType = r.RequestType, + RequestTypeDescription = r.RequestTypeDescription, + ReferenceNumber = r.ReferenceNumber, + Status = r.Status, + Submitted = r.Submitted, + WindowId = r.WindowId + }) + .ToListAsync(); + + public async Task GetAmendmentRequestAsync( + Guid windowId, long organisationUrn, string referenceNumber) => await db.ChangeRequests .Where(r => r.WindowId == windowId && r.OrganisationUrn == organisationUrn diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs index 1c368362..a9fc21d2 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs @@ -1,25 +1,36 @@ +using DfE.CheckPerformanceData.Application.AmendmentRequests; using DfE.CheckPerformanceData.Web.Controllers.ViewModels; using Microsoft.AspNetCore.Mvc; namespace DfE.CheckPerformanceData.Web.Controllers; -public class EstablishmentAmendmentRequestsController: Controller +public class EstablishmentAmendmentRequestsController(IUrnAmendmentRequestsService service): Controller { [Route("/establishment-amendment-requests")] - public async Task Index() + public async Task Index(CancellationToken cancellationToken) { - var viewModel = new EstablishmentAmendmentRequestsViewModel + UrnAmendmentRequestsResult result = await service.GetAllSubmittedAmendmentRequestsAsync(cancellationToken); + HashSet openWindowIds = result.OpenWindows.Select(w => w.WindowId).ToHashSet(); + EstablishmentAmendmentRequestsViewModel viewModel = new EstablishmentAmendmentRequestsViewModel { - ActiveWindows = new List() + ActiveWindows = result.OpenWindows.Select(w => new ActiveWindow { - new ActiveWindow() { WindowTitle = "dave", DeadlineText = "test"} - }, - Rows = new List + WindowId = w.WindowId.ToString(), + WindowTitle = w.WindowName, + DeadlineText = w.WindowEndDate.ToString("dd-MM-yyyy") + }).ToList(), + Rows = result.SubmittedRows.Select(r => new AmendmentItem { - new AmendmentItem { PupilName = "Row 1", ReferenceNumber = "Seedx001", RequestType = "Remove", Status = "Submitted", WindowName = "Window 1", DateSubmitted = "2023-01-01", WindowId = "1", WindowIsOpen = false}, - new AmendmentItem { PupilName = "Row 2", ReferenceNumber = "Seedx002", RequestType = "Delete", Status = "Withdraw", WindowName = "Window 1", DateSubmitted = "2023-01-01", WindowId = "2", WindowIsOpen = true} + PupilName = r.PupilName, + ReferenceNumber = r.ReferenceNumber, + RequestType = r.RequestType.ToString(), + Status = r.Status.ToString(), + DateSubmitted = r.Submitted.ToString("dd-MM-yyyy"), + WindowName = r.WindowName, + WindowId = r.WindowId.ToString(), + WindowIsOpen = openWindowIds.Contains(r.WindowId) + }).ToList() - } }; return View(viewModel); } diff --git a/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml index 0a911c74..f0d485c1 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml @@ -53,27 +53,17 @@ @row.WindowName @row.DateSubmitted - - + + View request @row.ReferenceNumber for @row.PupilName + + @if (row.WindowIsOpen) + { + + Edit request @row.ReferenceNumber for @row.PupilName + + } + + } } From c604cd44e7e61ca0b51684dd243bbb65b8a7e3e1 Mon Sep 17 00:00:00 2001 From: GG-DfE Date: Tue, 11 Aug 2026 23:32:59 +0100 Subject: [PATCH 3/7] updating as test breaking, moving from local time to uk/london time to reduce risk of wrong local time on container --- .../UrnAmendmentRequestsService.cs | 11 ++++++--- .../WindowManagement/IWindowService.cs | 8 +------ .../WindowManagement/WindowService.cs | 7 ++++++ .../Time/UkTime.cs | 24 +++++++++++++++++++ .../Extensions/LondonTime.cs | 9 ++++--- 5 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 src/DfE.CheckPerformanceData.Domain/Time/UkTime.cs diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs index 5b9fbb41..09111e48 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs @@ -1,22 +1,27 @@ using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Application.RequestSubmission; using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Time; namespace DfE.CheckPerformanceData.Application.AmendmentRequests; public sealed class UrnAmendmentRequestsService( IRequestRepository requestRepository, IWindowRepository windowRepository, - ICurrentUserService currentUserService) : IUrnAmendmentRequestsService + ICurrentUserService currentUserService, + TimeProvider timeProvider) : IUrnAmendmentRequestsService { public async Task GetAllSubmittedAmendmentRequestsAsync(CancellationToken cancellationToken) { - + long urn = long.Parse(currentUserService.OrganisationUrn); IReadOnlyList submitted = await requestRepository.GetAllSubmittedRequestsAsync(urn); List allWindows = await windowRepository.GetAllWindowsAsync(cancellationToken); + + // The repository returns raw rows, so IsOpen is unset here — evaluate against the clock. + DateTime now = UkTime.Now(timeProvider); List currentOpenWindows = allWindows - .Where(w => w.IsOpen) + .Where(w => w.IsOpenAt(now)) .Select(w => new OpenWindow { WindowId = w.Id, diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs index 7b55497f..c929dcd9 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs @@ -1,5 +1,6 @@ using DfE.CheckPerformanceData.Application.ResultsEnquiry; using DfE.CheckPerformanceData.Domain.Enums; +using DfE.CheckPerformanceData.Domain.Time; namespace DfE.CheckPerformanceData.Application.WindowManagement; @@ -32,13 +33,6 @@ public sealed class CheckingWindowDto public string SchemaFileChecksum { get; set; } = string.Empty; public bool Validated { get; set; } public DateTime? ValidatedAt { get; set; } - public bool IsOpen { - get - { - DateTimeOffset now = DateTime.UtcNow; - return (StartDate <= now.DateTime && now.DateTime <= EndDate); - - } set; } public bool IsOpen { get; set; } public string TurnaroundCommitment { get; set; } = string.Empty; diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs index e0f0f01f..878c315d 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs @@ -1,3 +1,4 @@ +using DfE.CheckPerformanceData.Domain.Time; using DfE.CheckPerformanceData.Domain.Enums; namespace DfE.CheckPerformanceData.Application.WindowManagement; @@ -6,8 +7,14 @@ public class WindowService(IWindowRepository windowRepository, TimeProvider time { public async Task GetAllDataAsync(CancellationToken cancellationToken) { + DateTime now = UkTime.Now(timeProvider); List windows = await windowRepository.GetAllWindowsAsync(cancellationToken); + foreach (CheckingWindowDto window in windows) + { + window.IsOpen = window.IsOpenAt(now); + } + return new PageResult { Windows = windows diff --git a/src/DfE.CheckPerformanceData.Domain/Time/UkTime.cs b/src/DfE.CheckPerformanceData.Domain/Time/UkTime.cs new file mode 100644 index 00000000..57b3d280 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Domain/Time/UkTime.cs @@ -0,0 +1,24 @@ +namespace DfE.CheckPerformanceData.Domain.Time; + +/// +/// UK wall-clock time. Checking window boundaries (StartDate/EndDate) are wall-clock +/// UK deadlines chosen by an admin — "closes 17:00" means 17:00 in London, not 17:00 UTC — so any +/// open/closed comparison has to be made against UK local time, not against a UTC instant. +/// +/// Deliberately does NOT use : that resolves to +/// , which is the host's zone. The deploy containers run UTC, so +/// GetLocalNow() is an hour out through BST while looking correct on a developer machine set to +/// London. +/// +public static class UkTime +{ + /// + /// The Europe/London zone — BST in summer, GMT in winter. The IANA id resolves on the Linux + /// deploy target and, via .NET's ICU mapping, on Windows too. Resolved once at type load. + /// + public static readonly TimeZoneInfo Zone = TimeZoneInfo.FindSystemTimeZoneById("Europe/London"); + + /// UK wall-clock now, taken from the supplied clock's UTC instant. + public static DateTime Now(TimeProvider clock) => + TimeZoneInfo.ConvertTimeFromUtc(clock.GetUtcNow().UtcDateTime, Zone); +} diff --git a/src/DfE.CheckPerformanceData.Web/Extensions/LondonTime.cs b/src/DfE.CheckPerformanceData.Web/Extensions/LondonTime.cs index 71aa71e1..9678611f 100644 --- a/src/DfE.CheckPerformanceData.Web/Extensions/LondonTime.cs +++ b/src/DfE.CheckPerformanceData.Web/Extensions/LondonTime.cs @@ -1,4 +1,5 @@ using System.Globalization; +using DfE.CheckPerformanceData.Domain.Time; namespace DfE.CheckPerformanceData.Web.Extensions; @@ -13,11 +14,9 @@ namespace DfE.CheckPerformanceData.Web.Extensions; /// public static class LondonTime { - /// - /// The Europe/London zone. The IANA id resolves on the Linux deploy target and, - /// via .NET's ICU mapping, on Windows too. Resolved once at type load. - /// - public static readonly TimeZoneInfo Zone = TimeZoneInfo.FindSystemTimeZoneById("Europe/London"); + /// The Europe/London zone. Single definition, shared with the domain's + /// so display and window-boundary logic can never diverge. + public static readonly TimeZoneInfo Zone = UkTime.Zone; /// Friendly format for service-user views, e.g. "24 Jun 2026 13:05". public const string Friendly = "d MMM yyyy HH:mm"; From f93a5358bed9f9efc644f37d49c25509825ccca5 Mon Sep 17 00:00:00 2001 From: GG-DfE Date: Wed, 12 Aug 2026 14:12:01 +0100 Subject: [PATCH 4/7] adding in closed window seed data --- .../AmendmentRequestsService.cs | 2 +- .../Seeding/SeedChangeRequests.cs | 109 ++++++++++++++---- .../Index.cshtml | 10 +- 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs index 07c91e48..b64b26be 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs @@ -49,7 +49,7 @@ public async Task GetAmendmentRequestsAsync(Guid window RequestTypeDescription = r.RequestTypeDescription, ReferenceNumber = r.ReferenceNumber, Status = r.Status, - Submitted = r.Submitted + Submitted = r.Submitted, }).ToList() }; } diff --git a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs index 96041f7f..6e844e7f 100644 --- a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs +++ b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs @@ -11,6 +11,10 @@ namespace DfE.CheckPerformanceData.Web.Seeding; // flow (ticking multiple ReadyToSubmit drafts, editing an InProgress draft, hitting the // already-submitted/duplicate-pupil warnings). Runs after SeedPupilData, which is where the // pupils referenced here come from. +// +// Two windows are seeded: the open KS4 June window (the in-flight scenarios above) and the +// closed KS4 June window from last year, with half as many rows, so the establishment-wide +// Amendment requests grid has both an editable and a read-only (window closed) window in it. public static class SeedChangeRequests { private const string Laestab = "860/4070"; // Kingsmead School @@ -32,11 +36,25 @@ public static class SeedChangeRequests // seeded rows read identically to genuine requests in the Amendment requests / bulk grids. private const string RequestTypeDescription = "Remove - " + ReasonLabel; + private readonly record struct Scenario(string Reference, RequestStatus Status, IPupilRecord Pupil); + public static async Task ExecuteSeedAsync( IPupilDataBlobClient pupilClient, IRequestRepository requestRepository, IRequestStateBlobClient requestStateBlobClient, ICheckYourPupilDataService checkYourPupilDataService) + { + await SeedOpenWindowAsync(pupilClient, requestRepository, requestStateBlobClient, checkYourPupilDataService); + await SeedClosedWindowAsync(pupilClient, requestRepository, requestStateBlobClient, checkYourPupilDataService); + } + + // The live KS4 June window: in-flight statuses, duplicate pairs and drafts, so the bulk + // submission and validation journeys can all be exercised. + private static async Task SeedOpenWindowAsync( + IPupilDataBlobClient pupilClient, + IRequestRepository requestRepository, + IRequestStateBlobClient requestStateBlobClient, + ICheckYourPupilDataService checkYourPupilDataService) { var windowId = DevDataSeeder.KeyStage4JuneCheckingWindowId; @@ -44,34 +62,85 @@ public static async Task ExecuteSeedAsync( var pupils = await pupilClient.GetPupilsAsync( windowId, CheckingExerciseType.PupilData, Laestab, CheckingWindowType.KS4June); if (pupils is null || pupils.Count == 0) return; + var p = await GetIncludedPupilsAsync(pupilClient, windowId, required: 9); + if (p is null) return; - var included = pupils - .Where(p => PupilInclusion.IsKs4Included(p.Pincl)) - .DistinctBy(p => p.Id) - .Take(9) - .ToList(); + var scenarios = new[] + { + new Scenario("CYPMD_KS4June_SEED001", RequestStatus.ReadyToSubmit, p[0]), + new Scenario("CYPMD_KS4June_SEED002", RequestStatus.ReadyToSubmit, p[1]), + new Scenario("CYPMD_KS4June_SEED003", RequestStatus.ReadyToSubmit, p[2]), + new Scenario("CYPMD_KS4June_SEED004", RequestStatus.ReadyToSubmit, p[3]), + new Scenario("CYPMD_KS4June_SEED005", RequestStatus.ReadyToSubmit, p[4]), + new Scenario("CYPMD_KS4June_SEED006", RequestStatus.ReadyToSubmit, p[5]), + new Scenario("CYPMD_KS4June_SEED007", RequestStatus.ReadyToSubmit, p[5]), // duplicate of SEED006 + new Scenario("CYPMD_KS4June_SEED008", RequestStatus.SubmittedUnCommitted, p[6]), + new Scenario("CYPMD_KS4June_SEED009", RequestStatus.ReadyToSubmit, p[6]), // duplicate of already-submitted SEED008 + new Scenario("CYPMD_KS4June_SEED010", RequestStatus.InProgress, p[7]), + new Scenario("CYPMD_KS4June_SEED011", RequestStatus.InProgress, p[8]) + }; - if (included.Count < 9) return; + await SeedScenariosAsync(requestRepository, requestStateBlobClient, checkYourPupilDataService, + windowId, scenarios, DateTime.UtcNow); + } - var window = await checkYourPupilDataService.GetCheckingWindowAsync(windowId); + // Last year's closed KS4 June window: half as many rows as the open window, and only the + // statuses a window can be left in once its requests have been committed — nothing stays + // InProgress / ReadyToSubmit / SubmittedUnCommitted after commit. Gives the establishment + // Amendment requests grid a window whose rows are view-only (no Edit link). + private static async Task SeedClosedWindowAsync( + IPupilDataBlobClient pupilClient, + IRequestRepository requestRepository, + IRequestStateBlobClient requestStateBlobClient, + ICheckYourPupilDataService checkYourPupilDataService) + { + var windowId = DevDataSeeder.ClosedKeyStage4JuneCheckingWindowId; - var p = included; // p[0]..p[8] + var p = await GetIncludedPupilsAsync(pupilClient, windowId, required: 6); + if (p is null) return; var scenarios = new[] { - (Reference: "CYPMD_KS4June_SEED001", Status: RequestStatus.ReadyToSubmit, Pupil: p[0]), - (Reference: "CYPMD_KS4June_SEED002", Status: RequestStatus.ReadyToSubmit, Pupil: p[1]), - (Reference: "CYPMD_KS4June_SEED003", Status: RequestStatus.ReadyToSubmit, Pupil: p[2]), - (Reference: "CYPMD_KS4June_SEED004", Status: RequestStatus.ReadyToSubmit, Pupil: p[3]), - (Reference: "CYPMD_KS4June_SEED005", Status: RequestStatus.ReadyToSubmit, Pupil: p[4]), - (Reference: "CYPMD_KS4June_SEED006", Status: RequestStatus.ReadyToSubmit, Pupil: p[5]), - (Reference: "CYPMD_KS4June_SEED007", Status: RequestStatus.ReadyToSubmit, Pupil: p[5]), // duplicate of SEED006 - (Reference: "CYPMD_KS4June_SEED008", Status: RequestStatus.SubmittedUnCommitted, Pupil: p[6]), - (Reference: "CYPMD_KS4June_SEED009", Status: RequestStatus.ReadyToSubmit, Pupil: p[6]), // duplicate of already-submitted SEED008 - (Reference: "CYPMD_KS4June_SEED010", Status: RequestStatus.InProgress, Pupil: p[7]), - (Reference: "CYPMD_KS4June_SEED011", Status: RequestStatus.InProgress, Pupil: p[8]) + new Scenario("CYPMD_KS4June_CLOSED001", RequestStatus.SubmittedCommitted, p[0]), + new Scenario("CYPMD_KS4June_CLOSED002", RequestStatus.SubmittedCommitted, p[1]), + new Scenario("CYPMD_KS4June_CLOSED003", RequestStatus.SubmittedCommitted, p[2]), + new Scenario("CYPMD_KS4June_CLOSED004", RequestStatus.Withdrawn, p[3]), + new Scenario("CYPMD_KS4June_CLOSED005", RequestStatus.NotSubmitted, p[4]), // draft left unsubmitted at commit + new Scenario("CYPMD_KS4June_CLOSED006", RequestStatus.NotSubmitted, p[5]) }; + // SeedCheckingWindows dates this window a year back, so the rows must be dated inside + // it — otherwise they sort above the live window's rows on the establishment grid. + await SeedScenariosAsync(requestRepository, requestStateBlobClient, checkYourPupilDataService, + windowId, scenarios, DateTime.UtcNow.AddYears(-1)); + } + + // Seeded change requests are KS4-only, so both dev windows read as KS4June. + private static async Task?> GetIncludedPupilsAsync( + IPupilDataBlobClient pupilClient, Guid windowId, int required) + { + var pupils = await pupilClient.GetPupilsAsync(windowId, Laestab, CheckingWindowType.KS4June); + if (pupils is null || pupils.Count == 0) return null; + + var included = pupils + .Where(p => PupilInclusion.IsKs4Included(p.Pincl)) + .DistinctBy(p => p.Id) + .Take(required) + .ToList(); + + return included.Count < required ? null : included; + } + + private static async Task SeedScenariosAsync( + IRequestRepository requestRepository, + IRequestStateBlobClient requestStateBlobClient, + ICheckYourPupilDataService checkYourPupilDataService, + Guid windowId, + IEnumerable scenarios, + DateTime timestamp) + { + var window = await checkYourPupilDataService.GetCheckingWindowAsync(windowId); + foreach (var scenario in scenarios) { await requestRepository.UpsertAsync(new ChangeRequestData @@ -83,7 +152,7 @@ await requestRepository.UpsertAsync(new ChangeRequestData PupilUpn = scenario.Pupil.Identifier, PupilFirstname = scenario.Pupil.Firstname, PupilSurname = scenario.Pupil.Surname, - Timestamp = DateTime.UtcNow, + Timestamp = timestamp, SubmittedById = SubmittedById, SubmittedByName = SubmittedByName, SubmittedByEmail = SubmittedByEmail, diff --git a/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml index f0d485c1..471fd9d1 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml @@ -53,15 +53,19 @@ @row.WindowName @row.DateSubmitted - - View request @row.ReferenceNumber for @row.PupilName - @if (row.WindowIsOpen) { Edit request @row.ReferenceNumber for @row.PupilName } + else + { + + View request @row.ReferenceNumber for @row.PupilName + + + } } From af5efba673d9806a83ae9c8dea513d71c2802a88 Mon Sep 17 00:00:00 2001 From: GG-DfE Date: Mon, 17 Aug 2026 14:30:16 +0100 Subject: [PATCH 5/7] updating to correct redirects --- ...stablishmentAmendmentRequestsController.cs | 78 +++++++++++ ...stablishmentAmendmentRequestsController.cs | 37 ------ .../SubmittedRequestController.cs | 17 ++- .../SubmittedRequestViewModel.cs | 1 + ...EstablishmentAmendmentRequestsViewModel.cs | 4 +- .../AmendmentRequests/UrnAmendmentView.cshtml | 122 ++++++++++++++++++ .../Index.cshtml | 22 ++-- .../Views/Shared/_Layout.cshtml | 14 +- .../Views/SubmittedRequest/View.cshtml | 11 +- 9 files changed, 237 insertions(+), 69 deletions(-) create mode 100644 src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/EstablishmentAmendmentRequestsController.cs delete mode 100644 src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs create mode 100644 src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/UrnAmendmentView.cshtml diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/EstablishmentAmendmentRequestsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/EstablishmentAmendmentRequestsController.cs new file mode 100644 index 00000000..f251c27f --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/EstablishmentAmendmentRequestsController.cs @@ -0,0 +1,78 @@ +using DfE.CheckPerformanceData.Application.AmendmentRequests; +using DfE.CheckPerformanceData.Web.Controllers.SubmittedRequest; +using DfE.CheckPerformanceData.Web.Controllers.ViewModels; +using DfE.CheckPerformanceData.Web.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Controllers; + +public class EstablishmentAmendmentRequestsController( + IUrnAmendmentRequestsService urnAmendmentRequestsService, + ISubmittedRequestService submittedRequestService): Controller +{ + [Route("/establishment-amendment-requests")] + public async Task Index(CancellationToken cancellationToken) + { + UrnAmendmentRequestsResult result = await urnAmendmentRequestsService.GetAllSubmittedAmendmentRequestsAsync(cancellationToken); + HashSet openWindowIds = result.OpenWindows.Select(w => w.WindowId).ToHashSet(); + EstablishmentAmendmentRequestsViewModel viewModel = new EstablishmentAmendmentRequestsViewModel + { + ActiveWindows = result.OpenWindows.Select(w => new ActiveWindow + { + WindowId = w.WindowId.ToString(), + WindowTitle = w.WindowName, + DeadlineText = w.WindowEndDate.ToString("dd-MM-yyyy") + }).ToList(), + Rows = result.SubmittedRows.Select(r => new AmendmentItem + { + PupilName = r.PupilName, + ReferenceNumber = r.ReferenceNumber, + RequestType = r.RequestType.ToString(), + Status = r.Status, + DateSubmitted = r.Submitted.ToString("dd-MM-yyyy"), + WindowName = r.WindowName, + WindowId = r.WindowId.ToString(), + WindowIsOpen = openWindowIds.Contains(r.WindowId) + }).ToList() + + }; + return View(viewModel); + } + + [Route("/establishment-amendment-requests/{windowId:guid}/view/{referenceNumber}")] + public async Task View(Guid windowId, string referenceNumber, CancellationToken cancellationToken) + { + SubmittedRequestView? request = await submittedRequestService.GetAsync(windowId, referenceNumber); + if (request == null) + { + return NotFound(); + } + + return View("~/Views/AmendmentRequests/UrnAmendmentView.cshtml", new SubmittedRequestViewModel + { + WindowId = windowId, + WhatToChange = request.WhatToChange, + Status = request.Status, + ConfirmingDelete = false, + PupilName = request.PupilName, + FirstRecordDisplay = request.FirstRecordDisplay, + SecondRecordDisplay = request.SecondRecordDisplay, + Rows = request.Rows.Select(r => new SubmittedRequestRow + { + Title = r.Title, + DisplayValue = r.DisplayValue + }).ToList(), + Files = request.Files.Select(f => new SubmittedRequestFile + { + OriginalFileName = f.OriginalFileName, + StoredFileName = f.StoredFileName, + FileSizeBytes = f.FileSizeBytes + }).ToList(), + ReferenceNumber = request.ReferenceNumber, + SubmittedByEmail = request.SubmittedByEmail, + SubmittedAt = request.SubmittedAt, + WithdrawnByEmail = request.WithdrawnByEmail, + WithdrawnAtText = LondonTime.ToSubmittedAtText(request.WithdrawnAt) + }); + } +} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs deleted file mode 100644 index a9fc21d2..00000000 --- a/src/DfE.CheckPerformanceData.Web/Controllers/EstablishmentAmendmentRequestsController.cs +++ /dev/null @@ -1,37 +0,0 @@ -using DfE.CheckPerformanceData.Application.AmendmentRequests; -using DfE.CheckPerformanceData.Web.Controllers.ViewModels; -using Microsoft.AspNetCore.Mvc; - -namespace DfE.CheckPerformanceData.Web.Controllers; - -public class EstablishmentAmendmentRequestsController(IUrnAmendmentRequestsService service): Controller -{ - [Route("/establishment-amendment-requests")] - public async Task Index(CancellationToken cancellationToken) - { - UrnAmendmentRequestsResult result = await service.GetAllSubmittedAmendmentRequestsAsync(cancellationToken); - HashSet openWindowIds = result.OpenWindows.Select(w => w.WindowId).ToHashSet(); - EstablishmentAmendmentRequestsViewModel viewModel = new EstablishmentAmendmentRequestsViewModel - { - ActiveWindows = result.OpenWindows.Select(w => new ActiveWindow - { - WindowId = w.WindowId.ToString(), - WindowTitle = w.WindowName, - DeadlineText = w.WindowEndDate.ToString("dd-MM-yyyy") - }).ToList(), - Rows = result.SubmittedRows.Select(r => new AmendmentItem - { - PupilName = r.PupilName, - ReferenceNumber = r.ReferenceNumber, - RequestType = r.RequestType.ToString(), - Status = r.Status.ToString(), - DateSubmitted = r.Submitted.ToString("dd-MM-yyyy"), - WindowName = r.WindowName, - WindowId = r.WindowId.ToString(), - WindowIsOpen = openWindowIds.Contains(r.WindowId) - }).ToList() - - }; - return View(viewModel); - } -} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestController.cs index feda84e7..6528c000 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestController.cs @@ -24,15 +24,15 @@ public Task ViewConfirmation(Guid windowId, string referenceNumbe // "Are you sure?" page for an amendment request — a confirm-mode rendering of View. [Route("/{windowId}/AmendmentRequests/{referenceNumber}/delete")] - public Task ConfirmDelete(Guid windowId, string referenceNumber) => - RenderAmendment(windowId, referenceNumber, confirmingDelete: true); + public Task ConfirmDelete(Guid windowId, string referenceNumber, bool allEst = false) => + RenderAmendment(windowId, referenceNumber, confirmingDelete: true, allEst); // "Are you sure?" page for a confirm-data request — a confirm-mode rendering of ViewConfirmation. [Route("/{windowId}/AmendmentRequests/{referenceNumber}/delete-confirmation")] public Task ConfirmDeleteConfirmation(Guid windowId, string referenceNumber) => RenderConfirmation(windowId, referenceNumber, confirmingDelete: true); - private async Task RenderAmendment(Guid windowId, string referenceNumber, bool confirmingDelete) + private async Task RenderAmendment(Guid windowId, string referenceNumber, bool confirmingDelete, bool allEst = false) { var request = await service.GetAsync(windowId, referenceNumber); if (request is null) @@ -62,7 +62,8 @@ private async Task RenderAmendment(Guid windowId, string referenc SubmittedByEmail = request.SubmittedByEmail, SubmittedAt = request.SubmittedAt, WithdrawnByEmail = request.WithdrawnByEmail, - WithdrawnAtText = LondonTime.ToSubmittedAtText(request.WithdrawnAt) + WithdrawnAtText = LondonTime.ToSubmittedAtText(request.WithdrawnAt), + AllEst = allEst }); } @@ -106,7 +107,7 @@ public async Task DownloadEvidence(Guid windowId, string referenc [HttpPost] [ValidateAntiForgeryToken] [Route("/{windowId}/AmendmentRequests/{referenceNumber}/delete")] - public async Task Delete(Guid windowId, string referenceNumber) + public async Task Delete(Guid windowId, string referenceNumber, bool allEst = false) { var result = await requestService.DeleteAsync(windowId, referenceNumber); @@ -127,6 +128,12 @@ public async Task Delete(Guid windowId, string referenceNumber) TempData["DeletedMessage"] = result.WasHardDeleted ? $"{result.PupilName} has been removed from your saved request" : $"{result.PupilName}(reference number - {referenceNumber}) has been removed from your submitted request."; + + if (allEst) + { + return RedirectToAction("Index", "EstablishmentAmendmentRequests"); + } + return RedirectToAction("Index", "AmendmentRequests", new { windowId }); } } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestViewModel.cs index f978326e..e18915c4 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/SubmittedRequest/SubmittedRequestViewModel.cs @@ -20,6 +20,7 @@ public sealed class SubmittedRequestViewModel public DateTime? SubmittedAt { get; init; } public string? WithdrawnByEmail { get; init; } public string WithdrawnAtText { get; init; } = string.Empty; + public bool AllEst { get; set; } public string WhatToChangeLabel => WhatToChange switch { diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs index 0c72d6bb..1b77ca97 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs @@ -1,3 +1,5 @@ +using DfE.CheckPerformanceData.Domain.Enums; + namespace DfE.CheckPerformanceData.Web.Controllers.ViewModels; public sealed class EstablishmentAmendmentRequestsViewModel @@ -18,7 +20,7 @@ public sealed class AmendmentItem { public string PupilName { get; init; } public string ReferenceNumber { get; init; } public string RequestType { get; init; } - public string Status { get; init; } + public RequestStatus Status { get; init; } public string WindowName { get; init; } public string DateSubmitted { get; init; } public string WindowId { get; init; } diff --git a/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/UrnAmendmentView.cshtml b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/UrnAmendmentView.cshtml new file mode 100644 index 00000000..68f33d0f --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/UrnAmendmentView.cshtml @@ -0,0 +1,122 @@ +@using DfE.CheckPerformanceData.Domain.Enums +@model DfE.CheckPerformanceData.Web.Controllers.SubmittedRequest.SubmittedRequestViewModel + +Back + +@if (Model.ConfirmingDelete) +{ +

    @Model.ConfirmDeleteTitle

    +
    + + + Warning + If you delete a request you cannot get it back again + +
    +} +else +{ +

    Submitted amendment request

    +

    Details for the @Model.WhatToChangeNoun of @Model.PupilName

    +} + +
    +
    +
    What pupil data would you like to change?
    +
    @Model.WhatToChangeLabel
    +
    + @if (Model.SecondRecordDisplay is not null) + { +
    +
    First record to merge
    +
    @Model.FirstRecordDisplay
    +
    +
    +
    Second record to merge
    +
    @Model.SecondRecordDisplay
    +
    + } + else + { +
    +
    Pupil name
    +
    @Model.PupilName
    +
    + } + @foreach (var row in Model.Rows) + { +
    +
    @row.Title
    +
    @row.DisplayValue
    +
    + } +
    + +@if (Model.Files.Count > 0) +{ +

    Uploaded documents

    + + + + + + + + + + @foreach (var file in Model.Files) + { + + + + + + } + +
    File nameFile sizeType
    + + @file.OriginalFileName + + @file.FormattedFileSize@file.FileType
    +} + +

    @Model.ByLineTitle

    +
    + @if (Model.Status == RequestStatus.Withdrawn) + { +
    +
    Email
    +
    @(Model.WithdrawnByEmail ?? "Email address is not available")
    +
    +
    +
    When
    +
    @Model.WithdrawnAtText
    +
    + } + else + { +
    +
    Email
    +
    @(Model.SubmittedByEmail ?? "Email address is not available")
    +
    +
    +
    When
    +
    @Model.SubmittedAtText
    +
    + } + @if (Model.ShowReferenceNumber) + { +
    +
    Reference Number
    +
    @Model.ReferenceNumber
    +
    + } +
    + +

    + Go back to amendment requests summary +

    diff --git a/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml index 471fd9d1..3fdc90fd 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml @@ -1,3 +1,4 @@ +@using DfE.CheckPerformanceData.Domain.Enums @model DfE.CheckPerformanceData.Web.Controllers.ViewModels.EstablishmentAmendmentRequestsViewModel Amendment requests @@ -45,29 +46,24 @@ @row.ReferenceNumber @row.RequestType - @* *@ - - @row.Status + + @row.Status.ToString() @row.WindowName @row.DateSubmitted + + View request @row.ReferenceNumber for @row.PupilName + @if (row.WindowIsOpen) { - - Edit request @row.ReferenceNumber for @row.PupilName + + Delete request @row.ReferenceNumber for @row.PupilName } - else - { - - View request @row.ReferenceNumber for @row.PupilName - - - } - + } } diff --git a/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml index 18eaa7d2..c55c9566 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml @@ -95,23 +95,15 @@ @{ var selectedWindowId = Context.Session.GetString("SelectedWindowId"); } -
  • - Guidance -
  • @if (User.Identity is { IsAuthenticated: true }) { - @if (selectedWindowId is not null) - { -
  • - Pupils -
  • - } -
  • Amendment requests
  • } +
  • + Guidance +
  • @* Sign in / Sign out cluster. States: - Real DfE auth: "Sign out" -> DfeSignOut diff --git a/src/DfE.CheckPerformanceData.Web/Views/SubmittedRequest/View.cshtml b/src/DfE.CheckPerformanceData.Web/Views/SubmittedRequest/View.cshtml index 39b66dc6..af6bd26d 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/SubmittedRequest/View.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/SubmittedRequest/View.cshtml @@ -20,7 +20,7 @@
    } -else +else if (!Model.AllEst) {

    Submitted amendment request

    Details for the @Model.WhatToChangeNoun of @Model.PupilName

    @@ -131,7 +131,14 @@ else

    - Cancel and go back to amendment requests summary + @if (!Model.AllEst) + { + Cancel and go back to amendment requests summary + } + else + { + Cancel and go back to amendment requests summary + }

    } else From eb4bb7586f96a02682ae678b35d54299896b2a7a Mon Sep 17 00:00:00 2001 From: GG-DfE Date: Wed, 19 Aug 2026 11:10:38 +0100 Subject: [PATCH 6/7] Updating timeout for issue in pipeline --- .../Pages/JourneyAutocompleteRestoreTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Pages/JourneyAutocompleteRestoreTests.cs b/tests/DfE.CheckPerformanceData.E2ETests/Pages/JourneyAutocompleteRestoreTests.cs index 9894f4f0..de86b10b 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/Pages/JourneyAutocompleteRestoreTests.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/Pages/JourneyAutocompleteRestoreTests.cs @@ -46,6 +46,7 @@ public async Task CountrySelection_SurvivesValidationErrorReload_AndBackNavigati { // No stale DEV-* conflict requests: a leftover conflict for Alice Smith would // divert the pupil-search step into the duplicate-attention banner. + Page.SetDefaultTimeout(60000); // 60 seconds instead of default 30 seconds await SeedHelpers.CleanupDevRequestsAsync(_fixture.SeedClient); await ImpersonateInBrowserAsync(); From 474c304c3ac11800be8a59479d46f9aa1d3e3ce3 Mon Sep 17 00:00:00 2001 From: GG-DfE Date: Tue, 25 Aug 2026 11:24:42 +0100 Subject: [PATCH 7/7] updating to main --- .../UrnAmendmentRequestsService.cs | 10 +- .../DependencyManager.cs | 3 + .../WindowManagement/IWindowService.cs | 6 +- .../WindowManagement/IWindowStatusService.cs | 51 ++++++++ .../WindowManagement/WindowService.cs | 12 +- .../Repositories/RequestRepository.cs | 1 - .../Controllers/AdminDashboardController.cs | 4 +- .../WindowAdmin/SummaryController.cs | 8 +- .../Controllers/WindowAdminController.cs | 21 +++- .../Seeding/SeedChangeRequests.cs | 111 ++++-------------- .../WindowStatusServiceTests.cs | 100 ++++++++++++++++ 11 files changed, 214 insertions(+), 113 deletions(-) create mode 100644 src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowStatusService.cs create mode 100644 tests/DfE.CheckPerformanceData.UnitTests/WindowManagement/WindowStatusServiceTests.cs diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs index 09111e48..3be6156e 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs @@ -1,7 +1,6 @@ using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Application.RequestSubmission; using DfE.CheckPerformanceData.Application.WindowManagement; -using DfE.CheckPerformanceData.Domain.Time; namespace DfE.CheckPerformanceData.Application.AmendmentRequests; @@ -9,7 +8,7 @@ public sealed class UrnAmendmentRequestsService( IRequestRepository requestRepository, IWindowRepository windowRepository, ICurrentUserService currentUserService, - TimeProvider timeProvider) : IUrnAmendmentRequestsService + IWindowStatusService windowStatusService) : IUrnAmendmentRequestsService { public async Task GetAllSubmittedAmendmentRequestsAsync(CancellationToken cancellationToken) { @@ -18,10 +17,9 @@ public async Task GetAllSubmittedAmendmentRequestsAs IReadOnlyList submitted = await requestRepository.GetAllSubmittedRequestsAsync(urn); List allWindows = await windowRepository.GetAllWindowsAsync(cancellationToken); - // The repository returns raw rows, so IsOpen is unset here — evaluate against the clock. - DateTime now = UkTime.Now(timeProvider); - List currentOpenWindows = allWindows - .Where(w => w.IsOpenAt(now)) + // This reads the repository directly rather than going through IWindowService, so asking the + // status service is the only way to get the same answer the admin pages get. + List currentOpenWindows = windowStatusService.OpenWindows(allWindows) .Select(w => new OpenWindow { WindowId = w.Id, diff --git a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs index e7c224e5..473a2c13 100644 --- a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs +++ b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs @@ -43,6 +43,9 @@ public static IServiceCollection AddApplicationDependencies(this IServiceCollect // #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(); + // #295435: the window-level twin — the single place that compares a window's outer 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. diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs index c929dcd9..2a656bd2 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs @@ -1,6 +1,5 @@ using DfE.CheckPerformanceData.Application.ResultsEnquiry; using DfE.CheckPerformanceData.Domain.Enums; -using DfE.CheckPerformanceData.Domain.Time; namespace DfE.CheckPerformanceData.Application.WindowManagement; @@ -33,9 +32,12 @@ public sealed class CheckingWindowDto 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; + // #295435: there is no IsOpen here. Open/closed is a question about the clock, and a DTO cannot + // read one without either a static call (untestable) or a caller-supplied now (which lets two + // callers disagree). Ask IWindowStatusService. + // #319: Validated / ValidatedAt are gone from here. A window is not validated as a whole — ask // a CheckingExerciseDto, or fold the answer across Exercises. diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowStatusService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowStatusService.cs new file mode 100644 index 00000000..a5259830 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowStatusService.cs @@ -0,0 +1,51 @@ +using DfE.CheckPerformanceData.Domain.Time; + +namespace DfE.CheckPerformanceData.Application.WindowManagement; + +/// +/// The only place in the solution that compares a checking window's outer dates against the clock. +/// The outer pair is the union of the window's exercises, and it alone decides whether the window is +/// open — whether or not any exercise inside it is. See docs/16-19-window-model.md. +/// +/// +/// The window-level twin of , and it keeps the same rule: time +/// comes from the injected and is never accepted from a caller, because +/// that is what stops two callers reading different clocks and disagreeing. Do not reintroduce a +/// window-level IsOpen flag — one that only some read paths populate reads as "closed" everywhere +/// else, which is exactly how the admin summary page came to print "Is Open: False" for every +/// window. +/// +/// Comparison is against rather than GetLocalNow: StartDate/EndDate are +/// wall-clock UK deadlines chosen by an admin — "closes 17:00" means 17:00 in London — and the +/// deploy containers run UTC, so a host-zone clock is an hour out through BST. +/// +/// Takes the window DTO rather than a date pair so no caller can hand over the two dates the wrong +/// way round. LandingPage has an unrelated class of the same name, so a file importing both +/// namespaces must alias one, as Persistence already does. +/// +public interface IWindowStatusService +{ + /// True when the window's outer pair brackets now. + bool IsOpen(CheckingWindowDto window); + + /// Every window open right now, input order preserved. Empty is a valid answer. + IReadOnlyList OpenWindows(IEnumerable windows); +} + +/// +public sealed class WindowStatusService(TimeProvider timeProvider) : IWindowStatusService +{ + public bool IsOpen(CheckingWindowDto window) => Brackets(window, UkTime.Now(timeProvider)); + + // One clock read for the whole list. Reading it per window would let a scan that happens to + // straddle a boundary answer for two windows against two different instants. + public IReadOnlyList OpenWindows(IEnumerable windows) + { + DateTime now = UkTime.Now(timeProvider); + return [.. windows.Where(w => Brackets(w, now))]; + } + + // Inclusive at both ends, matching how an exercise's own dates are compared. + private static bool Brackets(CheckingWindowDto window, DateTime now) => + window.StartDate <= now && window.EndDate >= now; +} diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs index 878c315d..4bee516b 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs @@ -1,20 +1,16 @@ -using DfE.CheckPerformanceData.Domain.Time; using DfE.CheckPerformanceData.Domain.Enums; namespace DfE.CheckPerformanceData.Application.WindowManagement; -public class WindowService(IWindowRepository windowRepository, TimeProvider timeProvider): IWindowService +// No clock here: this service reads and writes windows, and IWindowStatusService is the one place +// that compares their dates against now. Callers that need open/closed ask that service with the +// windows this one returns. +public class WindowService(IWindowRepository windowRepository): IWindowService { public async Task GetAllDataAsync(CancellationToken cancellationToken) { - DateTime now = UkTime.Now(timeProvider); List windows = await windowRepository.GetAllWindowsAsync(cancellationToken); - foreach (CheckingWindowDto window in windows) - { - window.IsOpen = window.IsOpenAt(now); - } - return new PageResult { Windows = windows diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs index 4449fa2b..abf77912 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs @@ -248,7 +248,6 @@ await db.ChangeRequests }) .ToListAsync(); - public async Task GetAmendmentRequestAsync(Guid windowId, long organisationUrn, string referenceNumber) => public async Task> GetAllSubmittedRequestsAsync( long organisationUrn) => await db.ChangeRequests diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/AdminDashboardController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/AdminDashboardController.cs index 21fd282b..6993466f 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/AdminDashboardController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/AdminDashboardController.cs @@ -15,6 +15,7 @@ namespace DfE.CheckPerformanceData.Web.Controllers; [RequireAdminSection(AdminNavKeys.Dashboard)] public sealed class AdminDashboardController( IWindowService windowService, + IWindowStatusService windowStatusService, IDashboardService dashboardService, IOptions settings) : Controller { @@ -24,8 +25,7 @@ public sealed class AdminDashboardController( public async Task Index(Guid? windowId, CancellationToken cancellationToken) { var all = await windowService.GetAllDataAsync(cancellationToken); - var openWindows = (all?.Windows ?? []) - .Where(w => w.IsOpen) + var openWindows = windowStatusService.OpenWindows(all?.Windows ?? []) .OrderBy(w => w.Title, StringComparer.OrdinalIgnoreCase) .ToList(); diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs index 3bd9e8a9..7b249d76 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs @@ -4,7 +4,9 @@ namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; -public sealed class SummaryController(IWindowService windowService): Controller +public sealed class SummaryController( + IWindowService windowService, + IWindowStatusService windowStatusService): Controller { [HttpGet("admin/windows/summary/{id:guid}")] @@ -21,6 +23,10 @@ public async Task Index(Guid id, CancellationToken cancellationTo WindowId = w.Id, Title = w.Title, TurnaroundCommitment = w.TurnaroundCommitment, + // #295435: assigned rather than left to default. GetByIdAsync never populated the old + // window-level IsOpen flag, so this page printed "Is Open: False" for every window, + // open or not. + IsOpen = windowStatusService.IsOpen(w), StartDate = w.StartDate, EndDate = w.EndDate, KeyStage = w.KeyStage, diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdminController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdminController.cs index 07dfca0e..621c6527 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdminController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdminController.cs @@ -5,15 +5,30 @@ namespace DfE.CheckPerformanceData.Web.Controllers; public sealed class WindowAdminController( - IWindowService windowService + IWindowService windowService, + IWindowStatusService windowStatusService ) : Controller { [HttpGet("admin/windows")] public async Task Index(CancellationToken cancellationToken) { PageResult? pageResult = await windowService.GetAllDataAsync(cancellationToken); - List windowListItems = pageResult?.Windows.Select(wli => new WindowListItem() {Id = wli.Id, Name = wli.Title, IsOpen = wli.IsOpen, IsPublished = true}).ToList()!; - WindowViewModel vm = new WindowViewModel(windowListItems!); + List windows = pageResult?.Windows ?? []; + + // Asked once for the whole list rather than per row, so every row on the page is answered + // against the same instant. + HashSet openWindowIds = windowStatusService.OpenWindows(windows).Select(w => w.Id).ToHashSet(); + + List windowListItems = windows + .Select(w => new WindowListItem + { + Id = w.Id, + Name = w.Title, + IsOpen = openWindowIds.Contains(w.Id), + IsPublished = true + }) + .ToList(); + WindowViewModel vm = new WindowViewModel(windowListItems); return View( vm ); } diff --git a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs index 6e844e7f..a2efa3e2 100644 --- a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs +++ b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs @@ -11,10 +11,6 @@ namespace DfE.CheckPerformanceData.Web.Seeding; // flow (ticking multiple ReadyToSubmit drafts, editing an InProgress draft, hitting the // already-submitted/duplicate-pupil warnings). Runs after SeedPupilData, which is where the // pupils referenced here come from. -// -// Two windows are seeded: the open KS4 June window (the in-flight scenarios above) and the -// closed KS4 June window from last year, with half as many rows, so the establishment-wide -// Amendment requests grid has both an editable and a read-only (window closed) window in it. public static class SeedChangeRequests { private const string Laestab = "860/4070"; // Kingsmead School @@ -36,25 +32,11 @@ public static class SeedChangeRequests // seeded rows read identically to genuine requests in the Amendment requests / bulk grids. private const string RequestTypeDescription = "Remove - " + ReasonLabel; - private readonly record struct Scenario(string Reference, RequestStatus Status, IPupilRecord Pupil); - public static async Task ExecuteSeedAsync( IPupilDataBlobClient pupilClient, IRequestRepository requestRepository, IRequestStateBlobClient requestStateBlobClient, ICheckYourPupilDataService checkYourPupilDataService) - { - await SeedOpenWindowAsync(pupilClient, requestRepository, requestStateBlobClient, checkYourPupilDataService); - await SeedClosedWindowAsync(pupilClient, requestRepository, requestStateBlobClient, checkYourPupilDataService); - } - - // The live KS4 June window: in-flight statuses, duplicate pairs and drafts, so the bulk - // submission and validation journeys can all be exercised. - private static async Task SeedOpenWindowAsync( - IPupilDataBlobClient pupilClient, - IRequestRepository requestRepository, - IRequestStateBlobClient requestStateBlobClient, - ICheckYourPupilDataService checkYourPupilDataService) { var windowId = DevDataSeeder.KeyStage4JuneCheckingWindowId; @@ -62,85 +44,34 @@ private static async Task SeedOpenWindowAsync( var pupils = await pupilClient.GetPupilsAsync( windowId, CheckingExerciseType.PupilData, Laestab, CheckingWindowType.KS4June); if (pupils is null || pupils.Count == 0) return; - var p = await GetIncludedPupilsAsync(pupilClient, windowId, required: 9); - if (p is null) return; - - var scenarios = new[] - { - new Scenario("CYPMD_KS4June_SEED001", RequestStatus.ReadyToSubmit, p[0]), - new Scenario("CYPMD_KS4June_SEED002", RequestStatus.ReadyToSubmit, p[1]), - new Scenario("CYPMD_KS4June_SEED003", RequestStatus.ReadyToSubmit, p[2]), - new Scenario("CYPMD_KS4June_SEED004", RequestStatus.ReadyToSubmit, p[3]), - new Scenario("CYPMD_KS4June_SEED005", RequestStatus.ReadyToSubmit, p[4]), - new Scenario("CYPMD_KS4June_SEED006", RequestStatus.ReadyToSubmit, p[5]), - new Scenario("CYPMD_KS4June_SEED007", RequestStatus.ReadyToSubmit, p[5]), // duplicate of SEED006 - new Scenario("CYPMD_KS4June_SEED008", RequestStatus.SubmittedUnCommitted, p[6]), - new Scenario("CYPMD_KS4June_SEED009", RequestStatus.ReadyToSubmit, p[6]), // duplicate of already-submitted SEED008 - new Scenario("CYPMD_KS4June_SEED010", RequestStatus.InProgress, p[7]), - new Scenario("CYPMD_KS4June_SEED011", RequestStatus.InProgress, p[8]) - }; - - await SeedScenariosAsync(requestRepository, requestStateBlobClient, checkYourPupilDataService, - windowId, scenarios, DateTime.UtcNow); - } - - // Last year's closed KS4 June window: half as many rows as the open window, and only the - // statuses a window can be left in once its requests have been committed — nothing stays - // InProgress / ReadyToSubmit / SubmittedUnCommitted after commit. Gives the establishment - // Amendment requests grid a window whose rows are view-only (no Edit link). - private static async Task SeedClosedWindowAsync( - IPupilDataBlobClient pupilClient, - IRequestRepository requestRepository, - IRequestStateBlobClient requestStateBlobClient, - ICheckYourPupilDataService checkYourPupilDataService) - { - var windowId = DevDataSeeder.ClosedKeyStage4JuneCheckingWindowId; - - var p = await GetIncludedPupilsAsync(pupilClient, windowId, required: 6); - if (p is null) return; - - var scenarios = new[] - { - new Scenario("CYPMD_KS4June_CLOSED001", RequestStatus.SubmittedCommitted, p[0]), - new Scenario("CYPMD_KS4June_CLOSED002", RequestStatus.SubmittedCommitted, p[1]), - new Scenario("CYPMD_KS4June_CLOSED003", RequestStatus.SubmittedCommitted, p[2]), - new Scenario("CYPMD_KS4June_CLOSED004", RequestStatus.Withdrawn, p[3]), - new Scenario("CYPMD_KS4June_CLOSED005", RequestStatus.NotSubmitted, p[4]), // draft left unsubmitted at commit - new Scenario("CYPMD_KS4June_CLOSED006", RequestStatus.NotSubmitted, p[5]) - }; - - // SeedCheckingWindows dates this window a year back, so the rows must be dated inside - // it — otherwise they sort above the live window's rows on the establishment grid. - await SeedScenariosAsync(requestRepository, requestStateBlobClient, checkYourPupilDataService, - windowId, scenarios, DateTime.UtcNow.AddYears(-1)); - } - - // Seeded change requests are KS4-only, so both dev windows read as KS4June. - private static async Task?> GetIncludedPupilsAsync( - IPupilDataBlobClient pupilClient, Guid windowId, int required) - { - var pupils = await pupilClient.GetPupilsAsync(windowId, Laestab, CheckingWindowType.KS4June); - if (pupils is null || pupils.Count == 0) return null; var included = pupils .Where(p => PupilInclusion.IsKs4Included(p.Pincl)) .DistinctBy(p => p.Id) - .Take(required) + .Take(9) .ToList(); - return included.Count < required ? null : included; - } + if (included.Count < 9) return; - private static async Task SeedScenariosAsync( - IRequestRepository requestRepository, - IRequestStateBlobClient requestStateBlobClient, - ICheckYourPupilDataService checkYourPupilDataService, - Guid windowId, - IEnumerable scenarios, - DateTime timestamp) - { var window = await checkYourPupilDataService.GetCheckingWindowAsync(windowId); + var p = included; // p[0]..p[8] + + var scenarios = new[] + { + (Reference: "CYPMD_KS4June_SEED001", Status: RequestStatus.ReadyToSubmit, Pupil: p[0]), + (Reference: "CYPMD_KS4June_SEED002", Status: RequestStatus.ReadyToSubmit, Pupil: p[1]), + (Reference: "CYPMD_KS4June_SEED003", Status: RequestStatus.ReadyToSubmit, Pupil: p[2]), + (Reference: "CYPMD_KS4June_SEED004", Status: RequestStatus.ReadyToSubmit, Pupil: p[3]), + (Reference: "CYPMD_KS4June_SEED005", Status: RequestStatus.ReadyToSubmit, Pupil: p[4]), + (Reference: "CYPMD_KS4June_SEED006", Status: RequestStatus.ReadyToSubmit, Pupil: p[5]), + (Reference: "CYPMD_KS4June_SEED007", Status: RequestStatus.ReadyToSubmit, Pupil: p[5]), // duplicate of SEED006 + (Reference: "CYPMD_KS4June_SEED008", Status: RequestStatus.SubmittedUnCommitted, Pupil: p[6]), + (Reference: "CYPMD_KS4June_SEED009", Status: RequestStatus.ReadyToSubmit, Pupil: p[6]), // duplicate of already-submitted SEED008 + (Reference: "CYPMD_KS4June_SEED010", Status: RequestStatus.InProgress, Pupil: p[7]), + (Reference: "CYPMD_KS4June_SEED011", Status: RequestStatus.InProgress, Pupil: p[8]) + }; + foreach (var scenario in scenarios) { await requestRepository.UpsertAsync(new ChangeRequestData @@ -152,7 +83,7 @@ await requestRepository.UpsertAsync(new ChangeRequestData PupilUpn = scenario.Pupil.Identifier, PupilFirstname = scenario.Pupil.Firstname, PupilSurname = scenario.Pupil.Surname, - Timestamp = timestamp, + Timestamp = DateTime.UtcNow, SubmittedById = SubmittedById, SubmittedByName = SubmittedByName, SubmittedByEmail = SubmittedByEmail, @@ -198,4 +129,4 @@ await requestRepository.UpsertAsync(new ChangeRequestData Laestab = p.Laestab, EntryDate = p.EntryDate }; -} +} \ No newline at end of file diff --git a/tests/DfE.CheckPerformanceData.UnitTests/WindowManagement/WindowStatusServiceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/WindowManagement/WindowStatusServiceTests.cs new file mode 100644 index 00000000..3242baf1 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.UnitTests/WindowManagement/WindowStatusServiceTests.cs @@ -0,0 +1,100 @@ +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.UnitTests.WindowManagement; + +/// +/// #295435: these cases moved off WindowServiceTests when the window-level IsOpen flag was deleted. +/// WindowService no longer reads a clock, so open/closed is asserted here instead. +/// +public class WindowStatusServiceTests +{ + private static readonly DateTimeOffset Winter = new(2026, 1, 24, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void IsOpen_is_true_when_the_window_brackets_now() + { + CheckingWindowDto window = Window( + Winter.DateTime.AddDays(-1), Winter.DateTime.AddDays(1)); + + Assert.True(Sut(Winter).IsOpen(window)); + } + + [Theory] + [InlineData(-10, -5)] // already ended + [InlineData(5, 10)] // not yet started + public void IsOpen_is_false_when_now_is_outside_the_window(int startOffsetDays, int endOffsetDays) + { + CheckingWindowDto window = Window( + Winter.DateTime.AddDays(startOffsetDays), Winter.DateTime.AddDays(endOffsetDays)); + + Assert.False(Sut(Winter).IsOpen(window)); + } + + // Inclusive at both ends, matching how an exercise's own dates are compared. + [Fact] + public void IsOpen_is_true_on_the_first_and_last_instant() + { + DateTime now = Winter.DateTime; + + Assert.True(Sut(Winter).IsOpen(Window(now, now.AddDays(1)))); + Assert.True(Sut(Winter).IsOpen(Window(now.AddDays(-1), now))); + } + + [Fact] + public void OpenWindows_returns_only_the_open_ones_in_input_order() + { + CheckingWindowDto ended = Window(Winter.DateTime.AddDays(-10), Winter.DateTime.AddDays(-5)); + CheckingWindowDto open = Window(Winter.DateTime.AddDays(-1), Winter.DateTime.AddDays(1)); + CheckingWindowDto future = Window(Winter.DateTime.AddDays(5), Winter.DateTime.AddDays(10)); + + IReadOnlyList result = Sut(Winter).OpenWindows([ended, open, future]); + + Assert.Equal([open.Id], result.Select(w => w.Id)); + } + + [Fact] + public void OpenWindows_of_an_empty_list_is_empty() + { + Assert.Empty(Sut(Winter).OpenWindows([])); + } + + /// + /// The reason this compares against UkTime rather than the UTC instant. A window closing at + /// 17:00 on a BST day is still open at 16:30 UK time — 15:30 UTC — and shut at 17:30 UK time. + /// Read against UTC, the second of these would wrongly still be open. + /// + [Fact] + public void IsOpen_reads_the_deadline_as_UK_wall_clock_through_BST() + { + // 1 July 2026 is BST, so UK wall-clock is UTC+1. + CheckingWindowDto window = Window( + new DateTime(2026, 7, 1, 9, 0, 0), new DateTime(2026, 7, 1, 17, 0, 0)); + + DateTimeOffset halfPastFourUk = new(2026, 7, 1, 15, 30, 0, TimeSpan.Zero); + DateTimeOffset halfPastFiveUk = new(2026, 7, 1, 16, 30, 0, TimeSpan.Zero); + + Assert.True(Sut(halfPastFourUk).IsOpen(window)); + Assert.False(Sut(halfPastFiveUk).IsOpen(window)); + } + + private static WindowStatusService Sut(DateTimeOffset utcNow) => new(new StubTimeProvider(utcNow)); + + private static CheckingWindowDto Window(DateTime startDate, DateTime endDate) => + new() + { + Id = Guid.NewGuid(), + Title = "Window", + StartDate = startDate, + EndDate = endDate, + KeyStage = KeyStages.KS2, + CheckingWindowType = CheckingWindowType.KS2 + }; + + // LocalTimeZone is deliberately left at the host's zone: UkTime must not consult it, and a stub + // that pinned it to UTC would hide a regression back to GetLocalNow(). + private sealed class StubTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } +}