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 125ac963..8bad6f4f 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs @@ -35,7 +35,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..09111e48 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs @@ -0,0 +1,53 @@ +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, + 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.IsOpenAt(now)) + .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 344c23d9..9bc9bdf1 100644 --- a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs +++ b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs @@ -58,6 +58,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 eea084e3..43289156 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs @@ -1,4 +1,5 @@ using DfE.CheckPerformanceData.Domain.Enums; +using DfE.CheckPerformanceData.Domain.Time; namespace DfE.CheckPerformanceData.Application.WindowManagement; @@ -32,6 +33,13 @@ public sealed class CheckingWindowDto public DateTime? ValidatedAt { get; set; } public bool IsOpen { get; set; } + /// + /// Whether the window is open at the given moment. must be UK + /// wall-clock time () — Start/End are admin-chosen UK deadlines stored + /// without a zone, so comparing them to a UTC instant is an hour out through BST. + /// + public bool IsOpenAt(DateTime ukNow) => StartDate <= ukNow && ukNow <= EndDate; + /// /// The CSV + schema pairs ingested for this window, in sort order. A Post16 window has two /// (included + non-included); every other type has one. The legacy scalar diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs index 9c6e183e..521d8704 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs @@ -1,15 +1,17 @@ +using DfE.CheckPerformanceData.Domain.Time; + namespace DfE.CheckPerformanceData.Application.WindowManagement; public class WindowService(IWindowRepository windowRepository, TimeProvider timeProvider): IWindowService { public async Task GetAllDataAsync(CancellationToken cancellationToken) { - DateTimeOffset now = timeProvider.GetLocalNow(); + DateTime now = UkTime.Now(timeProvider); List windows = await windowRepository.GetAllWindowsAsync(cancellationToken); foreach (CheckingWindowDto window in windows) { - window.IsOpen = window.StartDate <= now.DateTime && now.DateTime <= window.EndDate; + window.IsOpen = window.IsOpenAt(now); } return new PageResult 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 144ff3e1..74f9c923 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs @@ -291,6 +291,24 @@ await db.ChangeRequests }) .ToListAsync(); + 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 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 fe34c40f..ff8a78e5 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/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 d1e93623..48796e72 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,45 +36,107 @@ 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; - // Seeded change requests are KS4-only; the window above is the KS4 June dev window. + 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; + if (pupils is null || pupils.Count == 0) return null; var included = pupils .Where(p => PupilInclusion.IsKs4Included(p.Pincl)) .DistinctBy(p => p.Id) - .Take(9) + .Take(required) .ToList(); - if (included.Count < 9) return; + 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); - 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 @@ -82,7 +148,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/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();