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/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.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..3be6156e --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs @@ -0,0 +1,51 @@ +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, + IWindowStatusService windowStatusService) : 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); + + // 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, + 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..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. @@ -64,6 +67,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..2a656bd2 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs @@ -30,9 +30,14 @@ 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 IsOpen { get; set; } + public bool Validated { get; set; } + public DateTime? ValidatedAt { 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 320a8634..4bee516b 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs @@ -2,18 +2,15 @@ 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) { - 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.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.Persistence/Repositories/RequestRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs index 43f0bc41..abf77912 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs @@ -248,7 +248,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/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/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/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 new file mode 100644 index 00000000..1b77ca97 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/EstablishmentAmendmentRequestsViewModel.cs @@ -0,0 +1,28 @@ +using DfE.CheckPerformanceData.Domain.Enums; + +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 RequestStatus 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/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/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"; diff --git a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs index 96041f7f..a2efa3e2 100644 --- a/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs +++ b/src/DfE.CheckPerformanceData.Web/Seeding/SeedChangeRequests.cs @@ -129,4 +129,4 @@ await requestRepository.UpsertAsync(new ChangeRequestData Laestab = p.Laestab, EntryDate = p.EntryDate }; -} +} \ No newline at end of file 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 new file mode 100644 index 00000000..3fdc90fd --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Views/EstablishmentAmendmentRequests/Index.cshtml @@ -0,0 +1,70 @@ +@using DfE.CheckPerformanceData.Domain.Enums +@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.ToString() + + @row.WindowName@row.DateSubmitted + + View request @row.ReferenceNumber for @row.PupilName + + @if (row.WindowIsOpen) + { + + Delete request @row.ReferenceNumber for @row.PupilName + + } +
\ 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..c55c9566 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml @@ -95,18 +95,15 @@ @{ var selectedWindowId = Context.Session.GetString("SelectedWindowId"); } -
  • - Guidance -
  • - @if (User.Identity is { IsAuthenticated: true } && selectedWindowId is not null) + @if (User.Identity is { IsAuthenticated: true }) {
  • - Pupils -
  • -
  • - Amendment requests + 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 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(); 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; + } +}