From 218aa9afbb3687b24ca43510f6dd493b023e6583 Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Wed, 23 Sep 2026 10:50:16 -0700 Subject: [PATCH 1/5] VPR-62 feat(student): Add backend Career Selection code --- .editorconfig | 6 + .gitignore | 3 + .jscpd.json | 4 +- .../DbUpdateExceptionExtensionsTests.cs | 26 + .../Utilities}/ExcelHelperTests.cs | 25 +- .../CareerSelectionControllerTests.cs | 831 +++++++++++++++++ .../CareerSelectionExportRequestTests.cs | 90 ++ test/Students/CareerSelectionMapperTests.cs | 124 +++ .../CareerSelectionOptionServiceTests.cs | 321 +++++++ test/Students/CareerSelectionServiceTests.cs | 622 +++++++++++++ test/Students/DvmStudentLookupServiceTests.cs | 208 +++++ test/Students/EmergencyContactServiceTests.cs | 32 +- test/Students/ExportServiceTests.cs | 309 +++++++ test/Students/StudentAppAccessServiceTests.cs | 191 ++++ test/Students/TestableAAUDContext.cs | 25 + .../Constants/CareerSelectionPermissions.cs | 28 + .../Constants/EmergencyContactPermissions.cs | 2 +- web/Areas/Students/Constants/StudentRoles.cs | 7 + .../Controllers/CareerSelectionController.cs | 459 ++++++++++ .../Controllers/EmergencyContactController.cs | 46 +- .../Students/Models/CareerDropdownOption.cs | 10 + web/Areas/Students/Models/CareerOptionType.cs | 33 + .../Models/CareerSelectionExportRequest.cs | 47 + .../Students/Models/CareerSelectionMapper.cs | 42 + .../Models/CareerSelectionOptionDto.cs | 17 + .../Models/CareerSelectionOptionRequest.cs | 14 + .../CareerSelectionOptionWriteResult.cs | 34 + .../Students/Models/Entities/CareerOption.cs | 18 + .../Models/Entities/CareerSelection.cs | 33 + .../Models/Entities/ICareerSelectionOption.cs | 16 + .../Models/Entities/PostGradOption.cs | 18 + .../Students/Models/Entities/SpeciesOption.cs | 18 + web/Areas/Students/Models/MentorOptionDto.cs | 15 + .../Students/Models/StudentCareerDetailDto.cs | 15 + .../Students/Models/StudentCareerInfoDto.cs | 32 + .../Models/StudentCareerListItemDto.cs | 16 + .../Students/Models/StudentCareerReportDto.cs | 16 + .../Students/Models/StudentCareerRowDto.cs | 15 + .../Scripts/CareerSelectionDataAnalysis.cs | 837 ++++++++++++++++++ .../Scripts/CareerSelectionMigration.csproj | 27 + .../Scripts/CareerSelectionScriptHelper.cs | 562 ++++++++++++ .../Scripts/MigrateCareerSelectionData.cs | 769 ++++++++++++++++ web/Areas/Students/Scripts/Program.cs | 58 ++ web/Areas/Students/Scripts/RunAnalysis.bat | 98 ++ web/Areas/Students/Scripts/RunMigrateData.bat | 117 +++ .../Services/CareerSelectionExportService.cs | 307 +++++++ .../Services/CareerSelectionOptionService.cs | 293 ++++++ .../Students/Services/CareerSelectionScope.cs | 17 + .../Services/CareerSelectionService.cs | 679 ++++++++++++++ .../Services/DvmStudentLookupService.cs | 126 +++ .../Services/EmergencyContactExportService.cs | 259 ++---- .../Services/EmergencyContactService.cs | 198 +---- .../Services/ICareerSelectionOptionService.cs | 42 + .../Services/ICareerSelectionService.cs | 78 ++ .../Services/IDvmStudentLookupService.cs | 38 + .../Services/IStudentAppAccessService.cs | 27 + .../Services/StudentAppAccessService.cs | 107 +++ .../Students/Services/StudentExportHelper.cs | 78 ++ .../Students/Services/StudentListAccess.cs | 55 ++ web/Classes/ApiController.cs | 36 + web/Classes/SQLContext/StudentsContext.cs | 91 ++ web/Classes/Utilities/CsvExportHelper.cs | 46 + .../Utilities/DbUpdateExceptionExtensions.cs | 13 + web/Classes/Utilities/ExcelHelper.cs | 19 +- .../Utilities/PdfAccessibilityHelper.cs | 23 + web/Classes/Utilities/PersonSearchHelper.cs | 13 +- web/Viper.csproj | 4 + 67 files changed, 8370 insertions(+), 415 deletions(-) rename test/{Effort => Classes/Utilities}/ExcelHelperTests.cs (85%) create mode 100644 test/Students/CareerSelectionControllerTests.cs create mode 100644 test/Students/CareerSelectionExportRequestTests.cs create mode 100644 test/Students/CareerSelectionMapperTests.cs create mode 100644 test/Students/CareerSelectionOptionServiceTests.cs create mode 100644 test/Students/CareerSelectionServiceTests.cs create mode 100644 test/Students/DvmStudentLookupServiceTests.cs create mode 100644 test/Students/ExportServiceTests.cs create mode 100644 test/Students/StudentAppAccessServiceTests.cs create mode 100644 test/Students/TestableAAUDContext.cs create mode 100644 web/Areas/Students/Constants/CareerSelectionPermissions.cs create mode 100644 web/Areas/Students/Constants/StudentRoles.cs create mode 100644 web/Areas/Students/Controllers/CareerSelectionController.cs create mode 100644 web/Areas/Students/Models/CareerDropdownOption.cs create mode 100644 web/Areas/Students/Models/CareerOptionType.cs create mode 100644 web/Areas/Students/Models/CareerSelectionExportRequest.cs create mode 100644 web/Areas/Students/Models/CareerSelectionMapper.cs create mode 100644 web/Areas/Students/Models/CareerSelectionOptionDto.cs create mode 100644 web/Areas/Students/Models/CareerSelectionOptionRequest.cs create mode 100644 web/Areas/Students/Models/CareerSelectionOptionWriteResult.cs create mode 100644 web/Areas/Students/Models/Entities/CareerOption.cs create mode 100644 web/Areas/Students/Models/Entities/CareerSelection.cs create mode 100644 web/Areas/Students/Models/Entities/ICareerSelectionOption.cs create mode 100644 web/Areas/Students/Models/Entities/PostGradOption.cs create mode 100644 web/Areas/Students/Models/Entities/SpeciesOption.cs create mode 100644 web/Areas/Students/Models/MentorOptionDto.cs create mode 100644 web/Areas/Students/Models/StudentCareerDetailDto.cs create mode 100644 web/Areas/Students/Models/StudentCareerInfoDto.cs create mode 100644 web/Areas/Students/Models/StudentCareerListItemDto.cs create mode 100644 web/Areas/Students/Models/StudentCareerReportDto.cs create mode 100644 web/Areas/Students/Models/StudentCareerRowDto.cs create mode 100644 web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs create mode 100644 web/Areas/Students/Scripts/CareerSelectionMigration.csproj create mode 100644 web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs create mode 100644 web/Areas/Students/Scripts/MigrateCareerSelectionData.cs create mode 100644 web/Areas/Students/Scripts/Program.cs create mode 100644 web/Areas/Students/Scripts/RunAnalysis.bat create mode 100644 web/Areas/Students/Scripts/RunMigrateData.bat create mode 100644 web/Areas/Students/Services/CareerSelectionExportService.cs create mode 100644 web/Areas/Students/Services/CareerSelectionOptionService.cs create mode 100644 web/Areas/Students/Services/CareerSelectionScope.cs create mode 100644 web/Areas/Students/Services/CareerSelectionService.cs create mode 100644 web/Areas/Students/Services/DvmStudentLookupService.cs create mode 100644 web/Areas/Students/Services/ICareerSelectionOptionService.cs create mode 100644 web/Areas/Students/Services/ICareerSelectionService.cs create mode 100644 web/Areas/Students/Services/IDvmStudentLookupService.cs create mode 100644 web/Areas/Students/Services/IStudentAppAccessService.cs create mode 100644 web/Areas/Students/Services/StudentAppAccessService.cs create mode 100644 web/Areas/Students/Services/StudentExportHelper.cs create mode 100644 web/Areas/Students/Services/StudentListAccess.cs create mode 100644 web/Classes/Utilities/CsvExportHelper.cs diff --git a/.editorconfig b/.editorconfig index 88d7db3db..d0080c6a7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -178,3 +178,9 @@ dotnet_diagnostic.CA1505.severity = none # ApiPaginationAttribute.OnActionExecuting always constructs the object and overwrites the action argument [web/Models/ApiPagination.cs] dotnet_diagnostic.S6964.severity = none + +# S6964: IsOther is bound as part of the save request but never trusted — ValidateSelectionAsync +# re-reads it from the option table before the mapper consumes it, so a client value cannot +# influence what is stored +[web/Areas/Students/Models/CareerDropdownOption.cs] +dotnet_diagnostic.S6964.severity = none diff --git a/.gitignore b/.gitignore index 174d32e0d..28d3590bd 100644 --- a/.gitignore +++ b/.gitignore @@ -511,6 +511,9 @@ web/Areas/Effort/Scripts/Effort_Database_Schema_And_Data_LEGACY.txt # PhoneLists migration script outputs web/Areas/Personnel/Scripts/AnalysisOutput/ +# Career Selection migration script outputs +web/Areas/Students/Scripts/AnalysisOutput/ + # Code-quality tool outputs (fallow cache + jscpd + ReSharper reports) .fallow/ VueApp/.fallow/ diff --git a/.jscpd.json b/.jscpd.json index 7cd5a8240..41906a9b5 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -6,7 +6,9 @@ "**/coverage/**", "**/bin/**", "**/obj/**", - "**/Effort/Scripts/**" + "**/Effort/Scripts/**", + "**/Personnel/Scripts/**", + "**/Students/Scripts/**" ], "ignorePattern": [ "^\\s*import\\s+.*from\\s+['\"][^'\"]+['\"]\\s*;?\\s*$" diff --git a/test/Classes/Utilities/DbUpdateExceptionExtensionsTests.cs b/test/Classes/Utilities/DbUpdateExceptionExtensionsTests.cs index 0400f3ebf..a29767ff6 100644 --- a/test/Classes/Utilities/DbUpdateExceptionExtensionsTests.cs +++ b/test/Classes/Utilities/DbUpdateExceptionExtensionsTests.cs @@ -52,4 +52,30 @@ public void IsDataRejection_IsFalse_WhenTheInnerExceptionIsNotFromSqlServer() Assert.False(ex.IsDataRejection()); } + + [Theory] + [InlineData(2601)] // duplicate key in a unique index + [InlineData(2627)] // unique or primary key constraint violation + public void IsUniqueKeyViolationNumber_IsTrue_ForUniqueKeyErrors(int number) + { + Assert.True(DbUpdateExceptionExtensions.IsUniqueKeyViolationNumber(number)); + } + + [Theory] + [InlineData(547)] // foreign key - a retry against the winning row would fail the same way + [InlineData(8152)] // truncation - likewise + [InlineData(2628)] + [InlineData(1205)] + public void IsUniqueKeyViolationNumber_IsFalse_ForOtherErrors(int number) + { + Assert.False(DbUpdateExceptionExtensions.IsUniqueKeyViolationNumber(number)); + } + + [Fact] + public void IsUniqueKeyViolation_IsFalse_WhenTheInnerExceptionIsNotFromSqlServer() + { + var ex = new DbUpdateException("Something failed", new TimeoutException("The wait expired")); + + Assert.False(ex.IsUniqueKeyViolation()); + } } diff --git a/test/Effort/ExcelHelperTests.cs b/test/Classes/Utilities/ExcelHelperTests.cs similarity index 85% rename from test/Effort/ExcelHelperTests.cs rename to test/Classes/Utilities/ExcelHelperTests.cs index 45e61d6be..8ae1f647c 100644 --- a/test/Effort/ExcelHelperTests.cs +++ b/test/Classes/Utilities/ExcelHelperTests.cs @@ -1,6 +1,6 @@ using Viper.Classes.Utilities; -namespace Viper.test.Effort; +namespace Viper.test.Classes.Utilities; /// /// Unit tests for ExcelHelper utility methods. @@ -33,6 +33,29 @@ public void SanitizeStringCell_PrefixesAtSign() Assert.Equal("'@SUM", ExcelHelper.SanitizeStringCell("@SUM")); } + [Theory] + [InlineData("\t=SUM(A1)")] + [InlineData("\r=SUM(A1)")] + [InlineData("\n+cmd")] + [InlineData("\r\n-calculation")] + [InlineData(" @SUM")] + [InlineData(" \t\r\n=HYPERLINK(\"x\")")] + public void SanitizeStringCell_PrefixesFormulaBehindLeadingWhitespace(string value) + { + Assert.Equal("'" + value, ExcelHelper.SanitizeStringCell(value)); + } + + [Theory] + [InlineData(" Normal text")] + [InlineData("\tIndented")] + [InlineData("\r\nSecond line")] + [InlineData(" ")] + [InlineData("Ends with =")] + public void SanitizeStringCell_LeavesNonFormulaTextUnchanged(string value) + { + Assert.Equal(value, ExcelHelper.SanitizeStringCell(value)); + } + [Fact] public void SanitizeStringCell_LeavesNormalTextUnchanged() { diff --git a/test/Students/CareerSelectionControllerTests.cs b/test/Students/CareerSelectionControllerTests.cs new file mode 100644 index 000000000..dccdcaebe --- /dev/null +++ b/test/Students/CareerSelectionControllerTests.cs @@ -0,0 +1,831 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using NSubstitute.ReturnsExtensions; +using Viper.Areas.Students.Constants; +using Viper.Areas.Students.Controllers; +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Students; + +/// +/// Tests for CareerSelectionController: the authorization it applies itself (which students a +/// caller may see, and whose record they may open), and how service outcomes map to responses. +/// +public class CareerSelectionControllerTests +{ + private readonly ICareerSelectionService _service; + private readonly ICareerSelectionOptionService _optionService; + private readonly ICareerSelectionExportService _exportService; + private readonly RAPSContext _rapsContext; + private readonly IUserHelper _userHelper; + private readonly CareerSelectionController _controller; + + public CareerSelectionControllerTests() + { + _service = Substitute.For(); + _optionService = Substitute.For(); + _exportService = Substitute.For(); + _rapsContext = Substitute.For(); + _userHelper = Substitute.For(); + _controller = new CareerSelectionController( + _service, _optionService, _exportService, _rapsContext, _userHelper, + Substitute.For>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + } + + #region Student list and report + + [Fact] + public async Task GetStudentCareerList_AdminScope_RequestsEveryStudent() + { + var user = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(user); + _service.ResolveScope(user).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents) + .Returns([new StudentCareerListItemDto { PersonId = 5, FullName = "Student, Test" }]); + + var result = await _controller.GetStudentCareerList(); + + var okResult = Assert.IsType(result.Result); + Assert.Single(Assert.IsType>(okResult.Value)); + } + + [Fact] + public async Task GetStudentCareerList_FacultyScope_NarrowsToTheirMentees() + { + var faculty = CreateUser(2, "faculty", "FAC001"); + _userHelper.GetCurrentUser().Returns(faculty); + _service.ResolveScope(faculty).Returns(CareerSelectionScope.Mentored); + _service.GetStudentCareerListAsync(StudentListAccess.MentoredBy("FAC001")).Returns([]); + + await _controller.GetStudentCareerList(); + + await _service.Received(1).GetStudentCareerListAsync(StudentListAccess.MentoredBy("FAC001")); + await _service.DidNotReceive().GetStudentCareerListAsync(StudentListAccess.AllStudents); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetStudentCareerList_FacultyWithNoMothraId_IsRefusedRatherThanShownEveryone(string? mothraId) + { + // A faculty member we cannot identify mentors nobody. The roster must fail closed, the + // same way IsMentorOfAsync refuses a blank mentor on the single-record path. + var faculty = CreateUser(2, "faculty", mothraId!); + _userHelper.GetCurrentUser().Returns(faculty); + _service.ResolveScope(faculty).Returns(CareerSelectionScope.Mentored); + + var result = await _controller.GetStudentCareerList(); + + AssertForbidden(result.Result); + await _service.DidNotReceive().GetStudentCareerListAsync(Arg.Any()); + } + + [Fact] + public async Task GetStudentCareerList_StudentScope_ReturnsForbid() + { + // A student has a record but no roster: their own page is reached directly. + var student = CreateUser(3, "student", "STU001"); + _userHelper.GetCurrentUser().Returns(student); + _service.ResolveScope(student).Returns(CareerSelectionScope.Own); + + var result = await _controller.GetStudentCareerList(); + + AssertForbidden(result.Result); + await _service.DidNotReceive().GetStudentCareerListAsync(Arg.Any()); + } + + [Fact] + public async Task GetStudentCareerList_NoUser_ReturnsForbid() + { + _userHelper.GetCurrentUser().ReturnsNull(); + + var result = await _controller.GetStudentCareerList(); + + AssertForbidden(result.Result); + } + + [Fact] + public async Task GetStudentCareerReport_FacultyScope_NarrowsToTheirMentees() + { + var faculty = CreateUser(2, "faculty", "FAC001"); + _userHelper.GetCurrentUser().Returns(faculty); + _service.ResolveScope(faculty).Returns(CareerSelectionScope.Mentored); + _service.GetStudentCareerReportAsync(StudentListAccess.MentoredBy("FAC001")).Returns([]); + + await _controller.GetStudentCareerReport(); + + await _service.Received(1).GetStudentCareerReportAsync(StudentListAccess.MentoredBy("FAC001")); + } + + [Fact] + public async Task GetStudentCareerReport_NoScope_ReturnsForbid() + { + var user = CreateUser(4, "nobody", "NONE001"); + _userHelper.GetCurrentUser().Returns(user); + _service.ResolveScope(user).Returns(CareerSelectionScope.None); + + var result = await _controller.GetStudentCareerReport(); + + AssertForbidden(result.Result); + await _service.DidNotReceive().GetStudentCareerReportAsync(Arg.Any()); + } + + #endregion + + #region GetStudentCareerDetail + + [Fact] + public async Task GetStudentCareerDetail_NoUser_ReturnsUnauthorized() + { + _userHelper.GetCurrentUser().ReturnsNull(); + + var result = await _controller.GetStudentCareerDetail(5); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetStudentCareerDetail_NoCareerSelectionRole_ReturnsForbid() + { + var user = CreateUser(4, "nobody", "NONE001"); + _userHelper.GetCurrentUser().Returns(user); + + var result = await _controller.GetStudentCareerDetail(5); + + AssertForbidden(result.Result); + await _service.DidNotReceive().GetStudentCareerDetailAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetStudentCareerDetail_AdminCanOpenAnyRecord() + { + var admin = GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); + _service.CanEdit(999, admin).Returns(true); + _service.GetStudentCareerDetailAsync(999, true, true) + .Returns(new StudentCareerDetailDto { PersonId = 999, FullName = "Student, Test" }); + + var result = await _controller.GetStudentCareerDetail(999); + + var okResult = Assert.IsType(result.Result); + var dto = Assert.IsType(okResult.Value); + Assert.Equal(999, dto.PersonId); + // canEdit and canViewStudentList are the controller's decision, taken before the read. + await _service.Received(1).GetStudentCareerDetailAsync(999, true, true); + } + + [Fact] + public async Task GetStudentCareerDetail_StudentOpeningOwnRecord_HasNoStudentList() + { + var student = GrantPermission(CreateUser(100, "student", "STU001"), CareerSelectionPermissions.Student); + _service.CanEdit(100, student).Returns(true); + _service.GetStudentCareerDetailAsync(100, true, false) + .Returns(new StudentCareerDetailDto { PersonId = 100, FullName = "Self, Test" }); + + var result = await _controller.GetStudentCareerDetail(100); + + Assert.IsType(result.Result); + // A student sees no roster, so the record is read without the student-list flag. + await _service.Received(1).GetStudentCareerDetailAsync(100, true, false); + } + + [Fact] + public async Task GetStudentCareerDetail_StudentOpeningAnotherRecord_ReturnsForbid() + { + GrantPermission(CreateUser(100, "student", "STU001"), CareerSelectionPermissions.Student); + + var result = await _controller.GetStudentCareerDetail(999); + + AssertForbidden(result.Result); + await _service.DidNotReceive().GetStudentCareerDetailAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetStudentCareerDetail_FacultyOpeningTheirMentee_ReturnsOk() + { + var faculty = GrantPermission(CreateUser(2, "faculty", "FAC001"), CareerSelectionPermissions.Faculty); + _service.IsMentorOfAsync("FAC001", 999).Returns(true); + _service.CanEdit(999, faculty).Returns(false); + _service.GetStudentCareerDetailAsync(999, false, true) + .Returns(new StudentCareerDetailDto { PersonId = 999, FullName = "Mentee, Test" }); + + var result = await _controller.GetStudentCareerDetail(999); + + Assert.IsType(result.Result); + // A mentor may open the record read-only, and does get the roster. + await _service.Received(1).GetStudentCareerDetailAsync(999, false, true); + } + + [Fact] + public async Task GetStudentCareerDetail_FacultyOpeningSomeoneElsesMentee_ReturnsForbid() + { + GrantPermission(CreateUser(2, "faculty", "FAC001"), CareerSelectionPermissions.Faculty); + _service.IsMentorOfAsync("FAC001", 999).Returns(false); + + var result = await _controller.GetStudentCareerDetail(999); + + AssertForbidden(result.Result); + await _service.DidNotReceive().GetStudentCareerDetailAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetStudentCareerDetail_UnknownStudent_ReturnsNotFound() + { + var admin = GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); + _service.CanEdit(999, admin).Returns(true); + _service.GetStudentCareerDetailAsync(999, true, true).ReturnsNull(); + + var result = await _controller.GetStudentCareerDetail(999); + + Assert.IsType(result.Result); + } + + #endregion + + #region UpdateStudentCareerSelection + + [Fact] + public async Task UpdateStudentCareerSelection_NoUser_ReturnsUnauthorized() + { + _userHelper.GetCurrentUser().ReturnsNull(); + + var result = await _controller.UpdateStudentCareerSelection(100, new StudentCareerInfoDto()); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateStudentCareerSelection_WithoutEditRights_ReturnsForbid() + { + var student = CreateUser(100, "student", "STU001"); + _userHelper.GetCurrentUser().Returns(student); + _service.CanEdit(100, student).Returns(false); + + var result = await _controller.UpdateStudentCareerSelection(100, new StudentCareerInfoDto()); + + AssertForbidden(result.Result); + await _service.DidNotReceive().UpdateStudentCareerSelectionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateStudentCareerSelection_ValidationErrors_ReturnsValidationProblem() + { + var admin = GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); + _service.CanEdit(100, admin).Returns(true); + _service.UpdateStudentCareerSelectionAsync(100, Arg.Any(), true) + .Returns(["Select a career direction."]); + + var result = await _controller.UpdateStudentCareerSelection(100, new StudentCareerInfoDto()); + + // ValidationProblem returns ObjectResult with a ValidationProblemDetails body; the status + // code is filled in by the MVC pipeline, so assert the body shape rather than the code. + var objectResult = Assert.IsType(result.Result); + var problem = Assert.IsType(objectResult.Value); + Assert.True(problem.Errors.TryGetValue("CareerSelection", out var errors)); + Assert.Contains("Select a career direction.", errors); + } + + [Fact] + public async Task UpdateStudentCareerSelection_StudentNoLongerCurrent_ReturnsNotFound() + { + var admin = GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); + _service.CanEdit(100, admin).Returns(true); + _service.UpdateStudentCareerSelectionAsync(100, Arg.Any(), true) + .ThrowsAsync(new InvalidOperationException("PersonId 100 is not a current DVM student")); + + var result = await _controller.UpdateStudentCareerSelection(100, new StudentCareerInfoDto()); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateStudentCareerSelection_Saved_ReturnsRefreshedDetail() + { + var admin = GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); + _service.CanEdit(100, admin).Returns(true); + _service.UpdateStudentCareerSelectionAsync(100, Arg.Any(), true).Returns([]); + _service.GetStudentCareerDetailAsync(100, true, true) + .Returns(new StudentCareerDetailDto { PersonId = 100, FullName = "Student, Test" }); + + var result = await _controller.UpdateStudentCareerSelection(100, new StudentCareerInfoDto()); + + var okResult = Assert.IsType(result.Result); + Assert.Equal(100, Assert.IsType(okResult.Value).PersonId); + } + + #endregion + + #region Dropdown options + + [Fact] + public async Task GetOptions_UnknownType_ReturnsNotFound() + { + _userHelper.GetCurrentUser().Returns(CreateUser(1, "admin", "ADMIN001")); + + var result = await _controller.GetOptions("colours", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetOptions_AdminGetsUsageCounts() + { + GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); + _optionService.GetOptionsAsync(CareerOptionType.PostGrad, true, Arg.Any()).Returns([]); + + var result = await _controller.GetOptions("post-grad", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + await _optionService.Received(1) + .GetOptionsAsync(CareerOptionType.PostGrad, true, Arg.Any()); + } + + [Fact] + public async Task GetOptions_NonAdminGetsNoUsageCounts() + { + // Only admins manage the lists; nobody filling in the form needs the counts. + _userHelper.GetCurrentUser().Returns(CreateUser(100, "student", "STU001")); + _optionService.GetOptionsAsync(CareerOptionType.Career, false, Arg.Any()).Returns([]); + + await _controller.GetOptions("career", TestContext.Current.CancellationToken); + + await _optionService.Received(1) + .GetOptionsAsync(CareerOptionType.Career, false, Arg.Any()); + } + + [Fact] + public async Task CreateOption_Success_ReturnsCreated() + { + _optionService.CreateOptionAsync(CareerOptionType.Career, "Mixed Animal", Arg.Any()) + .Returns(CareerSelectionOptionWriteResult.Success( + new CareerSelectionOptionDto { Id = 7, Label = "Mixed Animal" })); + + var result = await _controller.CreateOption("career", new CareerSelectionOptionRequest { Label = "Mixed Animal" }, TestContext.Current.CancellationToken); + + var created = Assert.IsType(result.Result); + Assert.Equal(7, Assert.IsType(created.Value).Id); + } + + [Fact] + public async Task CreateOption_DuplicateName_ReturnsConflictWithMessage() + { + _optionService.CreateOptionAsync(CareerOptionType.Career, "Equine", Arg.Any()) + .Returns(CareerSelectionOptionWriteResult.Conflict("That option already exists.")); + + var result = await _controller.CreateOption("career", new CareerSelectionOptionRequest { Label = "Equine" }, TestContext.Current.CancellationToken); + + var conflict = Assert.IsType(result.Result); + Assert.Equal("That option already exists.", conflict.Value); + } + + [Fact] + public async Task UpdateOption_CatchAll_ReturnsBadRequestWithMessage() + { + _optionService.UpdateOptionAsync(CareerOptionType.Species, 3, "Other things", Arg.Any()) + .Returns(CareerSelectionOptionWriteResult.Invalid("The Other option cannot be renamed.")); + + var result = await _controller.UpdateOption("species", 3, new CareerSelectionOptionRequest { Label = "Other things" }, TestContext.Current.CancellationToken); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal("The Other option cannot be renamed.", badRequest.Value); + } + + [Fact] + public async Task DeleteOption_Success_ReturnsNoContent() + { + _optionService.DeleteOptionAsync(CareerOptionType.Species, 4, Arg.Any()) + .Returns(CareerSelectionOptionWriteResult.Success()); + + var result = await _controller.DeleteOption("species", 4, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteOption_AlreadyGone_ReturnsNotFound() + { + _optionService.DeleteOptionAsync(CareerOptionType.Species, 4, Arg.Any()) + .Returns(CareerSelectionOptionWriteResult.NotFound()); + + var result = await _controller.DeleteOption("species", 4, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteOption_OptionInUse_ReturnsConflictWithMessage() + { + // A selection holds a foreign key to the option, so it cannot be deleted out from under it. + _optionService.DeleteOptionAsync(CareerOptionType.Species, 4, Arg.Any()) + .Returns(CareerSelectionOptionWriteResult.Conflict("2 students have selected this option.")); + + var result = await _controller.DeleteOption("species", 4, TestContext.Current.CancellationToken); + + var conflict = Assert.IsType(result); + Assert.Equal("2 students have selected this option.", conflict.Value); + } + + [Fact] + public async Task UpdateOption_Success_ReturnsTheRenamedOption() + { + _optionService.UpdateOptionAsync(CareerOptionType.Species, 3, "Equine and camelid", Arg.Any()) + .Returns(CareerSelectionOptionWriteResult.Success( + new CareerSelectionOptionDto { Id = 3, Label = "Equine and camelid" })); + + var result = await _controller.UpdateOption("species", 3, + new CareerSelectionOptionRequest { Label = "Equine and camelid" }, TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + Assert.Equal("Equine and camelid", Assert.IsType(okResult.Value).Label); + } + + [Theory] + [InlineData("colours")] + [InlineData("")] + public async Task OptionWrites_UnknownType_ReturnNotFound(string type) + { + var request = new CareerSelectionOptionRequest { Label = "Exotics" }; + + var created = await _controller.CreateOption(type, request, TestContext.Current.CancellationToken); + var updated = await _controller.UpdateOption(type, 1, request, TestContext.Current.CancellationToken); + var deleted = await _controller.DeleteOption(type, 1, TestContext.Current.CancellationToken); + + Assert.IsType(created.Result); + Assert.IsType(updated.Result); + Assert.IsType(deleted); + await _optionService.DidNotReceive().CreateOptionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region App access + + [Fact] + public async Task GetAccessStatus_ReturnsCurrentState() + { + _service.IsAppOpenAsync().Returns(true); + + var result = await _controller.GetAccessStatus(); + + var okResult = Assert.IsType(result.Result); + Assert.True((bool)okResult.Value!); + } + + [Fact] + public async Task ToggleAppAccess_ReturnsNewState() + { + _service.ToggleAppAccessAsync().Returns(false); + + var result = await _controller.ToggleAppAccess(); + + var okResult = Assert.IsType(result.Result); + Assert.False((bool)okResult.Value!); + } + + #endregion + + #region Exports + + [Fact] + public async Task ExportOverviewExcel_WithoutListAccess_ReturnsForbid() + { + var student = CreateUser(100, "student", "STU001"); + _userHelper.GetCurrentUser().Returns(student); + _service.ResolveScope(student).Returns(CareerSelectionScope.Own); + + var result = await _controller.ExportOverviewExcel(); + + AssertForbidden(result); + await _service.DidNotReceive().GetStudentCareerListAsync(Arg.Any()); + } + + [Fact] + public async Task ExportOverviewExcel_NoStudents_ReturnsAHeadersOnlyWorkbook() + { + // Legacy handed back a file with headers and no rows rather than refusing the export. + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents).Returns([]); + using var stream = new MemoryStream([1, 2, 3]); + _exportService.GenerateOverviewExcel(Arg.Any>()).Returns(stream); + + var result = await _controller.ExportOverviewExcel(); + + Assert.IsType(result); + _exportService.Received(1).GenerateOverviewExcel(Arg.Is>(d => d.Count == 0)); + } + + [Fact] + public async Task ExportOverviewExcel_WithStudents_ReturnsWorkbook() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents) + .Returns([new StudentCareerListItemDto { PersonId = 5, FullName = "Student, Test" }]); + using var stream = new MemoryStream([1, 2, 3]); + _exportService.GenerateOverviewExcel(Arg.Any>()) + .Returns(stream); + + var result = await _controller.ExportOverviewExcel(); + + var file = Assert.IsType(result); + Assert.Contains("CareerSelectionOverview", file.FileDownloadName); + } + + [Fact] + public async Task ExportExcel_WithStudents_ReturnsTheReportWorkbook() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents) + .Returns([new StudentCareerReportDto { PersonId = 5, FullName = "Student, Test" }]); + using var stream = new MemoryStream([1, 2, 3]); + _exportService.GenerateExcel(Arg.Any>()).Returns(stream); + + var result = await _controller.ExportExcel(); + + var file = Assert.IsType(result); + Assert.Contains("CareerSelection", file.FileDownloadName); + } + + [Fact] + public async Task ExportOverviewPdf_WithStudents_ReturnsAPdfDownload() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents) + .Returns([new StudentCareerListItemDto { PersonId = 5, FullName = "Student, Test" }]); + _exportService.GenerateOverviewPdf(Arg.Any>()).Returns([1, 2, 3]); + + var result = await _controller.ExportOverviewPdf(); + + var file = Assert.IsType(result); + Assert.Equal("application/pdf", file.ContentType); + Assert.Matches(@"^CareerSelectionOverview_\d{8}\.pdf$", file.FileDownloadName); + } + + [Fact] + public async Task ExportOverviewPdf_NoStudents_ReturnsAPdf() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents).Returns([]); + _exportService.GenerateOverviewPdf(Arg.Any>()).Returns([1, 2, 3]); + + var result = await _controller.ExportOverviewPdf(); + + var file = Assert.IsType(result); + Assert.Equal("application/pdf", file.ContentType); + _exportService.Received(1).GenerateOverviewPdf(Arg.Is>(d => d.Count == 0)); + } + + [Fact] + public async Task ExportExcel_WithoutListAccess_ReturnsForbid() + { + var student = CreateUser(100, "student", "STU001"); + _userHelper.GetCurrentUser().Returns(student); + _service.ResolveScope(student).Returns(CareerSelectionScope.Own); + + var result = await _controller.ExportExcel(); + + AssertForbidden(result); + await _service.DidNotReceive().GetStudentCareerReportAsync(Arg.Any()); + } + + [Fact] + public async Task ExportPdf_FacultyScope_ExportsOnlyTheirMentees() + { + var faculty = CreateUser(2, "faculty", "FAC001"); + _userHelper.GetCurrentUser().Returns(faculty); + _service.ResolveScope(faculty).Returns(CareerSelectionScope.Mentored); + _service.GetStudentCareerReportAsync(StudentListAccess.MentoredBy("FAC001")) + .Returns([new StudentCareerReportDto { PersonId = 5, FullName = "Mentee, Test" }]); + _exportService.GeneratePdf(Arg.Any>()).Returns([1, 2, 3]); + + var result = await _controller.ExportPdf(); + + var file = Assert.IsType(result); + Assert.Equal("application/pdf", file.ContentType); + Assert.Matches(@"^CareerSelection_\d{8}\.pdf$", file.FileDownloadName); + await _service.Received(1).GetStudentCareerReportAsync(StudentListAccess.MentoredBy("FAC001")); + } + + [Fact] + public async Task ExportOverviewCsv_WithStudents_ReturnsACsvDownload() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents) + .Returns([new StudentCareerListItemDto { PersonId = 5, FullName = "Student, Test" }]); + _exportService.GenerateOverviewCsv(Arg.Any>()).Returns([1, 2, 3]); + + var result = await _controller.ExportOverviewCsv(); + + var file = Assert.IsType(result); + Assert.Equal("text/csv", file.ContentType); + Assert.Equal("CareerSelectionOverview.csv", file.FileDownloadName); + } + + [Fact] + public async Task ExportCsv_WithStudents_ReturnsTheReportCsv() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents) + .Returns([new StudentCareerReportDto { PersonId = 5, FullName = "Student, Test" }]); + _exportService.GenerateCsv(Arg.Any>()).Returns([1, 2, 3]); + + var result = await _controller.ExportCsv(); + + var file = Assert.IsType(result); + Assert.Equal("CareerSelection.csv", file.FileDownloadName); + } + + [Fact] + public async Task ExportCsv_WithoutListAccess_ReturnsForbid() + { + var student = CreateUser(100, "student", "STU001"); + _userHelper.GetCurrentUser().Returns(student); + _service.ResolveScope(student).Returns(CareerSelectionScope.Own); + + var result = await _controller.ExportCsv(); + + AssertForbidden(result); + await _service.DidNotReceive().GetStudentCareerReportAsync(Arg.Any()); + } + + [Fact] + public async Task ExportOverviewCsv_NoStudents_ReturnsAHeadersOnlyCsv() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents).Returns([]); + _exportService.GenerateOverviewCsv(Arg.Any>()).Returns([1, 2, 3]); + + var result = await _controller.ExportOverviewCsv(); + + Assert.IsType(result); + _exportService.Received(1).GenerateOverviewCsv(Arg.Is>(d => d.Count == 0)); + } + + [Fact] + public async Task ExportCsv_WithRowKeys_ExportsThoseRowsInTheGridsOrder() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents).Returns( + [ + new StudentCareerReportDto { PersonId = 5, RowKey = "5" }, + new StudentCareerReportDto { PersonId = 6, RowKey = "6" }, + new StudentCareerReportDto { PersonId = 7, RowKey = "7" }, + ]); + _exportService.GenerateCsv(Arg.Any>()).Returns([1, 2, 3]); + + await _controller.ExportCsv(new CareerSelectionExportRequest { RowKeys = ["7", "5"] }); + + _exportService.Received(1).GenerateCsv(Arg.Is>( + d => d.Select(r => r.PersonId).SequenceEqual(new[] { 7, 5 }))); + } + + [Fact] + public async Task ExportExcel_FacultySendsANonMenteesKey_ExportsOnlyTheirMentees() + { + // Keys only narrow what the caller may already see; a key for someone else's student + // matches nothing in the mentor's roster and is dropped. + var faculty = CreateUser(2, "faculty", "FAC001"); + _userHelper.GetCurrentUser().Returns(faculty); + _service.ResolveScope(faculty).Returns(CareerSelectionScope.Mentored); + _service.GetStudentCareerReportAsync(StudentListAccess.MentoredBy("FAC001")) + .Returns([new StudentCareerReportDto { PersonId = 5, RowKey = "5" }]); + using var stream = new MemoryStream([1, 2, 3]); + _exportService.GenerateExcel(Arg.Any>()).Returns(stream); + + await _controller.ExportExcel(new CareerSelectionExportRequest { RowKeys = ["5", "99"] }); + + _exportService.Received(1).GenerateExcel(Arg.Is>( + d => d.Count == 1 && d[0].PersonId == 5)); + } + + [Fact] + public async Task ExportPdf_EmptyRowKeys_ReturnsAPdfWithNoRows() + { + // The grid's search matched nothing: still a file, as legacy gave. + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents) + .Returns([new StudentCareerReportDto { PersonId = 5, RowKey = "5" }]); + _exportService.GeneratePdf(Arg.Any>()).Returns([1, 2, 3]); + + var result = await _controller.ExportPdf(new CareerSelectionExportRequest { RowKeys = [] }); + + Assert.IsType(result); + _exportService.Received(1).GeneratePdf(Arg.Is>(d => d.Count == 0)); + } + + [Fact] + public async Task ExportOverviewExcel_NoRowKeys_ExportsEveryStudent() + { + var admin = CreateUser(1, "admin", "ADMIN001"); + _userHelper.GetCurrentUser().Returns(admin); + _service.ResolveScope(admin).Returns(CareerSelectionScope.All); + _service.GetStudentCareerListAsync(StudentListAccess.AllStudents).Returns( + [ + new StudentCareerListItemDto { PersonId = 5, RowKey = "5" }, + new StudentCareerListItemDto { PersonId = 6, RowKey = "6" }, + ]); + using var stream = new MemoryStream([1, 2, 3]); + _exportService.GenerateOverviewExcel(Arg.Any>()).Returns(stream); + + await _controller.ExportOverviewExcel(new CareerSelectionExportRequest { RowKeys = null }); + + _exportService.Received(1).GenerateOverviewExcel(Arg.Is>(d => d.Count == 2)); + } + + [Theory] + [InlineData(nameof(CareerSelectionController.ExportPdf))] + [InlineData(nameof(CareerSelectionController.ExportOverviewPdf))] + public void PdfExports_ArePosts(string action) + { + // The row keys ride in a body, which a GET cannot carry. + var method = typeof(CareerSelectionController).GetMethod(action)!; + + Assert.NotNull(method.GetCustomAttributes(typeof(HttpPostAttribute), inherit: false).SingleOrDefault()); + Assert.Empty(method.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)); + } + + #endregion + + #region Mentor search + + [Fact] + public async Task SearchMentors_ReturnsMatches() + { + _service.SearchMentorsAsync("smi", Arg.Any()) + .Returns([new MentorOptionDto { PersonId = 8, FullName = "Smith, Ann", IamId = "IAM8" }]); + + var result = await _controller.SearchMentors("smi", TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + Assert.Single(Assert.IsType>(okResult.Value)); + } + + #endregion + + #region Helpers + + private static void AssertForbidden(ActionResult? result) + { + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status403Forbidden, objectResult.StatusCode); + } + + /// Signs the user in and grants them one career selection permission. + private AaudUser GrantPermission(AaudUser user, string permission) + { + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, permission).Returns(true); + return user; + } + + private static AaudUser CreateUser(int personId, string loginId, string mothraId) + { + return new AaudUser + { + AaudUserId = personId, + ClientId = "UCD", + MothraId = mothraId, + LoginId = loginId, + DisplayFullName = $"Test {loginId}", + DisplayFirstName = "Test", + DisplayLastName = loginId, + LastName = loginId, + FirstName = "Test" + }; + } + + #endregion +} diff --git a/test/Students/CareerSelectionExportRequestTests.cs b/test/Students/CareerSelectionExportRequestTests.cs new file mode 100644 index 000000000..2c53013b5 --- /dev/null +++ b/test/Students/CareerSelectionExportRequestTests.cs @@ -0,0 +1,90 @@ +using Viper.Areas.Students.Models; + +namespace Viper.test.Students; + +/// +/// Tests for CareerSelectionExportRequest.ApplyRowKeys: how the grid's row keys narrow and order +/// the rows an export writes. +/// +public class CareerSelectionExportRequestTests +{ + private static List Rows(params string[] keys) => + keys.Select(k => new StudentCareerReportDto { RowKey = k }).ToList(); + + private static List KeysOf(List rows) => + rows.Select(r => r.RowKey).ToList(); + + [Fact] + public void ApplyRowKeys_NoRequest_ReturnsEveryRowInItsOrder() + { + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("1", "2", "3"), null); + + Assert.Equal(["1", "2", "3"], KeysOf(result)); + } + + [Fact] + public void ApplyRowKeys_NullKeys_ReturnsEveryRowInItsOrder() + { + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("1", "2", "3"), + new CareerSelectionExportRequest { RowKeys = null }); + + Assert.Equal(["1", "2", "3"], KeysOf(result)); + } + + [Fact] + public void ApplyRowKeys_EmptyKeys_ReturnsNoRows() + { + // The grid's search matched nothing, which is not the same as no filter at all. + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("1", "2", "3"), + new CareerSelectionExportRequest { RowKeys = [] }); + + Assert.Empty(result); + } + + [Fact] + public void ApplyRowKeys_FollowsTheKeysOrder() + { + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("1", "2", "3"), + new CareerSelectionExportRequest { RowKeys = ["3", "1", "2"] }); + + Assert.Equal(["3", "1", "2"], KeysOf(result)); + } + + [Fact] + public void ApplyRowKeys_KeysOutsideTheRows_AreIgnored() + { + // A key for a student the caller may not see matches nothing and adds nothing. + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("1", "2"), + new CareerSelectionExportRequest { RowKeys = ["99", "2", "STU00009"] }); + + Assert.Equal(["2"], KeysOf(result)); + } + + [Fact] + public void ApplyRowKeys_RepeatedKey_KeepsItsFirstPositionAndOneRow() + { + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("1", "2"), + new CareerSelectionExportRequest { RowKeys = ["2", "1", "2"] }); + + Assert.Equal(["2", "1"], KeysOf(result)); + } + + [Fact] + public void ApplyRowKeys_NullKeyInTheList_IsSkipped() + { + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("1", "2"), + new CareerSelectionExportRequest { RowKeys = [null!, "2"] }); + + Assert.Equal(["2"], KeysOf(result)); + } + + [Fact] + public void ApplyRowKeys_MatchesKeysExactly() + { + // Unmapped students are keyed by MothraId, so case must not merge two different keys. + var result = CareerSelectionExportRequest.ApplyRowKeys(Rows("STU00001"), + new CareerSelectionExportRequest { RowKeys = ["stu00001"] }); + + Assert.Empty(result); + } +} diff --git a/test/Students/CareerSelectionMapperTests.cs b/test/Students/CareerSelectionMapperTests.cs new file mode 100644 index 000000000..9be156fae --- /dev/null +++ b/test/Students/CareerSelectionMapperTests.cs @@ -0,0 +1,124 @@ +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Models.Entities; + +namespace Viper.test.Students; + +/// +/// Tests for CareerSelectionMapper: how a submitted form becomes the stored row, in particular +/// which free text survives and what an unfilled field is stored as. +/// +public class CareerSelectionMapperTests +{ + [Fact] + public void ApplyStudentInfoToEntity_StoresTheSelectedOptionIds() + { + var entity = NewEntity(); + + CareerSelectionMapper.ApplyStudentInfoToEntity(new StudentCareerInfoDto + { + Direction = Option("Academia", 1), + PrimaryFocus = Option("Equine", 2), + SecondaryFocus = Option("Bovine", 3), + PostGrad = Option("Residency", 4), + ShortTermPlans = "Internship", + LongTermPlans = "Practice ownership", + }, entity); + + Assert.Equal(1, entity.Career); + Assert.Equal(2, entity.FirstSpecies); + Assert.Equal(3, entity.SecondSpecies); + Assert.Equal(4, entity.PostGrad); + Assert.Equal("Internship", entity.ShortTermStatement); + Assert.Equal("Practice ownership", entity.LongTermStatement); + } + + [Fact] + public void ApplyStudentInfoToEntity_NothingSelected_ClearsTheOptionIds() + { + var entity = NewEntity(); + entity.Career = 1; + entity.FirstSpecies = 2; + + CareerSelectionMapper.ApplyStudentInfoToEntity(new StudentCareerInfoDto(), entity); + + Assert.Null(entity.Career); + Assert.Null(entity.FirstSpecies); + } + + [Fact] + public void ApplyStudentInfoToEntity_CatchAll_KeepsItsFreeText() + { + var entity = NewEntity(); + + CareerSelectionMapper.ApplyStudentInfoToEntity(new StudentCareerInfoDto + { + Direction = Option("Other", 9, isOther: true), + DirectionOther = " Wildlife rehabilitation ", + }, entity); + + Assert.Equal(9, entity.Career); + Assert.Equal("Wildlife rehabilitation", entity.CareerOther); + } + + [Fact] + public void ApplyStudentInfoToEntity_OrdinaryOption_DropsFreeTextFromAPreviousOtherChoice() + { + // The stale text must not reappear if the student switches back to "Other" later. + var entity = NewEntity(); + entity.CareerOther = "Wildlife rehabilitation"; + + CareerSelectionMapper.ApplyStudentInfoToEntity(new StudentCareerInfoDto + { + Direction = Option("Academia", 1), + DirectionOther = "Wildlife rehabilitation", + }, entity); + + Assert.Equal(string.Empty, entity.CareerOther); + } + + [Fact] + public void ApplyStudentInfoToEntity_UnfilledText_IsStoredAsEmptyNotNull() + { + var entity = NewEntity(); + + CareerSelectionMapper.ApplyStudentInfoToEntity(new StudentCareerInfoDto(), entity); + + Assert.Equal(string.Empty, entity.CareerOther); + Assert.Equal(string.Empty, entity.FirstSpeciesOther); + Assert.Equal(string.Empty, entity.SecondSpeciesOther); + Assert.Equal(string.Empty, entity.ShortTermStatement); + Assert.Equal(string.Empty, entity.LongTermStatement); + } + + [Fact] + public void ApplyStudentInfoToEntity_TrimsStatements() + { + var entity = NewEntity(); + + CareerSelectionMapper.ApplyStudentInfoToEntity(new StudentCareerInfoDto + { + ShortTermPlans = " Internship\n", + LongTermPlans = "\tPractice ownership ", + }, entity); + + Assert.Equal("Internship", entity.ShortTermStatement); + Assert.Equal("Practice ownership", entity.LongTermStatement); + } + + [Fact] + public void ApplyStudentInfoToEntity_LeavesTheMentorAlone() + { + // The mentor is admin-only, so the service sets it; the mapper must not touch it. + var entity = NewEntity(); + entity.FacultyMothraId = "FAC00001"; + + CareerSelectionMapper.ApplyStudentInfoToEntity(new StudentCareerInfoDto { MentorId = 500 }, entity); + + Assert.Equal("FAC00001", entity.FacultyMothraId); + } + + private static CareerSelection NewEntity() => new() { Pidm = 20000001, DateAdded = DateTime.Now }; + + private static CareerDropdownOption Option(string label, int value, bool isOther = false) => + new() { Label = label, Value = value, IsOther = isOther }; +} diff --git a/test/Students/CareerSelectionOptionServiceTests.cs b/test/Students/CareerSelectionOptionServiceTests.cs new file mode 100644 index 000000000..43564422e --- /dev/null +++ b/test/Students/CareerSelectionOptionServiceTests.cs @@ -0,0 +1,321 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Models.Entities; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; + +namespace Viper.test.Students; + +/// +/// Tests for CareerSelectionOptionService: the admin option lists, their ordering, and the usage +/// counts that decide whether an option can be deleted. +/// +public sealed class CareerSelectionOptionServiceTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly CareerSelectionOptionService _service; + private int _nextPidm = 10000000; + + public CareerSelectionOptionServiceTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _service = new CareerSelectionOptionService(_context); + } + + public void Dispose() + { + _context.Dispose(); + } + + private CareerSelection Selection(Action customize) + { + var selection = new CareerSelection + { + Pidm = _nextPidm++, + DateAdded = DateTime.Now, + }; + customize(selection); + return selection; + } + + [Fact] + public async Task GetOptionsAsync_SortsCatchAllLastThenByLabel() + { + _context.CareerOptions.AddRange( + new CareerOption { CareerOptionId = 1, Career = "Other", IsOther = true }, + new CareerOption { CareerOptionId = 2, Career = "Small Animal" }, + new CareerOption { CareerOptionId = 3, Career = "Academia" }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetOptionsAsync(CareerOptionType.Career, includeUsage: true, TestContext.Current.CancellationToken); + + Assert.Equal(["Academia", "Small Animal", "Other"], result.Select(o => o.Label)); + Assert.True(result[2].IsOther); + Assert.Equal(3, result[0].Id); + } + + [Fact] + public async Task GetOptionsAsync_CountsCareerUsageIncludingZero() + { + _context.CareerOptions.AddRange( + new CareerOption { CareerOptionId = 1, Career = "Academia" }, + new CareerOption { CareerOptionId = 2, Career = "Industry" }); + _context.CareerSelections.AddRange( + Selection(c => c.Career = 1), + Selection(c => c.Career = 1), + Selection(c => c.Career = null)); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetOptionsAsync(CareerOptionType.Career, includeUsage: true, TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Single(o => o.Id == 1).UsageCount); + Assert.Equal(0, result.Single(o => o.Id == 2).UsageCount); + } + + [Fact] + public async Task GetOptionsAsync_CountsSpeciesFromBothColumns() + { + _context.SpeciesOptions.AddRange( + new SpeciesOption { SpeciesOptionId = 1, Species = "Equine" }, + new SpeciesOption { SpeciesOptionId = 2, Species = "Canine" }, + new SpeciesOption { SpeciesOptionId = 3, Species = "Bovine" }); + _context.CareerSelections.AddRange( + Selection(c => { c.FirstSpecies = 1; c.SecondSpecies = 2; }), + // Used only as a secondary species: still blocks a delete. + Selection(c => { c.FirstSpecies = null; c.SecondSpecies = 3; }), + // The same species in both columns is one selection, not two. + Selection(c => { c.FirstSpecies = 1; c.SecondSpecies = 1; })); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetOptionsAsync(CareerOptionType.Species, includeUsage: true, TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Single(o => o.Id == 1).UsageCount); + Assert.Equal(1, result.Single(o => o.Id == 2).UsageCount); + Assert.Equal(1, result.Single(o => o.Id == 3).UsageCount); + } + + [Fact] + public async Task GetOptionsAsync_CountsPostGradUsage() + { + _context.PostGradOptions.AddRange( + new PostGradOption { PostGradOptionId = 1, PostGrad = "Internship" }, + new PostGradOption { PostGradOptionId = 2, PostGrad = "Other", IsOther = true }); + _context.CareerSelections.Add(Selection(c => c.PostGrad = 2)); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetOptionsAsync(CareerOptionType.PostGrad, includeUsage: true, TestContext.Current.CancellationToken); + + Assert.Equal(0, result.Single(o => o.Id == 1).UsageCount); + Assert.Equal(1, result.Single(o => o.Id == 2).UsageCount); + } + + [Fact] + public async Task GetOptionsAsync_WithoutUsage_LeavesCountsAtZero() + { + _context.CareerOptions.Add(new CareerOption { CareerOptionId = 1, Career = "Academia" }); + _context.CareerSelections.Add(Selection(c => c.Career = 1)); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetOptionsAsync(CareerOptionType.Career, includeUsage: false, TestContext.Current.CancellationToken); + + Assert.Equal(0, Assert.Single(result).UsageCount); + } + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task SeedSpeciesAsync() + { + _context.SpeciesOptions.AddRange( + new SpeciesOption { SpeciesOptionId = 1, Species = "Equine" }, + new SpeciesOption { SpeciesOptionId = 2, Species = "Canine" }, + new SpeciesOption { SpeciesOptionId = 3, Species = "Other", IsOther = true }); + await _context.SaveChangesAsync(Ct); + } + + [Fact] + public async Task CreateOptionAsync_TrimsAndSavesANewOption() + { + await SeedSpeciesAsync(); + + var result = await _service.CreateOptionAsync(CareerOptionType.Species, " Exotics ", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Success, result.Status); + Assert.Equal("Exotics", result.Option!.Label); + Assert.False(result.Option.IsOther); + var saved = await _context.SpeciesOptions.SingleAsync(o => o.SpeciesOptionId == result.Option.Id, Ct); + Assert.Equal("Exotics", saved.Species); + } + + [Theory] + [InlineData("equine")] + [InlineData(" EQUINE ")] + [InlineData("other")] + public async Task CreateOptionAsync_RejectsADuplicateIgnoringCaseAndSpaces(string label) + { + await SeedSpeciesAsync(); + + var result = await _service.CreateOptionAsync(CareerOptionType.Species, label, Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Conflict, result.Status); + Assert.Equal(3, await _context.SpeciesOptions.CountAsync(Ct)); + } + + [Fact] + public async Task CreateOptionAsync_AllowsTheSameNameInADifferentList() + { + await SeedSpeciesAsync(); + + var result = await _service.CreateOptionAsync(CareerOptionType.Career, "Equine", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Success, result.Status); + } + + [Theory] + [InlineData(CareerOptionType.Career, 101, CareerSelectionOptionWriteStatus.Invalid)] + [InlineData(CareerOptionType.Species, 101, CareerSelectionOptionWriteStatus.Invalid)] + [InlineData(CareerOptionType.PostGrad, 150, CareerSelectionOptionWriteStatus.Success)] + [InlineData(CareerOptionType.PostGrad, 201, CareerSelectionOptionWriteStatus.Invalid)] + public async Task CreateOptionAsync_HoldsNamesToTheirListsLength(CareerOptionType type, int length, CareerSelectionOptionWriteStatus expected) + { + var result = await _service.CreateOptionAsync(type, new string('a', length), Ct); + + Assert.Equal(expected, result.Status); + } + + [Fact] + public async Task CreateOptionAsync_RejectsABlankName() + { + var result = await _service.CreateOptionAsync(CareerOptionType.Career, " ", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Invalid, result.Status); + } + + [Fact] + public async Task UpdateOptionAsync_RenamesAnOption() + { + await SeedSpeciesAsync(); + + var result = await _service.UpdateOptionAsync(CareerOptionType.Species, 1, " Horses ", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Success, result.Status); + Assert.Equal("Horses", (await _context.SpeciesOptions.SingleAsync(o => o.SpeciesOptionId == 1, Ct)).Species); + } + + [Fact] + public async Task UpdateOptionAsync_AllowsChangingOnlyTheCase() + { + await SeedSpeciesAsync(); + + var result = await _service.UpdateOptionAsync(CareerOptionType.Species, 1, "EQUINE", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Success, result.Status); + Assert.Equal("EQUINE", result.Option!.Label); + } + + [Fact] + public async Task UpdateOptionAsync_RejectsAnotherOptionsName() + { + await SeedSpeciesAsync(); + + var result = await _service.UpdateOptionAsync(CareerOptionType.Species, 1, "canine", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Conflict, result.Status); + Assert.Equal("Equine", (await _context.SpeciesOptions.SingleAsync(o => o.SpeciesOptionId == 1, Ct)).Species); + } + + [Fact] + public async Task UpdateOptionAsync_RefusesTheCatchAll() + { + await SeedSpeciesAsync(); + + var result = await _service.UpdateOptionAsync(CareerOptionType.Species, 3, "Something Else", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Invalid, result.Status); + } + + [Fact] + public async Task UpdateOptionAsync_ReportsAMissingOption() + { + await SeedSpeciesAsync(); + + var result = await _service.UpdateOptionAsync(CareerOptionType.Species, 99, "Horses", Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.NotFound, result.Status); + } + + [Fact] + public async Task DeleteOptionAsync_DeletesAnUnusedOption() + { + await SeedSpeciesAsync(); + + var result = await _service.DeleteOptionAsync(CareerOptionType.Species, 2, Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Success, result.Status); + Assert.False(await _context.SpeciesOptions.AnyAsync(o => o.SpeciesOptionId == 2, Ct)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DeleteOptionAsync_RefusesASpeciesInUseInEitherColumn(bool asPrimary) + { + await SeedSpeciesAsync(); + _context.CareerSelections.Add(Selection(c => + { + if (asPrimary) + { + c.FirstSpecies = 2; + } + else + { + c.SecondSpecies = 2; + } + })); + await _context.SaveChangesAsync(Ct); + + var result = await _service.DeleteOptionAsync(CareerOptionType.Species, 2, Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Conflict, result.Status); + Assert.True(await _context.SpeciesOptions.AnyAsync(o => o.SpeciesOptionId == 2, Ct)); + } + + [Fact] + public async Task DeleteOptionAsync_RefusesTheCatchAll() + { + await SeedSpeciesAsync(); + + var result = await _service.DeleteOptionAsync(CareerOptionType.Species, 3, Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.Invalid, result.Status); + } + + [Fact] + public async Task DeleteOptionAsync_ReportsAMissingOption() + { + var result = await _service.DeleteOptionAsync(CareerOptionType.PostGrad, 99, Ct); + + Assert.Equal(CareerSelectionOptionWriteStatus.NotFound, result.Status); + } + + [Theory] + [InlineData("career", CareerOptionType.Career)] + [InlineData("species", CareerOptionType.Species)] + [InlineData("post-grad", CareerOptionType.PostGrad)] + [InlineData("Post-Grad", CareerOptionType.PostGrad)] + public void TryParseSlug_ResolvesKnownSlugs(string slug, CareerOptionType expected) + { + Assert.True(CareerOptionTypes.TryParseSlug(slug, out var type)); + Assert.Equal(expected, type); + } + + [Theory] + [InlineData("postGrad")] + [InlineData("PostGrad")] + [InlineData("")] + [InlineData(null)] + public void TryParseSlug_RejectsUnknownSlugs(string? slug) + { + Assert.False(CareerOptionTypes.TryParseSlug(slug, out _)); + } +} diff --git a/test/Students/CareerSelectionServiceTests.cs b/test/Students/CareerSelectionServiceTests.cs new file mode 100644 index 000000000..eea3ce659 --- /dev/null +++ b/test/Students/CareerSelectionServiceTests.cs @@ -0,0 +1,622 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Viper.Areas.Students.Constants; +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Models.Entities; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Students; + +/// +/// Tests for CareerSelectionService: who may see which students, the completeness and value +/// mapping behind the roster and report, and the rules a save is held to. +/// +public sealed class CareerSelectionServiceTests : IDisposable +{ + private const string MentorMothraId = "FAC00001"; + private const string OtherMentorMothraId = "FAC00002"; + + private readonly TestableAAUDContext _aaudContext; + private readonly RAPSContext _rapsContext; + private readonly VIPERContext _viperContext; + private readonly IUserHelper _userHelper; + private readonly CareerSelectionService _service; + + public CareerSelectionServiceTests() + { + _aaudContext = new TestableAAUDContext(InMemory("AAUD")); + _rapsContext = new RAPSContext(InMemory("RAPS")); + _viperContext = new VIPERContext(InMemory("VIPER")); + _userHelper = Substitute.For(); + _service = new CareerSelectionService( + _rapsContext, _aaudContext, _userHelper, _viperContext, + Substitute.For>(), + new StudentAppAccessService(_rapsContext, _userHelper), + new DvmStudentLookupService(_aaudContext)); + } + + public void Dispose() + { + _aaudContext.Dispose(); + _rapsContext.Dispose(); + _viperContext.Dispose(); + } + + #region ResolveScope + + [Fact] + public void ResolveScope_Admin_SeesEveryStudent() + { + var user = Grant(CareerSelectionPermissions.Admin); + + Assert.Equal(CareerSelectionScope.All, _service.ResolveScope(user)); + } + + [Fact] + public void ResolveScope_ReadOnly_SeesEveryStudent() + { + var user = Grant(CareerSelectionPermissions.ReadOnly); + + Assert.Equal(CareerSelectionScope.All, _service.ResolveScope(user)); + } + + [Fact] + public void ResolveScope_Faculty_SeesTheirMentees() + { + var user = Grant(CareerSelectionPermissions.Faculty); + + Assert.Equal(CareerSelectionScope.Mentored, _service.ResolveScope(user)); + } + + [Fact] + public void ResolveScope_AdminWhoIsAlsoFaculty_StillSeesEveryStudent() + { + // Admin outranks Faculty, so holding both must not narrow the roster to their mentees. + var user = Grant(CareerSelectionPermissions.Admin, CareerSelectionPermissions.Faculty); + + Assert.Equal(CareerSelectionScope.All, _service.ResolveScope(user)); + } + + [Theory] + [InlineData(CareerSelectionPermissions.Student)] + [InlineData(CareerSelectionPermissions.ViewOwn)] + public void ResolveScope_Student_SeesOnlyTheirOwn(string permission) + { + var user = Grant(permission); + + Assert.Equal(CareerSelectionScope.Own, _service.ResolveScope(user)); + } + + [Fact] + public void ResolveScope_NoPermissions_SeesNothing() + { + var user = Grant(); + + Assert.Equal(CareerSelectionScope.None, _service.ResolveScope(user)); + } + + #endregion + + #region IsMentorOfAsync + + [Fact] + public async Task IsMentorOfAsync_RecordedMentor_ReturnsTrue() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + Assert.True(await _service.IsMentorOfAsync(MentorMothraId, 100)); + } + + [Fact] + public async Task IsMentorOfAsync_SomeoneElsesMentee_ReturnsFalse() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = OtherMentorMothraId); + + Assert.False(await _service.IsMentorOfAsync(MentorMothraId, 100)); + } + + [Fact] + public async Task IsMentorOfAsync_StudentWithNoRecord_ReturnsFalse() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + + Assert.False(await _service.IsMentorOfAsync(MentorMothraId, 100)); + } + + [Fact] + public async Task IsMentorOfAsync_BlankMentor_ReturnsFalse() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = null); + + Assert.False(await _service.IsMentorOfAsync(" ", 100)); + } + + #endregion + + #region GetStudentCareerListAsync + + [Fact] + public async Task GetStudentCareerListAsync_NoMentor_ListsEveryStudent() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001", lastName: "Beta"); + await SeedStudentAsync(101, "STU00002", pidm: "20000002", lastName: "Alpha"); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + var result = await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents); + + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.PersonId == 101 && r.FullName == "Alpha, Test"); + } + + [Fact] + public async Task GetStudentCareerListAsync_Mentor_ListsOnlyTheirMentees() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedStudentAsync(101, "STU00002", pidm: "20000002"); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + await SeedSelectionAsync("20000002", s => s.FacultyMothraId = OtherMentorMothraId); + + var result = await _service.GetStudentCareerListAsync(StudentListAccess.MentoredBy(MentorMothraId)); + + var student = Assert.Single(result); + Assert.Equal(100, student.PersonId); + } + + [Fact] + public async Task GetStudentCareerListAsync_FlagsAnsweredFields() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001", mailId: "tstudent"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => + { + s.Career = 1; + s.FirstSpecies = 1; + s.PostGrad = 1; + s.ShortTermStatement = "Internship"; + }); + + var student = Assert.Single(await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents)); + + Assert.True(student.DirectionCompleted); + Assert.True(student.PrimaryFocusCompleted); + Assert.True(student.PostGradCompleted); + Assert.True(student.ShortTermPlansCompleted); + Assert.False(student.SecondaryFocusCompleted); + Assert.False(student.LongTermPlansCompleted); + Assert.Equal("tstudent@ucdavis.edu", student.Email); + } + + [Fact] + public async Task GetStudentCareerListAsync_CatchAllWithoutItsText_IsNotComplete() + { + // "Other" only counts as answered once the free text says what it is. + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => + { + s.Career = 2; + s.CareerOther = null; + s.FirstSpecies = 2; + s.FirstSpeciesOther = "Camelid"; + }); + + var student = Assert.Single(await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents)); + + Assert.False(student.DirectionCompleted); + Assert.True(student.PrimaryFocusCompleted); + } + + [Fact] + public async Task GetStudentCareerListAsync_ResolvesMentorIdentity() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(500, MentorMothraId, "Vet", "Ann"); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + var student = Assert.Single(await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents)); + + Assert.Equal("Vet, Ann", student.MentorName); + } + + [Fact] + public async Task GetStudentCareerListAsync_StudentWithNoAaudUser_IsListedWithoutARoute() + { + // A student missing from AaudUser has no PersonId to route on, but still belongs on the roster. + _aaudContext.Set().Add(new VwDvmStudentsMaxTerm + { + IdsMothraId = "STU00009", + PersonLastName = "Unmapped", + PersonFirstName = "Test", + StudentsClassLevel = "V1", + IdsPidm = "20000009", + IdsMailid = "unmapped", + StudentsTermCode = "202610" + }); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var student = Assert.Single(await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents)); + + Assert.Equal(0, student.PersonId); + Assert.False(student.HasDetailRoute); + Assert.Equal("STU00009", student.RowKey); + } + + [Fact] + public async Task GetStudentCareerListAsync_OrdersByLastThenFirstName() + { + // Seeded out of order. "de Luca" before "Zeta" shows the sort ignores case, where an + // ordinal sort would put every capitalized name first. + await SeedStudentAsync(100, "STU00001", pidm: "20000001", lastName: "Zeta", firstName: "Ann"); + await SeedStudentAsync(101, "STU00002", pidm: "20000002", lastName: "Alpha", firstName: "Zed"); + await SeedStudentAsync(102, "STU00003", pidm: "20000003", lastName: "de Luca", firstName: "Ann"); + await SeedStudentAsync(103, "STU00004", pidm: "20000004", lastName: "Alpha", firstName: "Amy"); + + var result = await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents); + + Assert.Equal([103, 101, 102, 100], result.Select(r => r.PersonId)); + } + + [Fact] + public async Task GetStudentCareerListAsync_SameName_OrdersByMothraId() + { + // Seeded with the higher MothraId first, so a pass-through of insertion order would fail. + await SeedStudentAsync(101, "STU00002", pidm: "20000002", lastName: "Smith", firstName: "Sam"); + await SeedStudentAsync(100, "STU00001", pidm: "20000001", lastName: "Smith", firstName: "Sam"); + + var result = await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents); + + // PersonId 100 holds STU00001. + Assert.Equal([100, 101], result.Select(r => r.PersonId)); + } + + #endregion + + #region GetStudentCareerReportAsync + + [Fact] + public async Task GetStudentCareerReportAsync_ReportsSelectedLabels() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => + { + s.Career = 1; + s.FirstSpecies = 1; + s.PostGrad = 1; + s.ShortTermStatement = "Internship"; + s.LongTermStatement = "Practice ownership"; + }); + + var student = Assert.Single(await _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents)); + + Assert.Equal("Academia", student.Direction); + Assert.Equal("Equine", student.PrimaryFocus); + Assert.Equal("Residency", student.PostGrad); + Assert.Equal("Internship", student.ShortTermPlans); + Assert.Equal("Practice ownership", student.LongTermPlans); + } + + [Fact] + public async Task GetStudentCareerReportAsync_CatchAllReportsItsFreeText() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => + { + s.Career = 2; + s.CareerOther = "Wildlife rehabilitation"; + }); + + var student = Assert.Single(await _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents)); + + Assert.Equal("Wildlife rehabilitation", student.Direction); + } + + [Fact] + public async Task GetStudentCareerReportAsync_Mentor_ReportsOnlyTheirMentees() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedStudentAsync(101, "STU00002", pidm: "20000002"); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + await SeedSelectionAsync("20000002", s => s.FacultyMothraId = OtherMentorMothraId); + + var student = Assert.Single(await _service.GetStudentCareerReportAsync(StudentListAccess.MentoredBy(MentorMothraId))); + + Assert.Equal(100, student.PersonId); + } + + [Fact] + public async Task GetStudentCareerReportAsync_OrdersByLastThenFirstName() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001", lastName: "Beta", firstName: "Ann"); + await SeedStudentAsync(101, "STU00002", pidm: "20000002", lastName: "Alpha", firstName: "Zed"); + await SeedStudentAsync(102, "STU00003", pidm: "20000003", lastName: "Alpha", firstName: "Amy"); + + var result = await _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents); + + Assert.Equal([102, 101, 100], result.Select(r => r.PersonId)); + } + + #endregion + + #region GetStudentCareerDetailAsync + + [Fact] + public async Task GetStudentCareerDetailAsync_UnknownStudent_ReturnsNull() + { + Assert.Null(await _service.GetStudentCareerDetailAsync(999, canEdit: true, canViewStudentList: true)); + } + + [Fact] + public async Task GetStudentCareerDetailAsync_NoRecordYet_ReturnsEmptyShell() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + + var detail = await _service.GetStudentCareerDetailAsync(100, canEdit: true, canViewStudentList: false); + + Assert.NotNull(detail); + Assert.Equal("Student, Test", detail.FullName); + Assert.True(detail.CanEdit); + Assert.Null(detail.StudentInfo.Direction); + } + + [Fact] + public async Task GetStudentCareerDetailAsync_ReturnsStoredSelectionAndMentor() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(500, MentorMothraId, "Vet", "Ann"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => + { + s.Career = 1; + s.ShortTermStatement = "Internship"; + s.FacultyMothraId = MentorMothraId; + }); + + var detail = await _service.GetStudentCareerDetailAsync(100, canEdit: false, canViewStudentList: true); + + Assert.NotNull(detail); + Assert.Equal("Academia", detail.StudentInfo.Direction?.Label); + Assert.Equal(1, detail.StudentInfo.Direction?.Value); + Assert.Equal("Internship", detail.StudentInfo.ShortTermPlans); + Assert.Equal(500, detail.StudentInfo.MentorId); + Assert.Equal("Vet, Ann", detail.StudentInfo.MentorName); + } + + #endregion + + #region UpdateStudentCareerSelectionAsync + + [Fact] + public async Task UpdateStudentCareerSelectionAsync_NotACurrentStudent_Throws() + { + await Assert.ThrowsAsync( + () => _service.UpdateStudentCareerSelectionAsync(999, new StudentCareerInfoDto(), isAdmin: true)); + } + + [Fact] + public async Task UpdateStudentCareerSelectionAsync_CreatesTheFirstRecord() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedOptionsAsync(); + + var errors = await _service.UpdateStudentCareerSelectionAsync(100, new StudentCareerInfoDto + { + Direction = new CareerDropdownOption { Label = "Academia", Value = 1 }, + ShortTermPlans = "Internship", + }, isAdmin: false); + + Assert.Empty(errors); + var saved = await _viperContext.CareerSelections.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(1, saved.Career); + Assert.Equal("Internship", saved.ShortTermStatement); + } + + [Fact] + public async Task UpdateStudentCareerSelectionAsync_UnknownOption_ReturnsAValidationError() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedOptionsAsync(); + + var errors = await _service.UpdateStudentCareerSelectionAsync(100, new StudentCareerInfoDto + { + Direction = new CareerDropdownOption { Label = "Made up", Value = 99 }, + }, isAdmin: true); + + Assert.NotEmpty(errors); + Assert.Empty(await _viperContext.CareerSelections.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateStudentCareerSelectionAsync_StudentSave_LeavesTheMentorAlone() + { + // The mentor is admin-managed and read-only to the student. + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(500, MentorMothraId, "Vet", "Ann"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + await _service.UpdateStudentCareerSelectionAsync(100, new StudentCareerInfoDto + { + ShortTermPlans = "Internship", + MentorId = null, + }, isAdmin: false); + + var saved = await _viperContext.CareerSelections.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(MentorMothraId, saved.FacultyMothraId); + } + + [Fact] + public async Task UpdateStudentCareerSelectionAsync_AdminSave_ClearsTheMentor() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(500, MentorMothraId, "Vet", "Ann"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + await _service.UpdateStudentCareerSelectionAsync(100, new StudentCareerInfoDto { MentorId = null }, isAdmin: true); + + var saved = await _viperContext.CareerSelections.SingleAsync(TestContext.Current.CancellationToken); + Assert.Null(saved.FacultyMothraId); + } + + #endregion + + #region CanEdit + + [Fact] + public async Task CanEdit_Admin_CanEditAnyRecord() + { + var admin = await SeedStudentAsync(1, "ADM00001", pidm: "10000001", loginId: "admin"); + _userHelper.HasPermission(_rapsContext, Arg.Is(u => u.LoginId == admin.LoginId), + CareerSelectionPermissions.Admin).Returns(true); + + Assert.True(_service.CanEdit(100, admin)); + } + + [Fact] + public async Task CanEdit_ReadOnly_CannotEdit() + { + var readOnly = await SeedStudentAsync(2, "RO000001", pidm: "10000002", loginId: "readonly"); + _userHelper.HasPermission(_rapsContext, Arg.Any(), CareerSelectionPermissions.ReadOnly).Returns(true); + + Assert.False(_service.CanEdit(100, readOnly)); + } + + [Fact] + public async Task CanEdit_StudentWhileAppOpen_CanEditOwnRecordOnly() + { + // Holding the Student permission is what "the app is open" means for a student. + var student = await SeedStudentAsync(100, "STU00001", pidm: "20000001", loginId: "student"); + _userHelper.HasPermission(_rapsContext, Arg.Any(), CareerSelectionPermissions.Student).Returns(true); + + Assert.True(_service.CanEdit(100, student)); + Assert.False(_service.CanEdit(999, student)); + } + + [Fact] + public async Task CanEdit_StudentWithoutThePermission_CannotEdit() + { + var student = await SeedStudentAsync(100, "STU00001", pidm: "20000001", loginId: "student"); + + Assert.False(_service.CanEdit(100, student)); + } + + #endregion + + #region Helpers + + private static DbContextOptions InMemory(string name) where T : DbContext => + new DbContextOptionsBuilder() + .UseInMemoryDatabase($"{name}_{Guid.NewGuid()}") + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + /// Signs a user in and grants them the named career selection permissions. + private AaudUser Grant(params string[] permissions) + { + var user = new AaudUser + { + AaudUserId = 1, + ClientId = "UCD", + MothraId = MentorMothraId, + LoginId = "tester", + DisplayFullName = "Test Tester", + DisplayFirstName = "Test", + DisplayLastName = "Tester", + LastName = "Tester", + FirstName = "Test" + }; + foreach (var permission in permissions) + { + _userHelper.HasPermission(_rapsContext, user, permission).Returns(true); + } + return user; + } + + /// Adds a DVM student to the students view and to AaudUser. Call SaveChanges after. + private async Task SeedStudentAsync(int personId, string mothraId, string pidm, + string lastName = "Student", string mailId = "student", string loginId = "student", + string firstName = "Test") + { + _aaudContext.Set().Add(new VwDvmStudentsMaxTerm + { + IdsMothraId = mothraId, + PersonLastName = lastName, + PersonFirstName = firstName, + StudentsClassLevel = "V1", + IdsPidm = pidm, + IdsMailid = mailId, + StudentsTermCode = "202610" + }); + + var user = new AaudUser + { + AaudUserId = personId, + ClientId = "UCD", + MothraId = mothraId, + LoginId = loginId, + Pidm = pidm, + DisplayFullName = $"{firstName} {lastName}", + DisplayFirstName = firstName, + DisplayLastName = lastName, + LastName = lastName, + FirstName = firstName + }; + _aaudContext.AaudUsers.Add(user); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + return user; + } + + private async Task SeedMentorAsync(int personId, string mothraId, string lastName, string firstName) + { + _aaudContext.AaudUsers.Add(new AaudUser + { + AaudUserId = personId, + ClientId = "UCD", + MothraId = mothraId, + LoginId = $"mentor{personId}", + IamId = $"IAM{personId}", + DisplayFullName = $"{firstName} {lastName}", + DisplayFirstName = firstName, + DisplayLastName = lastName, + LastName = lastName, + FirstName = firstName + }); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + /// Option id 1 is an ordinary choice in each list; id 2 is the catch-all. + private async Task SeedOptionsAsync() + { + _viperContext.CareerOptions.AddRange( + new CareerOption { CareerOptionId = 1, Career = "Academia" }, + new CareerOption { CareerOptionId = 2, Career = "Other", IsOther = true }); + _viperContext.SpeciesOptions.AddRange( + new SpeciesOption { SpeciesOptionId = 1, Species = "Equine" }, + new SpeciesOption { SpeciesOptionId = 2, Species = "Other", IsOther = true }); + _viperContext.PostGradOptions.AddRange( + new PostGradOption { PostGradOptionId = 1, PostGrad = "Residency" }, + new PostGradOption { PostGradOptionId = 2, PostGrad = "Other", IsOther = true }); + await _viperContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + // Takes the PIDM in the view's string form, the same literal the matching student is seeded + // with, and converts it the way the service does. + private async Task SeedSelectionAsync(string pidm, Action customize) + { + var selection = new CareerSelection { Pidm = int.Parse(pidm), DateAdded = DateTime.Now }; + customize(selection); + _viperContext.CareerSelections.Add(selection); + await _viperContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + #endregion +} diff --git a/test/Students/DvmStudentLookupServiceTests.cs b/test/Students/DvmStudentLookupServiceTests.cs new file mode 100644 index 000000000..d72a3de53 --- /dev/null +++ b/test/Students/DvmStudentLookupServiceTests.cs @@ -0,0 +1,208 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Students; + +/// +/// Tests for DvmStudentLookupService: who counts as a current DVM student, and the identity the +/// student self-service apps read for them. +/// +public sealed class DvmStudentLookupServiceTests : IDisposable +{ + private readonly TestableAAUDContext _aaudContext; + private readonly DvmStudentLookupService _service; + + public DvmStudentLookupServiceTests() + { + _aaudContext = new TestableAAUDContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"AAUD_{Guid.NewGuid()}") + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options); + _service = new DvmStudentLookupService(_aaudContext); + } + + public void Dispose() + { + _aaudContext.Dispose(); + } + + #region LoadDvmStudentsAsync + + [Fact] + public async Task LoadDvmStudentsAsync_MapsMothraIdsToPersonIds() + { + await SeedStudentAsync("STU00001", 100, pidm: "20000001"); + await SeedStudentAsync("STU00002", 101, pidm: "20000002"); + + var (students, mothraToPersonId) = await _service.LoadDvmStudentsAsync(); + + Assert.Equal(2, students.Count); + Assert.Equal(100, mothraToPersonId["STU00001"]); + Assert.Equal(101, mothraToPersonId["STU00002"]); + } + + [Fact] + public async Task LoadDvmStudentsAsync_StudentWithNoAaudUser_IsListedButUnmapped() + { + await SeedStudentAsync("STU00009", personId: null, pidm: "20000009"); + + var (students, mothraToPersonId) = await _service.LoadDvmStudentsAsync(); + + Assert.Single(students); + Assert.Empty(mothraToPersonId); + } + + [Fact] + public async Task LoadDvmStudentsAsync_IgnoresNonStudentUsers() + { + await SeedStudentAsync("STU00001", 100, pidm: "20000001"); + _aaudContext.AaudUsers.Add(NewUser("FAC00001", 500)); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var (students, mothraToPersonId) = await _service.LoadDvmStudentsAsync(); + + Assert.Single(students); + Assert.Single(mothraToPersonId); + } + + #endregion + + #region GetDvmStudentAsync + + [Fact] + public async Task GetDvmStudentAsync_ReturnsIdentityAndPidm() + { + await SeedStudentAsync("STU00001", 100, pidm: "20000001", lastName: "Student", firstName: "Test"); + + var student = await _service.GetDvmStudentAsync(100); + + Assert.NotNull(student); + Assert.Equal(100, student.PersonId); + Assert.Equal("Student, Test", student.FullName); + Assert.Equal("V1", student.ClassLevel); + Assert.Equal(20000001, student.Pidm); + } + + [Fact] + public async Task GetDvmStudentAsync_NotADvmStudent_ReturnsNull() + { + _aaudContext.AaudUsers.Add(NewUser("FAC00001", 500)); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + Assert.Null(await _service.GetDvmStudentAsync(500)); + } + + [Fact] + public async Task GetDvmStudentAsync_UnparseablePidm_ReturnsIdentityWithoutOne() + { + // The record pages hand back an empty shell rather than failing when PIDM is missing. + await SeedStudentAsync("STU00001", 100, pidm: ""); + + var student = await _service.GetDvmStudentAsync(100); + + Assert.NotNull(student); + Assert.Null(student.Pidm); + } + + [Fact] + public async Task GetDvmStudentAsync_MissingClassLevel_ReadsAsEmpty() + { + await SeedStudentAsync("STU00001", 100, pidm: "20000001", classLevel: null); + + var student = await _service.GetDvmStudentAsync(100); + + Assert.NotNull(student); + Assert.Equal(string.Empty, student.ClassLevel); + } + + #endregion + + #region IsCurrentDvmStudentAsync and GetCurrentDvmPidmAsync + + [Fact] + public async Task IsCurrentDvmStudentAsync_CurrentStudent_ReturnsTrue() + { + await SeedStudentAsync("STU00001", 100, pidm: "20000001"); + + Assert.True(await _service.IsCurrentDvmStudentAsync(100)); + } + + [Fact] + public async Task IsCurrentDvmStudentAsync_NotAStudent_ReturnsFalse() + { + _aaudContext.AaudUsers.Add(NewUser("FAC00001", 500)); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + Assert.False(await _service.IsCurrentDvmStudentAsync(500)); + } + + [Fact] + public async Task GetCurrentDvmPidmAsync_ReturnsThePidmFromTheView() + { + await SeedStudentAsync("STU00001", 100, pidm: "20000001"); + + Assert.Equal(20000001, await _service.GetCurrentDvmPidmAsync(100)); + } + + [Fact] + public async Task GetCurrentDvmPidmAsync_UnknownPerson_ReturnsNull() + { + Assert.Null(await _service.GetCurrentDvmPidmAsync(999)); + } + + #endregion + + #region FormatEmail + + [Theory] + [InlineData("tstudent", "tstudent@ucdavis.edu")] + [InlineData("tstudent@ucdavis.edu", "tstudent@ucdavis.edu")] + [InlineData("someone@example.com", "someone@example.com")] + [InlineData("", "")] + [InlineData(null, "")] + public void FormatEmail_CompletesBareMailIdsOnly(string? mailId, string expected) + { + Assert.Equal(expected, DvmStudentLookupService.FormatEmail(mailId)); + } + + #endregion + + private async Task SeedStudentAsync(string mothraId, int? personId, string pidm, + string lastName = "Student", string firstName = "Test", string? classLevel = "V1") + { + _aaudContext.Set().Add(new VwDvmStudentsMaxTerm + { + IdsMothraId = mothraId, + PersonLastName = lastName, + PersonFirstName = firstName, + StudentsClassLevel = classLevel, + IdsPidm = pidm, + IdsMailid = "student", + StudentsTermCode = "202610" + }); + + if (personId != null) + { + _aaudContext.AaudUsers.Add(NewUser(mothraId, personId.Value, lastName, firstName)); + } + + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + private static AaudUser NewUser(string mothraId, int personId, string lastName = "Person", string firstName = "Test") => + new() + { + AaudUserId = personId, + ClientId = "UCD", + MothraId = mothraId, + LoginId = $"user{personId}", + DisplayFullName = $"{firstName} {lastName}", + DisplayFirstName = firstName, + DisplayLastName = lastName, + LastName = lastName, + FirstName = firstName + }; +} diff --git a/test/Students/EmergencyContactServiceTests.cs b/test/Students/EmergencyContactServiceTests.cs index d21fd43bd..b87d723a2 100644 --- a/test/Students/EmergencyContactServiceTests.cs +++ b/test/Students/EmergencyContactServiceTests.cs @@ -1400,7 +1400,9 @@ private EmergencyContactService CreateService() var userHelper = CreateMockUserHelper(); var logger = Substitute.For>(); return new EmergencyContactService( - _sisContext, _rapsContext, _aaudContext, userHelper, logger); + _sisContext, _rapsContext, _aaudContext, userHelper, logger, + new StudentAppAccessService(_rapsContext, userHelper), + new DvmStudentLookupService(_aaudContext)); } private static IUserHelper CreateMockUserHelper() @@ -1434,7 +1436,9 @@ private EmergencyContactService CreateServiceWithPermissions(params string[] gra } var logger = Substitute.For>(); return new EmergencyContactService( - _sisContext, _rapsContext, _aaudContext, userHelper, logger); + _sisContext, _rapsContext, _aaudContext, userHelper, logger, + new StudentAppAccessService(_rapsContext, userHelper), + new DvmStudentLookupService(_aaudContext)); } private static AaudUser CreateTestUser(int personId, string loginId) @@ -1468,7 +1472,9 @@ private static AaudUser CreateTestUser(int personId, string loginId) var userHelper = CreateMockUserHelper(); var logger = Substitute.For>(); var service = new EmergencyContactService( - _sisContext, _rapsContext, testAaudContext, userHelper, logger); + _sisContext, _rapsContext, testAaudContext, userHelper, logger, + new StudentAppAccessService(_rapsContext, userHelper), + new DvmStudentLookupService(testAaudContext)); return (testAaudContext, service); } @@ -1506,25 +1512,5 @@ private static void SeedDvmStudent( }); } - /// - /// AAUDContext subclass that maps keyless views as tables with keys, - /// enabling InMemory provider to store test data. - /// - private class TestableAAUDContext : AAUDContext - { - public TestableAAUDContext(DbContextOptions options) : base(options) { } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.IdsMothraId); - entity.ToTable("VwDvmStudentsMaxTerm"); - }); - } - } - #endregion } diff --git a/test/Students/ExportServiceTests.cs b/test/Students/ExportServiceTests.cs new file mode 100644 index 000000000..4c902ba07 --- /dev/null +++ b/test/Students/ExportServiceTests.cs @@ -0,0 +1,309 @@ +using ClosedXML.Excel; +using QuestPDF.Infrastructure; +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Services; + +namespace Viper.test.Students; + +/// +/// Smoke tests for the student self-service exports. They check that each generator produces a +/// real file with the expected headers and the row data in it, not that the layout looks right. +/// +public class ExportServiceTests +{ + private readonly CareerSelectionExportService _careerExports = new(); + private readonly EmergencyContactExportService _contactExports = new(); + + static ExportServiceTests() + { + // Program.cs sets this at startup; QuestPDF refuses to generate a document without it. + QuestPDF.Settings.License = LicenseType.Community; + } + + #region Career selection + + [Fact] + public void GenerateOverviewExcel_WritesCompletenessRows() + { + var data = new List + { + new() + { + PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", Email = "tstudent@ucdavis.edu", + DirectionCompleted = true, MentorName = "Vet, Ann", + } + }; + + using var workbook = new XLWorkbook(_careerExports.GenerateOverviewExcel(data)); + var sheet = workbook.Worksheets.First(); + + Assert.StartsWith("Generated ", sheet.Cell(1, 1).GetString()); + Assert.Equal("Class", sheet.Cell(2, 1).GetString()); + Assert.Equal("V1", sheet.Cell(3, 1).GetString()); + Assert.Equal("Student, Test", sheet.Cell(3, 2).GetString()); + // The overview reports whether each field is answered, not what was chosen. + Assert.Equal("Yes", sheet.Cell(3, 4).GetString()); + Assert.Equal("Vet, Ann", sheet.Cell(3, 8).GetString()); + } + + [Fact] + public void GenerateExcel_WritesTheSelectedValues() + { + var data = new List + { + new() + { + PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", + Direction = "Academia", PrimaryFocus = "Equine", ShortTermPlans = "Internship", + } + }; + + using var workbook = new XLWorkbook(_careerExports.GenerateExcel(data)); + var sheet = workbook.Worksheets.First(); + + Assert.Equal("Academia", sheet.Cell(3, 4).GetString()); + Assert.Equal("Equine", sheet.Cell(3, 5).GetString()); + Assert.Equal("Internship", sheet.Cell(3, 9).GetString()); + } + + [Fact] + public void GenerateExcel_NoStudents_StillWritesTheHeaderRow() + { + using var workbook = new XLWorkbook(_careerExports.GenerateExcel([])); + var sheet = workbook.Worksheets.First(); + + Assert.Equal("Class", sheet.Cell(2, 1).GetString()); + Assert.Equal(string.Empty, sheet.Cell(3, 1).GetString()); + } + + [Fact] + public void GenerateOverviewPdf_ProducesAPdf() + { + var data = new List + { + new() { PersonId = 100, FullName = "Student, Test", ClassLevel = "V1" } + }; + + AssertIsPdf(_careerExports.GenerateOverviewPdf(data)); + } + + [Fact] + public void GeneratePdf_LongStatements_ProducesAPdf() + { + // The report trims statements to an excerpt; a 5000-character one must not break it. + var data = new List + { + new() + { + PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", + ShortTermPlans = new string('x', 5000), + } + }; + + AssertIsPdf(_careerExports.GeneratePdf(data)); + } + + [Fact] + public void GenerateOverviewCsv_WritesHeadersAndCompletenessRows() + { + var data = new List + { + new() + { + PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", Email = "tstudent@ucdavis.edu", + DirectionCompleted = true, SecondaryFocusCompleted = false, MentorName = "Vet, Ann", + } + }; + + var lines = ReadCsv(_careerExports.GenerateOverviewCsv(data)); + + Assert.StartsWith("\"Class\",\"Name\",\"Email\"", lines[0], StringComparison.Ordinal); + // Asserted as a whole row rather than by Contains: the overview reports whether each field + // is answered rather than what was chosen, and Species 2 - unanswered and optional - is + // blank. A Contains check for a blank field also matches the equally blank Last Updated + // column, so it would pass even if the optional field started reporting a miss. + Assert.Equal( + "\"V1\",\"Student, Test\",\"tstudent@ucdavis.edu\",\"Yes\",\"No\",\"\",\"No\",\"Vet, Ann\",\"No\",\"No\",\"\"", + lines[1]); + } + + [Fact] + public void GenerateCsv_WritesWholeStatements() + { + var statement = new string('x', 5000); + var data = new List + { + new() + { + PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", + Direction = "Private Practice", ShortTermPlans = statement, + } + }; + + var csv = System.Text.Encoding.UTF8.GetString(_careerExports.GenerateCsv(data)); + + // The CSV carries the whole statement; only the PDF works from an excerpt. + Assert.Contains(statement, csv, StringComparison.Ordinal); + Assert.Contains("\"Private Practice\"", csv, StringComparison.Ordinal); + } + + [Fact] + public void GenerateCsv_QuotesAndCommasInAValue_StayInOneField() + { + var data = new List + { + new() { PersonId = 100, FullName = "Student, Test", ShortTermPlans = "She said \"hello\", twice" } + }; + + var csv = System.Text.Encoding.UTF8.GetString(_careerExports.GenerateCsv(data)); + + Assert.Contains("\"She said \"\"hello\"\", twice\"", csv, StringComparison.Ordinal); + } + + [Fact] + public void GenerateCsv_StatementStartingWithAnEquals_IsNotAFormula() + { + var data = new List + { + new() { PersonId = 100, FullName = "Student, Test", ShortTermPlans = "=1+1" } + }; + + var csv = System.Text.Encoding.UTF8.GetString(_careerExports.GenerateCsv(data)); + + // Student-entered text opened in Excel must not run as a formula (OWASP CWE-1236). + Assert.Contains("\"'=1+1\"", csv, StringComparison.Ordinal); + } + + [Fact] + public void GenerateCsv_NoStudents_StillWritesTheHeaderRow() + { + var lines = ReadCsv(_careerExports.GenerateCsv([])); + + Assert.Single(lines); + Assert.Contains("\"Last Updated\"", lines[0], StringComparison.Ordinal); + } + + [Fact] + public void GenerateCsv_IsUtf8WithABom() + { + var bytes = _careerExports.GenerateCsv([]); + + // Without the BOM Excel reads the file as the local codepage and mangles accented names. + Assert.Equal(System.Text.Encoding.UTF8.GetPreamble(), bytes.Take(3)); + } + + [Fact] + public void GenerateOverviewExcel_NoStudents_StillWritesTheHeaderRow() + { + using var workbook = new XLWorkbook(_careerExports.GenerateOverviewExcel([])); + var sheet = workbook.Worksheets.First(); + + Assert.Equal("Class", sheet.Cell(2, 1).GetString()); + Assert.Equal(string.Empty, sheet.Cell(3, 1).GetString()); + } + + [Fact] + public void GenerateOverviewCsv_NoStudents_StillWritesTheHeaderRow() + { + var lines = ReadCsv(_careerExports.GenerateOverviewCsv([])); + + Assert.Single(lines); + Assert.Contains("\"Last Updated\"", lines[0], StringComparison.Ordinal); + } + + [Fact] + public void GenerateOverviewPdf_NoStudents_ProducesAPdf() + { + // An empty export draws a "No matching records found" row spanning the table. + AssertIsPdf(_careerExports.GenerateOverviewPdf([])); + } + + [Fact] + public void GeneratePdf_NoStudents_ProducesAPdf() + { + AssertIsPdf(_careerExports.GeneratePdf([])); + } + + #endregion + + #region Emergency contacts + + [Fact] + public void GenerateOverviewExcel_WritesContactCompleteness() + { + var data = new List + { + new() + { + PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", + Email = "tstudent@ucdavis.edu", CellPhone = "5305551234", + StudentInfoComplete = 2, LocalContactComplete = 0, EmergencyContactComplete = 2, + } + }; + + using var workbook = new XLWorkbook(_contactExports.GenerateOverviewExcel(data)); + var sheet = workbook.Worksheets.First(); + + Assert.Equal("Name", sheet.Cell(2, 1).GetString()); + Assert.Equal("Student, Test", sheet.Cell(3, 1).GetString()); + Assert.Equal("Yes", sheet.Cell(3, 5).GetString()); + Assert.Equal("No", sheet.Cell(3, 6).GetString()); + Assert.Equal("Partial", sheet.Cell(3, 7).GetString()); + } + + [Fact] + public void GenerateExcel_WritesContactBlocks() + { + var data = new List + { + new() + { + PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", + Address = "One Shields Avenue", City = "Davis", Zip = "95616", + EmergencyContact = new ContactInfoDto { Name = "Doe, Jane", Relationship = "Parent" }, + } + }; + + using var workbook = new XLWorkbook(_contactExports.GenerateExcel(data)); + var sheet = workbook.Worksheets.First(); + + Assert.Contains("One Shields Avenue", sheet.Cell(3, 3).GetString()); + Assert.Contains("Davis 95616", sheet.Cell(3, 3).GetString()); + Assert.Contains("Doe, Jane", sheet.Cell(3, 5).GetString()); + } + + [Fact] + public void GenerateOverviewPdf_ContactsProducesAPdf() + { + var data = new List + { + new() { PersonId = 100, FullName = "Student, Test", ClassLevel = "V1" } + }; + + AssertIsPdf(_contactExports.GenerateOverviewPdf(data)); + } + + [Fact] + public void GeneratePdf_ContactsWithNoDetails_ProducesAPdf() + { + // Empty cells are the interesting case: the tagged-PDF layout needs a placeholder in each. + var data = new List + { + new() { PersonId = 100, FullName = "Student, Test", ClassLevel = "V1" } + }; + + AssertIsPdf(_contactExports.GeneratePdf(data)); + } + + #endregion + + /// The CSV rows, header first, with the BOM and the trailing newline dropped. + private static string[] ReadCsv(byte[] bytes) => + System.Text.Encoding.UTF8.GetString(bytes).Trim('\uFEFF').Trim().Split("\r\n"); + + private static void AssertIsPdf(byte[] bytes) + { + Assert.NotEmpty(bytes); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4)); + } +} diff --git a/test/Students/StudentAppAccessServiceTests.cs b/test/Students/StudentAppAccessServiceTests.cs new file mode 100644 index 000000000..bf7f7c44a --- /dev/null +++ b/test/Students/StudentAppAccessServiceTests.cs @@ -0,0 +1,191 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Students.Constants; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; +using Viper.Models.RAPS; + +namespace Viper.test.Students; + +/// +/// Tests for StudentAppAccessService: opening and closing a student self-service app, which is +/// held in RAPS as the app's student permission on the DVM student role. +/// +public sealed class StudentAppAccessServiceTests : IDisposable +{ + private const string Permission = "SVMSecure.CareerSelection.Student"; + private const string OtherPermission = "SVMSecure.Students.EmergencyContactStudent"; + + private readonly RAPSContext _rapsContext; + private readonly AAUDContext _aaudContext; + private readonly StudentAppAccessService _service; + private readonly int _permissionId; + private readonly int _otherPermissionId; + private readonly int _roleId; + + public StudentAppAccessServiceTests() + { + _rapsContext = new RAPSContext(InMemory("RAPS")); + _aaudContext = new AAUDContext(InMemory("AAUD")); + + var permission = new TblPermission { Permission = Permission }; + var otherPermission = new TblPermission { Permission = OtherPermission }; + var role = new TblRole { Role = StudentRoles.DvmStudents }; + _rapsContext.TblPermissions.AddRange(permission, otherPermission); + _rapsContext.TblRoles.Add(role); + _rapsContext.SaveChanges(); + _permissionId = permission.PermissionId; + _otherPermissionId = otherPermission.PermissionId; + _roleId = role.RoleId; + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + AaudUserId = 1, + ClientId = "UCD", + MothraId = "ADMIN001", + LoginId = "admin", + DisplayFullName = "Test Admin", + DisplayFirstName = "Test", + DisplayLastName = "Admin", + LastName = "Admin", + FirstName = "Test" + }); + _service = new StudentAppAccessService(_rapsContext, userHelper); + } + + public void Dispose() + { + _rapsContext.Dispose(); + _aaudContext.Dispose(); + } + + [Fact] + public async Task IsAppOpenAsync_NoGrant_ReturnsFalse() + { + Assert.False(await _service.IsAppOpenAsync(Permission)); + } + + [Fact] + public async Task IsAppOpenAsync_RoleHoldsThePermission_ReturnsTrue() + { + await GrantToRoleAsync(_permissionId, access: 1); + + Assert.True(await _service.IsAppOpenAsync(Permission)); + } + + [Fact] + public async Task IsAppOpenAsync_DenyRow_ReturnsFalse() + { + await GrantToRoleAsync(_permissionId, access: 0); + + Assert.False(await _service.IsAppOpenAsync(Permission)); + } + + [Fact] + public async Task IsAppOpenAsync_AnotherAppsGrant_DoesNotOpenThisOne() + { + // The permission name is what separates the apps; they share the one DVM student role. + await GrantToRoleAsync(_otherPermissionId, access: 1); + + Assert.False(await _service.IsAppOpenAsync(Permission)); + Assert.True(await _service.IsAppOpenAsync(OtherPermission)); + } + + [Fact] + public async Task ToggleAppAccessAsync_WhenClosed_OpensTheApp() + { + var isOpen = await _service.ToggleAppAccessAsync(Permission); + + Assert.True(isOpen); + var rolePermission = await _rapsContext.TblRolePermissions + .SingleAsync(rp => rp.PermissionId == _permissionId, TestContext.Current.CancellationToken); + Assert.Equal(1, rolePermission.Access); + Assert.Equal("admin", rolePermission.ModBy); + } + + [Fact] + public async Task ToggleAppAccessAsync_WhenOpen_RemovesTheRowRatherThanDenying() + { + // A role Deny would override an individual member Allow, silently breaking individual grants. + await GrantToRoleAsync(_permissionId, access: 1); + + var isOpen = await _service.ToggleAppAccessAsync(Permission); + + Assert.False(isOpen); + Assert.Empty(await _rapsContext.TblRolePermissions + .Where(rp => rp.PermissionId == _permissionId) + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ToggleAppAccessAsync_LegacyDenyRow_FlipsItToAllow() + { + await GrantToRoleAsync(_permissionId, access: 0); + + var isOpen = await _service.ToggleAppAccessAsync(Permission); + + Assert.True(isOpen); + var rolePermission = await _rapsContext.TblRolePermissions + .SingleAsync(rp => rp.PermissionId == _permissionId, TestContext.Current.CancellationToken); + Assert.Equal(1, rolePermission.Access); + } + + [Fact] + public async Task ToggleAppAccessAsync_LeavesTheOtherAppAlone() + { + await GrantToRoleAsync(_otherPermissionId, access: 1); + + await _service.ToggleAppAccessAsync(Permission); + + Assert.True(await _service.IsAppOpenAsync(OtherPermission)); + } + + [Fact] + public async Task GetPermissionIdAsync_ReturnsTheStoredId() + { + Assert.Equal(_permissionId, await _service.GetPermissionIdAsync(Permission)); + } + + [Fact] + public async Task GetPermissionIdAsync_UnknownPermission_Throws() + { + // A missing RAPS row is a deployment problem, not something a caller can recover from. + var ex = await Assert.ThrowsAsync( + () => _service.GetPermissionIdAsync("SVMSecure.NotReal")); + + Assert.Contains("SVMSecure.NotReal", ex.Message); + } + + [Fact] + public async Task IsAppOpenAsync_MissingDvmStudentRole_Throws() + { + _rapsContext.TblRoles.RemoveRange(_rapsContext.TblRoles); + await _rapsContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAsync(() => _service.IsAppOpenAsync(Permission)); + + Assert.Contains(StudentRoles.DvmStudents, ex.Message); + } + + private static DbContextOptions InMemory(string name) where T : DbContext => + new DbContextOptionsBuilder() + .UseInMemoryDatabase($"{name}_{Guid.NewGuid()}") + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + private async Task GrantToRoleAsync(int permissionId, byte access) + { + _rapsContext.TblRolePermissions.Add(new TblRolePermission + { + RoleId = _roleId, + PermissionId = permissionId, + Access = access, + ModTime = DateTime.Now, + ModBy = "seed" + }); + await _rapsContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } +} diff --git a/test/Students/TestableAAUDContext.cs b/test/Students/TestableAAUDContext.cs new file mode 100644 index 000000000..452576e2a --- /dev/null +++ b/test/Students/TestableAAUDContext.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Students; + +/// +/// AAUDContext subclass that maps keyless views as tables with keys, +/// enabling InMemory provider to store test data. +/// +internal class TestableAAUDContext : AAUDContext +{ + public TestableAAUDContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.IdsMothraId); + entity.ToTable("VwDvmStudentsMaxTerm"); + }); + } +} diff --git a/web/Areas/Students/Constants/CareerSelectionPermissions.cs b/web/Areas/Students/Constants/CareerSelectionPermissions.cs new file mode 100644 index 000000000..b1222789e --- /dev/null +++ b/web/Areas/Students/Constants/CareerSelectionPermissions.cs @@ -0,0 +1,28 @@ +namespace Viper.Areas.Students.Constants; + +public static class CareerSelectionPermissions +{ + public const string Admin = "SVMSecure.CareerSelection.Admin"; + public const string Student = "SVMSecure.CareerSelection.Student"; + public const string ReadOnly = "SVMSecure.CareerSelection.ReadOnly"; + public const string ViewOwn = "SVMSecure.CareerSelection.ViewOwn"; + public const string Faculty = "SVMSecure.CareerSelection.Faculty"; + + // Comma-separated sets for [Permission(Allow = ...)], which accepts any one of them. + + /// Those who can fill in a career selection: admins, and students while the app is open. + public const string Editors = Admin + "," + Student; + + /// + /// Those shown a student roster. Faculty pass this check but see only their mentees, which the + /// controller narrows further. + /// + public const string StudentListViewers = Admin + "," + ReadOnly + "," + Faculty; + + /// + /// Those who may open a career selection record: the roster viewers, plus a student's own + /// access. Which record each may open is narrowed in the controller, since a mentor's reach + /// depends on the data rather than on their permissions. + /// + public const string RecordViewers = Admin + "," + ReadOnly + "," + Faculty + "," + Student + "," + ViewOwn; +} diff --git a/web/Areas/Students/Constants/EmergencyContactPermissions.cs b/web/Areas/Students/Constants/EmergencyContactPermissions.cs index eee4abf6a..417cce775 100644 --- a/web/Areas/Students/Constants/EmergencyContactPermissions.cs +++ b/web/Areas/Students/Constants/EmergencyContactPermissions.cs @@ -5,5 +5,5 @@ public static class EmergencyContactPermissions public const string Admin = "SVMSecure.Students.EmergencyContactAdmin"; public const string Student = "SVMSecure.Students.EmergencyContactStudent"; public const string SISAllStudents = "SVMSecure.SIS.AllStudents"; - public const string StudentRoleName = "STUDENTS_DVM"; + public const string StudentRoleName = StudentRoles.DvmStudents; } diff --git a/web/Areas/Students/Constants/StudentRoles.cs b/web/Areas/Students/Constants/StudentRoles.cs new file mode 100644 index 000000000..aa5f794b4 --- /dev/null +++ b/web/Areas/Students/Constants/StudentRoles.cs @@ -0,0 +1,7 @@ +namespace Viper.Areas.Students.Constants; + +public static class StudentRoles +{ + /// The RAPS role holding every current DVM student. + public const string DvmStudents = "STUDENTS_DVM"; +} diff --git a/web/Areas/Students/Controllers/CareerSelectionController.cs b/web/Areas/Students/Controllers/CareerSelectionController.cs new file mode 100644 index 000000000..7db53a6d2 --- /dev/null +++ b/web/Areas/Students/Controllers/CareerSelectionController.cs @@ -0,0 +1,459 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Students.Constants; +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Services; +using Viper.Classes; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Web.Authorization; + +namespace Viper.Areas.Students.Controllers; + +[Route("/api/students/career-selection")] +public class CareerSelectionController : ApiController +{ + private readonly ICareerSelectionService _service; + private readonly ICareerSelectionOptionService _optionService; + private readonly ICareerSelectionExportService _exportService; + private readonly RAPSContext _rapsContext; + private readonly IUserHelper _userHelper; + private readonly ILogger _logger; + + public CareerSelectionController( + ICareerSelectionService service, + ICareerSelectionOptionService optionService, + ICareerSelectionExportService exportService, + RAPSContext rapsContext, + IUserHelper userHelper, + ILogger logger) + { + _service = service; + _optionService = optionService; + _exportService = exportService; + _rapsContext = rapsContext; + _userHelper = userHelper; + _logger = logger; + } + + /// + /// Get every option of one dropdown type, catch-all last. The type is the URL slug: career, + /// species or post-grad. Serves both the career selection form and the admin page that + /// manages the lists; only admins get usage counts, which is needed only for managing options. + /// + [HttpGet("options/{type}")] + [Permission(Allow = CareerSelectionPermissions.Editors)] + public async Task>> GetOptions(string type, CancellationToken ct = default) + { + if (!CareerOptionTypes.TryParseSlug(type, out var optionType)) + { + return NotFound(); + } + + var currentUser = _userHelper.GetCurrentUser(); + var isAdmin = currentUser != null + && _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Admin); + + var result = await _optionService.GetOptionsAsync(optionType, includeUsage: isAdmin, ct); + return Ok(result); + } + + /// + /// Add an option to one dropdown. Names are trimmed and must be unique within the dropdown, + /// ignoring case. + /// + [HttpPost("options/{type}")] + [Permission(Allow = CareerSelectionPermissions.Admin)] + public async Task> CreateOption(string type, [FromBody] CareerSelectionOptionRequest request, CancellationToken ct = default) + { + if (!CareerOptionTypes.TryParseSlug(type, out var optionType)) + { + return NotFound(); + } + + var result = await _optionService.CreateOptionAsync(optionType, request.Label, ct); + if (result.Status == CareerSelectionOptionWriteStatus.Success) + { + _logger.LogInformation("Career selection {OptionType} option {OptionId} added: {Label}", + optionType, result.Option!.Id, LogSanitizer.SanitizeString(result.Option.Label)); + return CreatedAtAction(nameof(GetOptions), new { type }, result.Option); + } + return OptionWriteFailure(result); + } + + /// + /// Rename an option. The catch-all option cannot be renamed. + /// + [HttpPut("options/{type}/{id:int}")] + [Permission(Allow = CareerSelectionPermissions.Admin)] + public async Task> UpdateOption(string type, int id, [FromBody] CareerSelectionOptionRequest request, CancellationToken ct = default) + { + if (!CareerOptionTypes.TryParseSlug(type, out var optionType)) + { + return NotFound(); + } + + var result = await _optionService.UpdateOptionAsync(optionType, id, request.Label, ct); + if (result.Status == CareerSelectionOptionWriteStatus.Success) + { + _logger.LogInformation("Career selection {OptionType} option {OptionId} renamed: {Label}", + optionType, id, LogSanitizer.SanitizeString(result.Option!.Label)); + return Ok(result.Option); + } + return OptionWriteFailure(result); + } + + /// + /// Delete an option. Refused for the catch-all option, and for any option a career selection + /// uses, since the selections hold a foreign key to it. + /// + [HttpDelete("options/{type}/{id:int}")] + [Permission(Allow = CareerSelectionPermissions.Admin)] + public async Task DeleteOption(string type, int id, CancellationToken ct = default) + { + if (!CareerOptionTypes.TryParseSlug(type, out var optionType)) + { + return NotFound(); + } + + var result = await _optionService.DeleteOptionAsync(optionType, id, ct); + if (result.Status == CareerSelectionOptionWriteStatus.Success) + { + _logger.LogInformation("Career selection {OptionType} option {OptionId} deleted", optionType, id); + return NoContent(); + } + return OptionWriteFailure(result); + } + + // The error strings become the response's errorMessage, which the manage page shows as-is. + private ActionResult OptionWriteFailure(CareerSelectionOptionWriteResult result) => result.Status switch + { + CareerSelectionOptionWriteStatus.NotFound => NotFound("The option no longer exists. Reload the page and try again."), + CareerSelectionOptionWriteStatus.Conflict => Conflict(result.Error), + _ => BadRequest(result.Error), + }; + + /// + /// Search current SVM affiliates for the mentor picker. Admin only: the mentor is + /// admin-managed and read-only to everyone else, so nobody else needs to search for one. + /// + [HttpGet("mentors")] + [Permission(Allow = CareerSelectionPermissions.Admin)] + public async Task>> SearchMentors(string search, CancellationToken ct = default) + { + var result = await _service.SearchMentorsAsync(search, ct); + return Ok(result); + } + + /// + /// Which students the caller may be shown in a roster, resolved once here rather than + /// separately at each roster endpoint. + /// + private StudentListAccess ResolveStudentListAccess() + { + var currentUser = _userHelper.GetCurrentUser(); + if (currentUser == null) + { + return StudentListAccess.Denied; + } + + return _service.ResolveScope(currentUser) switch + { + CareerSelectionScope.All => StudentListAccess.AllStudents, + CareerSelectionScope.Mentored => StudentListAccess.MentoredBy(currentUser.MothraId), + // A student has a record but no roster: their own page is reached directly. + _ => StudentListAccess.Denied + }; + } + + /// + /// Get all current DVM students with their career selection completeness status. + /// A faculty mentor sees only the students they mentor. + /// + [HttpGet] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public async Task>> GetStudentCareerList() + { + var access = ResolveStudentListAccess(); + if (access.IsDenied) + { + return ForbidApi(); + } + + var result = await _service.GetStudentCareerListAsync(access); + return Ok(result); + } + + /// + /// Get career selection details for a specific student. + /// The attribute admits anyone holding one of + /// . Which record each of them may + /// open is decided below. Admins and read-only viewers may open any record. + /// Faculty mentors may open the records of their mentees. + /// Everyone else with app access is limited to their own. + /// + [HttpGet("{personId:int}")] + [Permission(Allow = CareerSelectionPermissions.RecordViewers)] + public async Task> GetStudentCareerDetail(int personId) + { + var currentUser = _userHelper.GetCurrentUser(); + if (currentUser == null) + { + return Unauthorized(); + } + + var isAdmin = _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Admin); + var isReadOnly = _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.ReadOnly); + var isStudent = _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Student); + var isViewOwn = _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.ViewOwn); + var isFaculty = _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Faculty); + + if (!isAdmin && !isReadOnly && !isStudent && !isViewOwn && !isFaculty) + { + _logger.LogWarning( + "User {LoginId} attempted to access career selection for PersonId {PersonId} without any authorized role", + LogSanitizer.SanitizeId(currentUser.LoginId), + LogSanitizer.SanitizeId(personId.ToString())); + return ForbidApi(); + } + + if (!isAdmin && !isReadOnly && currentUser.AaudUserId != personId + && !(isFaculty && await _service.IsMentorOfAsync(currentUser.MothraId, personId))) + { + _logger.LogWarning( + "User {LoginId} attempted to access career selection for PersonId {PersonId}", + LogSanitizer.SanitizeId(currentUser.LoginId), + LogSanitizer.SanitizeId(personId.ToString())); + return ForbidApi(); + } + + var canViewStudentList = isAdmin || isReadOnly || isFaculty; + var canEdit = _service.CanEdit(personId, currentUser); + var result = await _service.GetStudentCareerDetailAsync(personId, canEdit, canViewStudentList); + if (result == null) + { + return NotFound(); + } + + return Ok(result); + } + + /// + /// Update or create career selection information for a student. + /// Admin can update any student. Students can update their own record when permitted. + /// Returns the updated detail DTO so the frontend can refresh without a second GET. + /// + [HttpPut("{personId:int}")] + [Permission(Allow = CareerSelectionPermissions.Editors)] + public async Task> UpdateStudentCareerSelection(int personId, StudentCareerInfoDto request) + { + var currentUser = _userHelper.GetCurrentUser(); + if (currentUser == null) + { + return Unauthorized(); + } + + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + var canEdit = _service.CanEdit(personId, currentUser); + if (!canEdit) + { + _logger.LogWarning( + "User {LoginId} attempted to update career selection for PersonId {PersonId} without permission", + LogSanitizer.SanitizeId(currentUser.LoginId), + LogSanitizer.SanitizeId(personId.ToString())); + return ForbidApi(); + } + + var isAdmin = _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Admin); + var isReadOnly = _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.ReadOnly); + + try + { + var errors = await _service.UpdateStudentCareerSelectionAsync(personId, request, isAdmin); + if (errors.Count > 0) + { + foreach (var error in errors) + { + ModelState.AddModelError("CareerSelection", error); + } + return ValidationProblem(ModelState); + } + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, + "Cannot update career selection for PersonId {PersonId}: {Message}", + LogSanitizer.SanitizeId(personId.ToString()), + LogSanitizer.SanitizeString(ex.Message)); + return NotFound(); + } + catch (DbUpdateException ex) when (ex.IsDataRejection()) + { + // Most likely an option that was validated above but deleted before this save landed: + // the selections hold a foreign key to it and the delete behavior is Restrict. + _logger.LogWarning(ex, + "Database error updating career selection for PersonId {PersonId}: {Message}", + LogSanitizer.SanitizeId(personId.ToString()), + LogSanitizer.SanitizeString(ex.InnerException?.Message ?? ex.Message)); + return BadRequest( + "Failed to save the career selection. One of the options may have changed. Reload the page and try again."); + } + + // Return the refreshed detail so the frontend has updated LastUpdated + var result = await _service.GetStudentCareerDetailAsync(personId, canEdit, isAdmin || isReadOnly); + if (result == null) + { + return NotFound(); + } + + return Ok(result); + } + + /// + /// Get all student career selections formatted for a report. + /// + [HttpGet("report")] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public async Task>> GetStudentCareerReport() + { + var access = ResolveStudentListAccess(); + if (access.IsDenied) + { + return ForbidApi(); + } + + var result = await _service.GetStudentCareerReportAsync(access); + return Ok(result); + } + + /// + /// Get current access status: whether the app is open for all students, + /// based on the presence of the .Student permission in the STUDENTS_DVM role. + /// + [HttpGet("access/status")] + [Permission(Allow = CareerSelectionPermissions.Admin)] + public async Task> GetAccessStatus() + { + var result = await _service.IsAppOpenAsync(); + return Ok(result); + } + + /// + /// Toggle app-wide student self-service access on or off. + /// + [HttpPost("access/toggle-app")] + [Permission(Allow = CareerSelectionPermissions.Admin)] + public async Task> ToggleAppAccess() + { + // A missing RAPS permission or role throws InvalidOperationException, which is a + // deployment problem rather than anything the admin can act on. It is left to the 500 + // path deliberately, so it is logged at Error with a correlation ID. + try + { + var newState = await _service.ToggleAppAccessAsync(); + return Ok(newState); + } + catch (DbUpdateException ex) when (ex.IsDataRejection()) + { + // Two admins toggling at once: both read no role-permission row and both insert one. + _logger.LogWarning(ex, "Database error toggling career selection app access: {Message}", + LogSanitizer.SanitizeString(ex.InnerException?.Message ?? ex.Message)); + return BadRequest( + "Failed to change student access. Another administrator may have just changed it. Reload the page and try again."); + } + } + + /// + /// Export the overview (completeness summary) as an Excel file. + /// + [HttpPost("export/overview/excel")] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public Task ExportOverviewExcel( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( + request, + _service.GetStudentCareerListAsync, + data => ExcelFile(_exportService.GenerateOverviewExcel(data), "CareerSelectionOverview")); + + /// + /// Export the overview (completeness summary) as a PDF file. A POST rather than a GET because + /// the grid's row keys can outgrow a query string. + /// + [HttpPost("export/overview/pdf")] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public Task ExportOverviewPdf( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( + request, + _service.GetStudentCareerListAsync, + data => File(_exportService.GenerateOverviewPdf(data), "application/pdf", + $"CareerSelectionOverview_{DateTime.Now:yyyyMMdd}.pdf")); + + /// + /// Export the overview (completeness summary) as a CSV file. + /// + [HttpPost("export/overview/csv")] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public Task ExportOverviewCsv( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( + request, + _service.GetStudentCareerListAsync, + data => CsvFile(_exportService.GenerateOverviewCsv(data), "CareerSelectionOverview")); + + /// + /// Export all career selections as an Excel file. + /// + [HttpPost("export/excel")] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public Task ExportExcel( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( + request, + _service.GetStudentCareerReportAsync, + data => ExcelFile(_exportService.GenerateExcel(data), "CareerSelection")); + + /// + /// Export all career selections as a PDF file. A POST rather than a GET because the grid's + /// row keys can outgrow a query string. + /// + [HttpPost("export/pdf")] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public Task ExportPdf( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( + request, + _service.GetStudentCareerReportAsync, + data => File(_exportService.GeneratePdf(data), "application/pdf", + $"CareerSelection_{DateTime.Now:yyyyMMdd}.pdf")); + + /// + /// Export all career selections as a CSV file. + /// + [HttpPost("export/csv")] + [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] + public Task ExportCsv( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( + request, + _service.GetStudentCareerReportAsync, + data => CsvFile(_exportService.GenerateCsv(data), "CareerSelection")); + + /// + /// Runs an export over the students the caller may see: a faculty mentor's export is narrowed + /// to their mentees, the same as the roster it mirrors, and then to the grid's rows when the + /// request names them. Nothing to export still produces a file, headers only, as legacy did. + /// + private async Task ExportAsync(CareerSelectionExportRequest? request, + Func>> loadData, Func, ActionResult> buildFile) + where T : StudentCareerRowDto + { + var access = ResolveStudentListAccess(); + if (access.IsDenied) + { + return ForbidApi(); + } + + var data = await loadData(access); + return buildFile(CareerSelectionExportRequest.ApplyRowKeys(data, request)); + } +} diff --git a/web/Areas/Students/Controllers/EmergencyContactController.cs b/web/Areas/Students/Controllers/EmergencyContactController.cs index 3992ed0fc..bf85d95f5 100644 --- a/web/Areas/Students/Controllers/EmergencyContactController.cs +++ b/web/Areas/Students/Controllers/EmergencyContactController.cs @@ -266,17 +266,8 @@ public async Task> CanEdit(int personId) public async Task ExportOverviewExcel() { var data = await _service.GetStudentContactListAsync(); - if (data.Count == 0) - { - return NoContent(); - } - - var stream = _exportService.GenerateOverviewExcel(data); - var filename = ExcelHelper.BuildExportFilename(new ExportFilenameOptions - { - ReportName = "EmergencyContactOverview" - }); - return File(stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename); + return FileOrNoContent(data, + d => ExcelFile(_exportService.GenerateOverviewExcel(d), "EmergencyContactOverview")); } /// @@ -287,13 +278,9 @@ public async Task ExportOverviewExcel() public async Task ExportOverviewPdf() { var data = await _service.GetStudentContactListAsync(); - if (data.Count == 0) - { - return NoContent(); - } - - var pdfBytes = _exportService.GenerateOverviewPdf(data); - return InlineFile(pdfBytes, "application/pdf", $"EmergencyContactOverview_{DateTime.Now:yyyyMMdd}.pdf"); + return FileOrNoContent(data, + d => InlineFile(_exportService.GenerateOverviewPdf(d), "application/pdf", + $"EmergencyContactOverview_{DateTime.Now:yyyyMMdd}.pdf")); } /// @@ -304,17 +291,8 @@ public async Task ExportOverviewPdf() public async Task ExportExcel() { var data = await _service.GetStudentContactReportAsync(); - if (data.Count == 0) - { - return NoContent(); - } - - var stream = _exportService.GenerateExcel(data); - var filename = ExcelHelper.BuildExportFilename(new ExportFilenameOptions - { - ReportName = "EmergencyContacts" - }); - return File(stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename); + return FileOrNoContent(data, + d => ExcelFile(_exportService.GenerateExcel(d), "EmergencyContacts")); } /// @@ -325,12 +303,8 @@ public async Task ExportExcel() public async Task ExportPdf() { var data = await _service.GetStudentContactReportAsync(); - if (data.Count == 0) - { - return NoContent(); - } - - var pdfBytes = _exportService.GeneratePdf(data); - return InlineFile(pdfBytes, "application/pdf", $"EmergencyContacts_{DateTime.Now:yyyyMMdd}.pdf"); + return FileOrNoContent(data, + d => InlineFile(_exportService.GeneratePdf(d), "application/pdf", + $"EmergencyContacts_{DateTime.Now:yyyyMMdd}.pdf")); } } diff --git a/web/Areas/Students/Models/CareerDropdownOption.cs b/web/Areas/Students/Models/CareerDropdownOption.cs new file mode 100644 index 000000000..11e4a9319 --- /dev/null +++ b/web/Areas/Students/Models/CareerDropdownOption.cs @@ -0,0 +1,10 @@ +namespace Viper.Areas.Students.Models; + +public class CareerDropdownOption +{ + // Not required: this type is both the response shape and part of the save body, and a save + // never reads the label. + public string Label { get; set; } = string.Empty; + public int? Value { get; set; } + public bool IsOther { get; set; } +} diff --git a/web/Areas/Students/Models/CareerOptionType.cs b/web/Areas/Students/Models/CareerOptionType.cs new file mode 100644 index 000000000..dd43f657a --- /dev/null +++ b/web/Areas/Students/Models/CareerOptionType.cs @@ -0,0 +1,33 @@ +namespace Viper.Areas.Students.Models; + +/// +/// The three option tables behind the career selection form's dropdowns. +/// +public enum CareerOptionType +{ + Career, + Species, + PostGrad +} + +public static class CareerOptionTypes +{ + private static readonly Dictionary BySlug = new(StringComparer.OrdinalIgnoreCase) + { + ["career"] = CareerOptionType.Career, + ["species"] = CareerOptionType.Species, + ["post-grad"] = CareerOptionType.PostGrad, + }; + + /// Resolves the URL segment the option management routes are keyed on. + public static bool TryParseSlug(string? slug, out CareerOptionType type) + { + if (slug != null && BySlug.TryGetValue(slug, out type)) + { + return true; + } + + type = default; + return false; + } +} diff --git a/web/Areas/Students/Models/CareerSelectionExportRequest.cs b/web/Areas/Students/Models/CareerSelectionExportRequest.cs new file mode 100644 index 000000000..016a3117f --- /dev/null +++ b/web/Areas/Students/Models/CareerSelectionExportRequest.cs @@ -0,0 +1,47 @@ +namespace Viper.Areas.Students.Models; + +/// +/// The optional body for a career selection export: the grid's rows, filtered and sorted as the +/// user sees them, so the file matches the screen. +/// +/// +/// The client sends row keys rather than its search text and sort, because re-running the grid's +/// search on the server would mean copying rules that only hold in the browser: Last Updated is +/// searched as the browser's locale date, the overview's completeness columns are searched as +/// true/false, and the grid's default sort orders blank values (Last Updated, Mentor, Species 2) +/// inconsistently. Keys can only narrow and reorder the rows the caller may already see. +/// +public class CareerSelectionExportRequest +{ + /// + /// The rows to export, in order. Null exports everything the caller may see; empty exports + /// headers only, as when the grid's search matches nothing. + /// + public List? RowKeys { get; set; } + + /// + /// Narrows to the requested keys, in the order requested. A key outside + /// the rows is ignored, so a request cannot reach a student the caller may not see, and a + /// repeated key keeps its first position. + /// + public static List ApplyRowKeys(List rows, CareerSelectionExportRequest? request) + where T : StudentCareerRowDto + { + if (request?.RowKeys == null) + { + return rows; + } + + var positionByKey = new Dictionary(StringComparer.Ordinal); + // A null in the list names no row; skipping it keeps a malformed body from throwing. + foreach (var key in request.RowKeys.OfType()) + { + positionByKey.TryAdd(key, positionByKey.Count); + } + + return rows + .Where(r => positionByKey.ContainsKey(r.RowKey)) + .OrderBy(r => positionByKey[r.RowKey]) + .ToList(); + } +} diff --git a/web/Areas/Students/Models/CareerSelectionMapper.cs b/web/Areas/Students/Models/CareerSelectionMapper.cs new file mode 100644 index 000000000..c177364fe --- /dev/null +++ b/web/Areas/Students/Models/CareerSelectionMapper.cs @@ -0,0 +1,42 @@ +using Viper.Areas.Students.Models.Entities; + +namespace Viper.Areas.Students.Models; + +/// +/// Deliberately not a Mapperly [Mapper]: the DTO carries a dropdown option object where the +/// entity keeps a nullable option id beside a free-text column, so there is no field the +/// generator could map. +/// +public static class CareerSelectionMapper +{ + /// + /// Applies the editable career selection fields to the entity. + /// Every choice is a stored option row, the catch-all included, so a null option means + /// nothing was selected. The caller is expected to have normalized the submitted options and + /// to have refreshed IsOther from the option tables. + /// FacultyMothraId is deliberately absent — the mentor is admin-only, so it is resolved in + /// the service, which knows who the caller is. + /// + public static void ApplyStudentInfoToEntity(StudentCareerInfoDto source, CareerSelection target) + { + target.Career = source.Direction?.Value; + target.CareerOther = OtherTextFor(source.Direction, source.DirectionOther); + target.FirstSpecies = source.PrimaryFocus?.Value; + target.FirstSpeciesOther = OtherTextFor(source.PrimaryFocus, source.PrimaryFocusOther); + target.SecondSpecies = source.SecondaryFocus?.Value; + target.SecondSpeciesOther = OtherTextFor(source.SecondaryFocus, source.SecondaryFocusOther); + target.PostGrad = source.PostGrad?.Value; + target.ShortTermStatement = Normalize(source.ShortTermPlans); + target.LongTermStatement = Normalize(source.LongTermPlans); + } + + /// + /// The free text means something only while the catch-all option is selected. Anything else + /// clears it, so a stale value cannot reappear when the student switches back to "Other". + /// + private static string OtherTextFor(CareerDropdownOption? option, string? text) + => option?.IsOther == true ? Normalize(text) : string.Empty; + + // Trimmed, and empty rather than null so every text column stores the same "unfilled" value. + private static string Normalize(string? value) => (value ?? string.Empty).Trim(); +} diff --git a/web/Areas/Students/Models/CareerSelectionOptionDto.cs b/web/Areas/Students/Models/CareerSelectionOptionDto.cs new file mode 100644 index 000000000..0f1aab08b --- /dev/null +++ b/web/Areas/Students/Models/CareerSelectionOptionDto.cs @@ -0,0 +1,17 @@ +namespace Viper.Areas.Students.Models; + +/// +/// One career selection dropdown option, served to the form and the admin page alike. The three +/// option tables name their id and label columns differently; this flattens them to one shape. +/// +public class CareerSelectionOptionDto +{ + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + public bool IsOther { get; set; } + /// + /// How many career selections reference the option. Anything above zero blocks deletion, + /// since the selections hold a foreign key to it. Only populated for admins; zero otherwise. + /// + public int UsageCount { get; set; } +} diff --git a/web/Areas/Students/Models/CareerSelectionOptionRequest.cs b/web/Areas/Students/Models/CareerSelectionOptionRequest.cs new file mode 100644 index 000000000..db101df2d --- /dev/null +++ b/web/Areas/Students/Models/CareerSelectionOptionRequest.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.Students.Models; + +/// +/// The body for adding or renaming a career selection dropdown option. +/// +public class CareerSelectionOptionRequest +{ + // Some columns have a max length of under 200; this is handled by the service layer. + [Required(AllowEmptyStrings = false, ErrorMessage = "Please enter a name.")] + [MaxLength(200)] + public string Label { get; set; } = string.Empty; +} diff --git a/web/Areas/Students/Models/CareerSelectionOptionWriteResult.cs b/web/Areas/Students/Models/CareerSelectionOptionWriteResult.cs new file mode 100644 index 000000000..dfce3d54a --- /dev/null +++ b/web/Areas/Students/Models/CareerSelectionOptionWriteResult.cs @@ -0,0 +1,34 @@ +namespace Viper.Areas.Students.Models; + +public enum CareerSelectionOptionWriteStatus +{ + Success, + NotFound, + // The request itself is unusable: a blank or overlong name, or an attempt to change the + // catch-all option. + Invalid, + // The request is fine but the current data refuses it: a duplicate name, or an option in use. + Conflict, +} + +/// +/// The outcome of adding, renaming or deleting a career selection dropdown option. Option is set +/// for a successful add or rename; Error is set for anything other than success or not found. +/// +public sealed record CareerSelectionOptionWriteResult( + CareerSelectionOptionWriteStatus Status, + CareerSelectionOptionDto? Option = null, + string? Error = null) +{ + public static CareerSelectionOptionWriteResult Success(CareerSelectionOptionDto? option = null) => + new(CareerSelectionOptionWriteStatus.Success, option); + + public static CareerSelectionOptionWriteResult NotFound() => + new(CareerSelectionOptionWriteStatus.NotFound); + + public static CareerSelectionOptionWriteResult Invalid(string error) => + new(CareerSelectionOptionWriteStatus.Invalid, Error: error); + + public static CareerSelectionOptionWriteResult Conflict(string error) => + new(CareerSelectionOptionWriteStatus.Conflict, Error: error); +} diff --git a/web/Areas/Students/Models/Entities/CareerOption.cs b/web/Areas/Students/Models/Entities/CareerOption.cs new file mode 100644 index 000000000..e5c05e46c --- /dev/null +++ b/web/Areas/Students/Models/Entities/CareerOption.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Students.Models.Entities; + +public class CareerOption : ICareerSelectionOption +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int CareerOptionId { get; set; } + [MaxLength(100, ErrorMessage = "Career Direction option must be 100 characters or fewer.")] + public required string Career { get; set; } + // Marks the catch-all row. It sorts last, cannot be deleted or renamed, and is what tells + // the form to collect free text instead of taking the label at face value. + public bool IsOther { get; set; } + + int ICareerSelectionOption.Id => CareerOptionId; + string ICareerSelectionOption.Label { get => Career; set => Career = value; } +} diff --git a/web/Areas/Students/Models/Entities/CareerSelection.cs b/web/Areas/Students/Models/Entities/CareerSelection.cs new file mode 100644 index 000000000..f49a7ac66 --- /dev/null +++ b/web/Areas/Students/Models/Entities/CareerSelection.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Students.Models.Entities; + +public class CareerSelection +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int CareerSelectionId { get; set; } + public required int Pidm { get; set; } + public required DateTime DateAdded { get; set; } + public DateTime? DateModified { get; set; } + public int? Career { get; set; } + [MaxLength(200, ErrorMessage = "Career Direction must be 200 characters or fewer.")] + public string? CareerOther { get; set; } + public int? FirstSpecies { get; set; } + [MaxLength(200, ErrorMessage = "Primary Species must be 200 characters or fewer.")] + public string? FirstSpeciesOther { get; set; } + public int? SecondSpecies { get; set; } + [MaxLength(200, ErrorMessage = "Secondary Species must be 200 characters or fewer.")] + public string? SecondSpeciesOther { get; set; } + public int? PostGrad { get; set; } + [MaxLength(5000, ErrorMessage = "Short Term Statement must be 5000 characters or fewer.")] + public string? ShortTermStatement { get; set; } + [MaxLength(5000, ErrorMessage = "Long Term Statement must be 5000 characters or fewer.")] + public string? LongTermStatement { get; set; } + [MaxLength(8, ErrorMessage = "Faculty Mothra ID must be 8 characters or fewer.")] + public string? FacultyMothraId { get; set; } + public CareerOption? CareerOption { get; set; } + public SpeciesOption? FirstSpeciesOption { get; set; } + public SpeciesOption? SecondSpeciesOption { get; set; } + public PostGradOption? PostGradOption { get; set; } +} diff --git a/web/Areas/Students/Models/Entities/ICareerSelectionOption.cs b/web/Areas/Students/Models/Entities/ICareerSelectionOption.cs new file mode 100644 index 000000000..b3a4e829f --- /dev/null +++ b/web/Areas/Students/Models/Entities/ICareerSelectionOption.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Students.Models.Entities; + +/// +/// A career selection dropdown option. Implemented by every option table so the catch-all row +/// can be found, sorted and protected without matching on its label, and so the tables can be +/// managed through one code path despite naming their id and label columns differently. +/// +public interface ICareerSelectionOption +{ + bool IsOther { get; } + + // Implemented explicitly on each entity, which keeps EF from mapping them as extra columns. + // They are for in-memory use only: EF cannot translate them into a query. + int Id { get; } + string Label { get; set; } +} diff --git a/web/Areas/Students/Models/Entities/PostGradOption.cs b/web/Areas/Students/Models/Entities/PostGradOption.cs new file mode 100644 index 000000000..fafc77499 --- /dev/null +++ b/web/Areas/Students/Models/Entities/PostGradOption.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Students.Models.Entities; + +public class PostGradOption : ICareerSelectionOption +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PostGradOptionId { get; set; } + [MaxLength(200, ErrorMessage = "Post-graduation option must be 200 characters or fewer.")] + public required string PostGrad { get; set; } + // Marks the catch-all row. It sorts last, cannot be deleted or renamed, and is what tells + // the form to collect free text instead of taking the label at face value. + public bool IsOther { get; set; } + + int ICareerSelectionOption.Id => PostGradOptionId; + string ICareerSelectionOption.Label { get => PostGrad; set => PostGrad = value; } +} diff --git a/web/Areas/Students/Models/Entities/SpeciesOption.cs b/web/Areas/Students/Models/Entities/SpeciesOption.cs new file mode 100644 index 000000000..d918b93e6 --- /dev/null +++ b/web/Areas/Students/Models/Entities/SpeciesOption.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Students.Models.Entities; + +public class SpeciesOption : ICareerSelectionOption +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int SpeciesOptionId { get; set; } + [MaxLength(100, ErrorMessage = "Species option must be 100 characters or fewer.")] + public required string Species { get; set; } + // Marks the catch-all row. It sorts last, cannot be deleted or renamed, and is what tells + // the form to collect free text instead of taking the label at face value. + public bool IsOther { get; set; } + + int ICareerSelectionOption.Id => SpeciesOptionId; + string ICareerSelectionOption.Label { get => Species; set => Species = value; } +} diff --git a/web/Areas/Students/Models/MentorOptionDto.cs b/web/Areas/Students/Models/MentorOptionDto.cs new file mode 100644 index 000000000..77971f370 --- /dev/null +++ b/web/Areas/Students/Models/MentorOptionDto.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Students.Models; + +/// +/// One SVM affiliate offered by the mentor picker. The PersonId is what a career selection is +/// saved with; the IamId is carried because the shared person picker keys its options on it. +/// +public class MentorOptionDto +{ + public int PersonId { get; set; } + public string IamId { get; set; } = string.Empty; + /// "Last, First", matching how mentors read everywhere else in the app. + public string FullName { get; set; } = string.Empty; + public string? LoginId { get; set; } + public string? MailId { get; set; } +} diff --git a/web/Areas/Students/Models/StudentCareerDetailDto.cs b/web/Areas/Students/Models/StudentCareerDetailDto.cs new file mode 100644 index 000000000..d455ad6ee --- /dev/null +++ b/web/Areas/Students/Models/StudentCareerDetailDto.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Students.Models; + +/// +/// Detailed career information for a student, matching the client's expected format. +/// +public class StudentCareerDetailDto +{ + public int PersonId { get; set; } + public required string FullName { get; set; } + public string? ClassLevel { get; set; } + public StudentCareerInfoDto StudentInfo { get; set; } = new StudentCareerInfoDto(); + public bool CanEdit { get; set; } + public bool CanViewStudentList { get; set; } + public DateTime? LastUpdated { get; set; } +} diff --git a/web/Areas/Students/Models/StudentCareerInfoDto.cs b/web/Areas/Students/Models/StudentCareerInfoDto.cs new file mode 100644 index 000000000..4e0bcebad --- /dev/null +++ b/web/Areas/Students/Models/StudentCareerInfoDto.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.Students.Models; + +/// +/// Career selection information for a student, matching the client's expected format. +/// +public class StudentCareerInfoDto +{ + public CareerDropdownOption? Direction { get; set; } + [MaxLength(200, ErrorMessage = "Career Direction must be 200 characters or fewer.")] + public string? DirectionOther { get; set; } + public CareerDropdownOption? PrimaryFocus { get; set; } + [MaxLength(200, ErrorMessage = "Primary Species must be 200 characters or fewer.")] + public string? PrimaryFocusOther { get; set; } + public CareerDropdownOption? SecondaryFocus { get; set; } + [MaxLength(200, ErrorMessage = "Secondary Species must be 200 characters or fewer.")] + public string? SecondaryFocusOther { get; set; } + public int? MentorId { get; set; } + public string? MentorName { get; set; } + /// + /// Display only, like : the mentor picker keys its options on the IAM + /// id, so a saved mentor needs one to render. A save reads the mentor from + /// and ignores whatever the client sends here. + /// + public string? MentorIamId { get; set; } + public CareerDropdownOption? PostGrad { get; set; } + [MaxLength(5000, ErrorMessage = "Short Term Statement must be 5000 characters or fewer.")] + public string? ShortTermPlans { get; set; } + [MaxLength(5000, ErrorMessage = "Long Term Statement must be 5000 characters or fewer.")] + public string? LongTermPlans { get; set; } +} diff --git a/web/Areas/Students/Models/StudentCareerListItemDto.cs b/web/Areas/Students/Models/StudentCareerListItemDto.cs new file mode 100644 index 000000000..7a294088d --- /dev/null +++ b/web/Areas/Students/Models/StudentCareerListItemDto.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Students.Models; + +/// +/// A list item representation of a student's completion status for each career selection field. +/// +public class StudentCareerListItemDto : StudentCareerRowDto +{ + public bool DirectionCompleted { get; set; } + public bool PrimaryFocusCompleted { get; set; } + public bool SecondaryFocusCompleted { get; set; } + public bool PostGradCompleted { get; set; } + public bool ShortTermPlansCompleted { get; set; } + public bool LongTermPlansCompleted { get; set; } + public string? MentorName { get; set; } + public DateTime? LastUpdated { get; set; } +} diff --git a/web/Areas/Students/Models/StudentCareerReportDto.cs b/web/Areas/Students/Models/StudentCareerReportDto.cs new file mode 100644 index 000000000..f0bce7207 --- /dev/null +++ b/web/Areas/Students/Models/StudentCareerReportDto.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Students.Models; + +/// +/// A report representation of a student's career selection, with all fields as strings for display purposes. +/// +public class StudentCareerReportDto : StudentCareerRowDto +{ + public string Direction { get; set; } = string.Empty; + public string PrimaryFocus { get; set; } = string.Empty; + public string SecondaryFocus { get; set; } = string.Empty; + public string PostGrad { get; set; } = string.Empty; + public string ShortTermPlans { get; set; } = string.Empty; + public string LongTermPlans { get; set; } = string.Empty; + public string MentorName { get; set; } = string.Empty; + public DateTime? LastUpdated { get; set; } +} diff --git a/web/Areas/Students/Models/StudentCareerRowDto.cs b/web/Areas/Students/Models/StudentCareerRowDto.cs new file mode 100644 index 000000000..ddcd32c8c --- /dev/null +++ b/web/Areas/Students/Models/StudentCareerRowDto.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Students.Models; + +/// +/// The student identity columns shared by every career selection roster row, the overview and +/// the report alike. +/// +public abstract class StudentCareerRowDto +{ + public int PersonId { get; set; } + public string RowKey { get; set; } = string.Empty; + public bool HasDetailRoute { get; set; } + public string FullName { get; set; } = string.Empty; + public string ClassLevel { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} diff --git a/web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs b/web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs new file mode 100644 index 000000000..ba411854b --- /dev/null +++ b/web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs @@ -0,0 +1,837 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Viper.Areas.Students.Scripts +{ + public sealed record DuplicatePidm(string Pidm, List CareerSelectionIds); + public sealed record BadPidm(int CareerSelectionId, string RawPidm, string Reason); + public sealed record LookupProblem(string Table, int Id, string Problem, string Label); + public sealed record DuplicateLabel(string Table, string Label, List Ids); + public sealed record OtherCandidate(string Table, int Id, string Label); + public sealed record OverLengthValue(string Column, int CareerSelectionId, int Length, int MaxLength, string Preview); + /// + /// A facultyMothraID the new app would not accept, plus whether the student it belongs to is + /// one an admin can actually open. A problem on a student who has left is inert history; one + /// on a current student is a record an admin will be blocked from saving. + /// + public sealed record MentorProblem( + int CareerSelectionId, string MothraId, string Problem, string ResolvedName, + string StudentPidm, bool StudentIsActive); + public sealed record ValueCount(string Value, int Count); + + /// What the migration will do to one lookup table's catch-all row. + public sealed record OtherPlan(string Table, string Action, int Id, string FromLabel); + + /// + /// How one legacy option column encoded its answers. Implicit is legacy's way of recording + /// "Other" - a null option id beside free text - and becomes a real reference to the catch-all + /// row. Stale is the reverse and is discarded, matching what the app itself writes. + /// The Placeholder counts are legacy's prompt text, read as blank before the rest are counted, + /// split by what it sat beside: no option, the catch-all, or a real option. + /// + public sealed record OtherEncodingCounts( + string Column, int Implicit, int Stale, int AlreadyOther, + int PlaceholderNoOption, int PlaceholderOnOther, int PlaceholderOnRealOption); + + /// + /// Flat, unscored data-quality report for the SIS -> students schema career selection + /// migration. Every list here is meant for line-by-line human review, not automated + /// resolution. + /// + public class CareerAnalysisReport + { + public int CareerSelectionRowCount { get; set; } + public int CareerCount { get; set; } + public int SpeciesCount { get; set; } + public int PostGradCount { get; set; } + + public List DuplicatePidms { get; } = []; + public List BadPidms { get; } = []; + public List LeadingZeroPidms { get; } = []; + + public List LookupProblems { get; } = []; + public List DuplicateLabels { get; } = []; + public List OtherCandidates { get; } = []; + public List OtherPlans { get; } = []; + public List AmbiguousOtherTables { get; } = []; + public List OtherLabelCollisions { get; } = []; + public List OtherEncodings { get; } = []; + + public int PostGradOtherUsedCount { get; set; } + public List CombinedShortTermOverLength { get; } = []; + public List OverLengthValues { get; } = []; + + public List ClassYearValues { get; } = []; + + public List MentorProblems { get; } = []; + + public List OrphanedOptionReferences { get; } = []; + public bool DestinationTablesExist { get; set; } + } + + /// + /// Read-only data-quality analysis for the SIS career selection -> students schema migration. + /// Connects to the legacy "SIS" database (read-only), "VIPER" (for users.Person and the + /// destination column widths) and "AAUD" (for the current-affiliate check), and reports every + /// conflict/risk identified while planning the migration. Writes no data anywhere - this is + /// the dry-run pass that precedes the real transform/apply script. + /// + public class CareerSelectionDataAnalysis + { + /// + /// Legacy text columns paired with the destination column that has to hold them. Widths + /// match today, so this exists to catch a destination that was created narrower than the + /// DDL we reviewed - not because any legacy value is known to be too long. + /// + private static readonly (string LegacyColumn, string DestinationColumn)[] TextColumns = + [ + ("careerOther", "CareerOther"), + ("firstSpeciesOther", "FirstSpeciesOther"), + ("secondSpeciesOther", "SecondSpeciesOther"), + ("shortTermStatement", "ShortTermStatement"), + ("longTermStatement", "LongTermStatement"), + ("facultyMothraID", "FacultyMothraId"), + ]; + + private readonly string _legacyConnectionString; + private readonly string _viperConnectionString; + private readonly string _aaudConnectionString; + private readonly string _outputPath; + private readonly DateTime _analysisDate; + private readonly CareerAnalysisReport _report = new(); + + public CareerSelectionDataAnalysis(IConfiguration? configuration = null, string? outputPath = null) + { + var config = configuration ?? CareerSelectionScriptHelper.LoadConfiguration(); + _viperConnectionString = CareerSelectionScriptHelper.GetConnectionString(config, "VIPER"); + _legacyConnectionString = CareerSelectionScriptHelper.GetConnectionString(config, "SIS"); + _aaudConnectionString = CareerSelectionScriptHelper.GetConnectionString(config, "AAUD"); + _outputPath = CareerSelectionScriptHelper.ValidateOutputPath(outputPath, "AnalysisOutput"); + _analysisDate = DateTime.Now; + + Directory.CreateDirectory(_outputPath); + } + + public static void Run(string[] args) + { + Console.WriteLine("==========================================="); + Console.WriteLine("CAREER SELECTION MIGRATION ANALYSIS"); + Console.WriteLine("==========================================="); + Console.WriteLine($"Analysis Started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($"Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"}"); + Console.WriteLine(); + + try + { + var analyzer = new CareerSelectionDataAnalysis(); + + Console.WriteLine("Connection Configuration:"); + Console.WriteLine($" SIS Database: {CareerSelectionScriptHelper.GetServerAndDatabase(analyzer._legacyConnectionString)}"); + Console.WriteLine($" VIPER Database: {CareerSelectionScriptHelper.GetServerAndDatabase(analyzer._viperConnectionString)}"); + Console.WriteLine($" AAUD Database: {CareerSelectionScriptHelper.GetServerAndDatabase(analyzer._aaudConnectionString)}"); + Console.WriteLine(); + + analyzer.RunFullAnalysis(); + } + catch (InvalidOperationException ex) + { + WriteFatalError(ex); + } + catch (SqlException ex) + { + WriteFatalError(ex); + } + } + + private static void WriteFatalError(Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\nERROR: {ex.Message}"); + Console.WriteLine("\nStack Trace:"); + Console.WriteLine(ex.StackTrace); + Console.ResetColor(); + Environment.Exit(1); + } + + public void RunFullAnalysis() + { + using var legacyConn = new SqlConnection(_legacyConnectionString); + using var viperConn = new SqlConnection(_viperConnectionString); + using var aaudConn = new SqlConnection(_aaudConnectionString); + legacyConn.Open(); + viperConn.Open(); + aaudConn.Open(); + + Console.WriteLine("Reading destination column widths from students.CareerSelection..."); + LoadDestinationWidths(viperConn); + Console.WriteLine(); + + CountLegacyRows(legacyConn); + Console.WriteLine("Legacy row counts:"); + Console.WriteLine($" tb_CareerSelection: {_report.CareerSelectionRowCount:N0}"); + Console.WriteLine($" LK_careers: {_report.CareerCount:N0}"); + Console.WriteLine($" LK_species: {_report.SpeciesCount:N0}"); + Console.WriteLine($" LK_PostGrad: {_report.PostGradCount:N0}"); + Console.WriteLine(); + + Console.WriteLine("Checking PIDM values against the destination int column..."); + AnalyzePidms(legacyConn); + WriteColoredCount(" Duplicate PIDMs", _report.DuplicatePidms.Count, isCritical: true); + WriteColoredCount(" Non-numeric or blank PIDMs", _report.BadPidms.Count, isCritical: true); + WriteColoredCount(" PIDMs with a leading zero", _report.LeadingZeroPidms.Count, isCritical: false); + Console.WriteLine(); + + Console.WriteLine("Checking lookup table labels against the destination NOT NULL/UNIQUE columns..."); + AnalyzeLookupTables(legacyConn); + WriteColoredCount(" Null or blank labels", _report.LookupProblems.Count, isCritical: true); + WriteColoredCount(" Duplicate labels", _report.DuplicateLabels.Count, isCritical: true); + WriteColoredCount(" Tables with an ambiguous Other row", _report.AmbiguousOtherTables.Count, isCritical: true); + WriteColoredCount(" Other label collisions", _report.OtherLabelCollisions.Count, isCritical: true); + Console.WriteLine(" Catch-all row plan:"); + foreach (var plan in _report.OtherPlans) + { + Console.WriteLine($" {plan.Table}: {plan.Action} (Id={plan.Id}, was '{plan.FromLabel}')"); + } + Console.WriteLine(); + + Console.WriteLine("Checking how each option column encoded Other..."); + AnalyzeOtherEncodings(legacyConn); + foreach (var e in _report.OtherEncodings) + { + Console.WriteLine($" {e.Column}: {e.Implicit:N0} implicit Other, " + + $"{e.AlreadyOther:N0} already Other, {e.Stale:N0} stale text to discard"); + Console.WriteLine($" placeholder text to discard: {e.PlaceholderNoOption:N0} with no option, " + + $"{e.PlaceholderOnOther:N0} on Other, {e.PlaceholderOnRealOption:N0} beside a real option"); + } + Console.WriteLine(); + + Console.WriteLine("Checking postGradOther folding into ShortTermStatement..."); + AnalyzePostGradOther(legacyConn); + Console.WriteLine($" Rows with a postGradOther value: {_report.PostGradOtherUsedCount:N0}"); + WriteColoredCount(" Combined values over the ShortTermStatement limit", + _report.CombinedShortTermOverLength.Count, isCritical: true); + Console.WriteLine(); + + Console.WriteLine("Checking every legacy text value against its destination column width..."); + AnalyzeTextWidths(legacyConn); + WriteColoredCount(" Values too long for their destination column", _report.OverLengthValues.Count, isCritical: true); + Console.WriteLine(); + + Console.WriteLine("Checking that classYear is empty, as the migration assumes..."); + AnalyzeClassYear(legacyConn); + var populatedClassYears = _report.ClassYearValues.Where(v => v.Value != "" && v.Value != "").ToList(); + WriteColoredCount(" Rows with a populated classYear", populatedClassYears.Sum(v => v.Count), isCritical: true); + Console.WriteLine(); + + Console.WriteLine("Resolving facultyMothraID against users.Person and vw_CurrentAffiliates..."); + AnalyzeMentors(legacyConn, viperConn, aaudConn); + // Only the ones on a current student are actionable: those are records an admin can + // open, and the mentor picker will block the first save of each. + var activeMentorProblems = _report.MentorProblems.Count(m => m.StudentIsActive); + WriteColoredCount(" On a current DVM student", activeMentorProblems, isCritical: true); + WriteColoredCount(" On a student who has left (inert history)", + _report.MentorProblems.Count - activeMentorProblems, isCritical: false); + Console.WriteLine(); + + Console.WriteLine("Checking option references resolve within the legacy lookup tables..."); + AnalyzeOrphanedOptionReferences(legacyConn); + WriteColoredCount(" Orphaned option references", _report.OrphanedOptionReferences.Count, isCritical: true); + Console.WriteLine(); + + var reportPath = WriteTextReport(); + Console.WriteLine("==========================================="); + Console.WriteLine($"Full report written to: {reportPath}"); + Console.WriteLine("==========================================="); + } + + private void CountLegacyRows(SqlConnection legacyConn) + { + _report.CareerSelectionRowCount = CountRows(legacyConn, "tb_CareerSelection"); + _report.CareerCount = CountRows(legacyConn, "LK_careers"); + _report.SpeciesCount = CountRows(legacyConn, "LK_species"); + _report.PostGradCount = CountRows(legacyConn, "LK_PostGrad"); + } + + private static int CountRows(SqlConnection conn, string table) + { + using var cmd = new SqlCommand($"SELECT COUNT(*) FROM [dbo].[{table}]", conn); + return (int)cmd.ExecuteScalar(); + } + + // Check 1: the destination has UQ_CareerSelection_Pidm, and CareerSelectionService builds + // its lookup with ToDictionary(c => c.Pidm), which throws on a duplicate key. Legacy + // constrains only the identity PK, so nothing there stopped a second row per student. + // Two PIDMs that differ only by a leading zero collide too, once both are parsed as int. + private void AnalyzePidms(SqlConnection legacyConn) + { + var byParsedPidm = new Dictionary>(); + + const string sql = "SELECT CareerSelection_ID, PIDM FROM [dbo].[tb_CareerSelection]"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetInt32(0); + var raw = reader.IsDBNull(1) ? "" : reader.GetString(1); + + if (!CareerSelectionScriptHelper.TryParsePidm(raw, out var pidm)) + { + var reason = string.IsNullOrWhiteSpace(raw) ? "blank" : "non-numeric"; + _report.BadPidms.Add(new BadPidm(id, raw, reason)); + continue; + } + + if (CareerSelectionScriptHelper.HasLeadingZero(raw)) + { + _report.LeadingZeroPidms.Add(new BadPidm(id, raw, $"parses as {pidm}")); + } + + if (!byParsedPidm.TryGetValue(pidm, out var rows)) + { + rows = []; + byParsedPidm[pidm] = rows; + } + rows.Add((id, raw.Trim())); + } + + foreach (var (pidm, rows) in byParsedPidm.Where(kv => kv.Value.Count > 1)) + { + // Report the raw spellings when they differ, so a leading-zero collision reads as + // one rather than looking like a plain duplicate. + var distinctRaw = rows.Select(r => r.Raw).Distinct().ToList(); + var label = distinctRaw.Count == 1 ? pidm.ToString() : $"{pidm} (stored as {string.Join(", ", distinctRaw)})"; + _report.DuplicatePidms.Add(new DuplicatePidm(label, rows.Select(r => r.Id).ToList())); + } + } + + // Check 2: the destination label columns are NOT NULL and uniquely constrained, while + // LK_careers.career and LK_species.species are nullable and none of the three enforces + // uniqueness. Also locates the catch-all row each table needs, since IsOther has no + // legacy source and only a literal "Other" is meant to carry it. + private void AnalyzeLookupTables(SqlConnection legacyConn) + { + AnalyzeLookupTable(legacyConn, "LK_careers", "career_ID", "career"); + AnalyzeLookupTable(legacyConn, "LK_species", "species_id", "species"); + AnalyzeLookupTable(legacyConn, "LK_PostGrad", "postgrad_id", "postgrad_text"); + } + + private void AnalyzeLookupTable(SqlConnection legacyConn, string table, string idColumn, string labelColumn) + { + var rows = CareerSelectionScriptHelper.ReadLookupTable(legacyConn, table, idColumn, labelColumn); + var byLabel = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + if (row.Label.Length == 0) + { + // ReadLookupTable folds a null label into an empty one; both violate the + // destination's NOT NULL and both need the same human decision. + _report.LookupProblems.Add(new LookupProblem(table, row.Id, "null or blank label", "")); + continue; + } + + if (CareerSelectionScriptHelper.IsOtherLabel(row.Label)) + { + _report.OtherCandidates.Add(new OtherCandidate(table, row.Id, row.Label)); + } + + if (!byLabel.TryGetValue(row.Label, out var ids)) + { + ids = []; + byLabel[row.Label] = ids; + } + ids.Add(row.Id); + } + + foreach (var (label, ids) in byLabel.Where(kv => kv.Value.Count > 1)) + { + _report.DuplicateLabels.Add(new DuplicateLabel(table, label, ids)); + } + + // Decided by the shared planner, so this report describes exactly what the transform + // will do rather than a second implementation of the same rule. + var plan = CareerSelectionScriptHelper.PlanOtherRow(rows); + + if (plan.Error is not null) + { + var destination = plan.Error.StartsWith("renaming", StringComparison.Ordinal) + ? _report.OtherLabelCollisions + : _report.AmbiguousOtherTables; + destination.Add($"{table}: {plan.Error}"); + return; + } + + _report.OtherPlans.Add(new OtherPlan(table, plan.Describe(), plan.OtherId, plan.RenamedFrom ?? "")); + } + + // Check 2b: how each legacy option column actually encoded its answers. Legacy had no + // "Other" row for careers or species, so a student choosing it left a null id beside text + // in the matching Other column - those rows are re-pointed at the catch-all the migration + // creates. The reverse (a real option selected AND stale Other text) is discarded, which is + // what CareerSelectionMapper.OtherTextFor does on every save the app makes. + private void AnalyzeOtherEncodings(SqlConnection legacyConn) + { + AnalyzeOtherEncoding(legacyConn, "career", "careerOther", "LK_careers"); + AnalyzeOtherEncoding(legacyConn, "firstSpecies", "firstSpeciesOther", "LK_species"); + AnalyzeOtherEncoding(legacyConn, "secondSpecies", "secondSpeciesOther", "LK_species"); + AnalyzeOtherEncoding(legacyConn, "postGrad", "postGradOther", "LK_PostGrad"); + } + + private void AnalyzeOtherEncoding( + SqlConnection legacyConn, string optionColumn, string otherColumn, string lookupTable) + { + var otherId = _report.OtherPlans.FirstOrDefault(p => p.Table == lookupTable)?.Id; + + var implicitCount = 0; + var staleCount = 0; + var alreadyOtherCount = 0; + var placeholderNoOption = 0; + var placeholderOnOther = 0; + var placeholderOnRealOption = 0; + + var sql = $"SELECT {optionColumn}, {otherColumn} FROM [dbo].[tb_CareerSelection]"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var optionId = reader.IsDBNull(0) ? (int?)null : reader.GetInt32(0); + var rawText = reader.IsDBNull(1) ? null : reader.GetString(1); + + if (CareerSelectionScriptHelper.IsLegacyOtherPlaceholder(rawText)) + { + if (optionId is null) + { + placeholderNoOption++; + } + else if (optionId == otherId) + { + placeholderOnOther++; + } + else + { + placeholderOnRealOption++; + } + } + + // Classified on the text the migration keeps, so these counts match what it writes. + var otherText = CareerSelectionScriptHelper.CleanOtherText(rawText); + var hasText = otherText.Length > 0; + + if (CareerSelectionScriptHelper.IsImplicitOther(optionId, otherText)) + { + implicitCount++; + } + else if (optionId is not null && optionId == otherId) + { + alreadyOtherCount++; + } + else if (optionId is not null && hasText) + { + staleCount++; + } + } + + _report.OtherEncodings.Add(new OtherEncodingCounts( + optionColumn, implicitCount, staleCount, alreadyOtherCount, + placeholderNoOption, placeholderOnOther, placeholderOnRealOption)); + } + + // Check 3: postGradOther has no destination column, so the migration folds it into + // ShortTermStatement. Measures the combined value the migration would actually write + // against the physical ShortTermStatement width, which is the whole point of this check + // on PROD - TEST is already known to be clear. + private void AnalyzePostGradOther(SqlConnection legacyConn) + { + var maxLength = GetDestinationWidth("ShortTermStatement", fallback: 5000); + var otherId = _report.OtherPlans.FirstOrDefault(p => p.Table == "LK_PostGrad")?.Id; + + const string sql = @" + SELECT CareerSelection_ID, shortTermStatement, postGradOther, postGrad + FROM [dbo].[tb_CareerSelection] + WHERE postGradOther IS NOT NULL AND RTRIM(postGradOther) <> ''"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetInt32(0); + var shortTerm = reader.IsDBNull(1) ? null : reader.GetString(1); + var postGradOther = reader.IsDBNull(2) ? null : reader.GetString(2); + var postGrad = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3); + + // Stale text beside a real, non-catch-all choice is discarded rather than merged - + // the same rule the other three columns follow. Merging it would attribute an + // explanation to a choice the student did not make. + if (postGrad is not null && postGrad != otherId) + { + continue; + } + + // The placeholder is read as blank, so there is nothing to merge. + if (CareerSelectionScriptHelper.IsLegacyOtherPlaceholder(postGradOther)) + { + continue; + } + + _report.PostGradOtherUsedCount++; + + var combined = CareerSelectionScriptHelper.CombineShortTermStatement(shortTerm, postGradOther); + if (combined.Length > maxLength) + { + _report.CombinedShortTermOverLength.Add(new OverLengthValue( + "ShortTermStatement", id, combined.Length, maxLength, Preview(combined))); + } + } + } + + // Check 4: every legacy text value against the physical width of the column that has to + // hold it, read from the destination rather than taken from the EF model. + private void AnalyzeTextWidths(SqlConnection legacyConn) + { + var columns = string.Join(", ", TextColumns.Select(c => c.LegacyColumn)); + var sql = $"SELECT CareerSelection_ID, {columns} FROM [dbo].[tb_CareerSelection]"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetInt32(0); + + for (var i = 0; i < TextColumns.Length; i++) + { + var (legacyColumn, destinationColumn) = TextColumns[i]; + if (reader.IsDBNull(i + 1)) + { + continue; + } + + var value = reader.GetString(i + 1).Trim(); + var maxLength = GetDestinationWidth(destinationColumn, FallbackWidth(legacyColumn)); + + if (value.Length > maxLength) + { + _report.OverLengthValues.Add(new OverLengthValue( + destinationColumn, id, value.Length, maxLength, Preview(value))); + } + } + } + } + + /// The reviewed DDL widths, used only when the destination table does not exist yet. + private static int FallbackWidth(string legacyColumn) => legacyColumn switch + { + "shortTermStatement" or "longTermStatement" => 5000, + "facultyMothraID" => 8, + _ => 200, + }; + + private readonly Dictionary _destinationWidths = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Reads the destination column widths once, up front. The tables are created by hand-run + /// DDL rather than an EF migration, so the analysis has to work before they exist - in + /// which case the checks fall back to the widths in the DDL we reviewed. + /// + private void LoadDestinationWidths(SqlConnection viperConn) + { + _report.DestinationTablesExist = + CareerSelectionScriptHelper.TableExists(viperConn, "students", "CareerSelection"); + + if (!_report.DestinationTablesExist) + { + Console.WriteLine(" students.CareerSelection does not exist yet - falling back to the reviewed DDL widths."); + return; + } + + foreach (var (name, length) in CareerSelectionScriptHelper.GetColumnMaxLengths( + viperConn, "students", "CareerSelection")) + { + _destinationWidths[name] = length; + } + } + + private int GetDestinationWidth(string column, int fallback) + { + return _destinationWidths.TryGetValue(column, out var width) && width > 0 ? width : fallback; + } + + // Check 5: classYear has no destination column because it is empty in legacy. Assert that + // rather than assume it - a populated value here means the decision to drop it was made + // against different data than this environment holds. + private void AnalyzeClassYear(SqlConnection legacyConn) + { + const string sql = "SELECT classYear, COUNT(*) FROM [dbo].[tb_CareerSelection] GROUP BY classYear"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var value = reader.IsDBNull(0) ? "" : reader.GetString(0).Trim(); + var count = reader.GetInt32(1); + _report.ClassYearValues.Add(new ValueCount(value.Length == 0 ? "" : value, count)); + } + } + + // Check 6: a blank or all-zero facultyMothraID migrates as NULL (no mentor), and any other + // value migrates unchanged, so an unresolvable value is not a migration failure. It matters + // later: the admin mentor picker validates a submitted mentor against vw_CurrentAffiliates, + // so a mentor who is no longer an affiliate blocks the first save of that student's record. + // The two cases are reported separately. + private void AnalyzeMentors(SqlConnection legacyConn, SqlConnection viperConn, SqlConnection aaudConn) + { + var mentorLookup = CareerSelectionScriptHelper.BuildMentorLookupMap(viperConn, aaudConn); + Console.WriteLine($" Loaded {mentorLookup.Count:N0} person/affiliate records."); + + var activePidms = CareerSelectionScriptHelper.LoadActiveStudentPidms(aaudConn); + Console.WriteLine($" Loaded {activePidms.Count:N0} current DVM students."); + + const string sql = @" + SELECT CareerSelection_ID, facultyMothraID, PIDM + FROM [dbo].[tb_CareerSelection] + WHERE facultyMothraID IS NOT NULL AND RTRIM(facultyMothraID) <> ''"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetInt32(0); + var mothraId = reader.GetString(1).Trim(); + var rawPidm = reader.IsDBNull(2) ? "" : reader.GetString(2).Trim(); + + // An all-zero placeholder names nobody, the same way a blank does. + if (!CareerSelectionScriptHelper.HasMothraId(mothraId)) + { + continue; + } + + var isActive = CareerSelectionScriptHelper.TryParsePidm(rawPidm, out var pidm) + && activePidms.Contains(pidm); + + if (!mentorLookup.TryGetValue(mothraId, out var mentor)) + { + _report.MentorProblems.Add(new MentorProblem( + id, mothraId, "no users.Person row", "", rawPidm, isActive)); + continue; + } + + if (!mentor.IsCurrentAffiliate) + { + _report.MentorProblems.Add(new MentorProblem( + id, mothraId, "resolves but is not a current SVM affiliate", mentor.FullName, + rawPidm, isActive)); + } + } + } + + // Check 7: the legacy FKs are trusted (WITH CHECK), but they are only as good as the last + // time they were enabled, and preserving legacy ids means the destination FKs will be + // applied to exactly these values. + private void AnalyzeOrphanedOptionReferences(SqlConnection legacyConn) + { + CheckOrphans(legacyConn, "career", "LK_careers", "career_ID"); + CheckOrphans(legacyConn, "firstSpecies", "LK_species", "species_id"); + CheckOrphans(legacyConn, "secondSpecies", "LK_species", "species_id"); + CheckOrphans(legacyConn, "postGrad", "LK_PostGrad", "postgrad_id"); + } + + private void CheckOrphans(SqlConnection legacyConn, string column, string lookupTable, string lookupId) + { + var sql = $@" + SELECT cs.CareerSelection_ID, cs.{column} + FROM [dbo].[tb_CareerSelection] cs + WHERE cs.{column} IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM [dbo].[{lookupTable}] lk WHERE lk.{lookupId} = cs.{column})"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + _report.OrphanedOptionReferences.Add( + $"CareerSelection_ID={reader.GetInt32(0)} {column}={reader.GetInt32(1)} not in {lookupTable}"); + } + } + + private static string Preview(string value) + { + const int previewLength = 80; + var collapsed = value.Replace("\r", " ").Replace("\n", " "); + return collapsed.Length <= previewLength ? collapsed : collapsed[..previewLength] + "..."; + } + + private static void WriteColoredCount(string label, int count, bool isCritical) + { + Console.ForegroundColor = count == 0 ? ConsoleColor.Green : (isCritical ? ConsoleColor.Red : ConsoleColor.Yellow); + Console.WriteLine($"{label}: {count:N0}"); + Console.ResetColor(); + } + + private string WriteTextReport() + { + var sb = new StringBuilder(); + sb.AppendLine("Career Selection Migration - Data Quality Analysis"); + sb.AppendLine($"Generated: {_analysisDate:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"}"); + sb.AppendLine(); + + sb.AppendLine("== Legacy row counts =="); + sb.AppendLine($" tb_CareerSelection: {_report.CareerSelectionRowCount:N0}"); + sb.AppendLine($" LK_careers: {_report.CareerCount:N0}"); + sb.AppendLine($" LK_species: {_report.SpeciesCount:N0}"); + sb.AppendLine($" LK_PostGrad: {_report.PostGradCount:N0}"); + sb.AppendLine($" Destination students.CareerSelection exists: {_report.DestinationTablesExist}"); + sb.AppendLine(); + + sb.AppendLine("== Duplicate PIDMs (blocks UQ_CareerSelection_Pidm and the app lookup) =="); + foreach (var d in _report.DuplicatePidms) + { + sb.AppendLine($" PIDM={d.Pidm} CareerSelection_IDs=[{string.Join(", ", d.CareerSelectionIds)}]"); + } + sb.AppendLine(); + + sb.AppendLine("== Non-numeric or blank PIDMs =="); + foreach (var b in _report.BadPidms) + { + sb.AppendLine($" CareerSelection_ID={b.CareerSelectionId} PIDM='{b.RawPidm}' ({b.Reason})"); + } + sb.AppendLine(); + + sb.AppendLine("== PIDMs with a leading zero (spelling is lost in the int column) =="); + foreach (var z in _report.LeadingZeroPidms) + { + sb.AppendLine($" CareerSelection_ID={z.CareerSelectionId} PIDM='{z.RawPidm}' {z.Reason}"); + } + sb.AppendLine(); + + sb.AppendLine("== Null or blank lookup labels (destination columns are NOT NULL) =="); + foreach (var p in _report.LookupProblems) + { + sb.AppendLine($" [{p.Table}] Id={p.Id} {p.Problem}"); + } + sb.AppendLine(); + + sb.AppendLine("== Duplicate lookup labels (destination columns are UNIQUE) =="); + sb.AppendLine(" NOTE: any row here reopens the decision to preserve legacy ids - merging"); + sb.AppendLine(" two option rows means remapping the tb_CareerSelection references."); + foreach (var d in _report.DuplicateLabels) + { + sb.AppendLine($" [{d.Table}] '{d.Label}' Ids=[{string.Join(", ", d.Ids)}]"); + } + sb.AppendLine(); + + sb.AppendLine("== Catch-all (Other) rows =="); + sb.AppendLine(" Legacy rows whose label starts with \"Other\":"); + foreach (var o in _report.OtherCandidates) + { + sb.AppendLine($" [{o.Table}] Id={o.Id} '{o.Label}'"); + } + if (_report.OtherCandidates.Count == 0) + { + sb.AppendLine(" (none)"); + } + sb.AppendLine(" What the migration will do, and the id each row ends up with:"); + foreach (var p in _report.OtherPlans) + { + sb.AppendLine($" [{p.Table}] {p.Action} - Id={p.Id}, was '{p.FromLabel}'"); + } + foreach (var a in _report.AmbiguousOtherTables) + { + sb.AppendLine($" AMBIGUOUS {a}"); + } + foreach (var c in _report.OtherLabelCollisions) + { + sb.AppendLine($" COLLISION {c}"); + } + sb.AppendLine(); + + sb.AppendLine("== How each option column encoded Other =="); + sb.AppendLine(" implicit = null option + free text; legacy's way of recording Other."); + sb.AppendLine(" These get re-pointed at the catch-all row above."); + sb.AppendLine(" already = already references the catch-all row."); + sb.AppendLine(" stale = a real non-catch-all option AND free text. The text is"); + sb.AppendLine(" discarded, which is what the app writes on every save."); + sb.AppendLine($" placeholder = legacy's \"{CareerSelectionScriptHelper.LegacyOtherPlaceholder}\" prompt,"); + sb.AppendLine(" discarded as blank before the counts above are taken. With no"); + sb.AppendLine(" option the row stays unanswered; on Other it keeps no text."); + foreach (var e in _report.OtherEncodings) + { + sb.AppendLine($" {e.Column}: implicit={e.Implicit:N0} already={e.AlreadyOther:N0} stale={e.Stale:N0}"); + sb.AppendLine($" placeholder: no-option={e.PlaceholderNoOption:N0} on-other={e.PlaceholderOnOther:N0} " + + $"real-option={e.PlaceholderOnRealOption:N0}"); + } + sb.AppendLine(); + + sb.AppendLine("== postGradOther folded into ShortTermStatement =="); + sb.AppendLine(" Counts only the rows that will actually be merged - a stale value beside"); + sb.AppendLine(" a real non-catch-all choice is discarded, not merged."); + sb.AppendLine($" Rows merged: {_report.PostGradOtherUsedCount:N0}"); + foreach (var o in _report.CombinedShortTermOverLength) + { + sb.AppendLine($" CareerSelection_ID={o.CareerSelectionId} combined length {o.Length} > {o.MaxLength}: {o.Preview}"); + } + sb.AppendLine(); + + sb.AppendLine("== Values too long for their destination column =="); + foreach (var o in _report.OverLengthValues) + { + sb.AppendLine($" {o.Column} CareerSelection_ID={o.CareerSelectionId} length {o.Length} > {o.MaxLength}: {o.Preview}"); + } + sb.AppendLine(); + + sb.AppendLine("== classYear distinct values (expected: only null/blank) =="); + foreach (var v in _report.ClassYearValues) + { + sb.AppendLine($" '{v.Value}': {v.Count:N0}"); + } + sb.AppendLine(); + + sb.AppendLine("== facultyMothraID problems =="); + sb.AppendLine(" NOTE: these migrate as-is. A mentor who is not a current affiliate blocks"); + sb.AppendLine(" the first admin save of that student's record, not the migration."); + sb.AppendLine(" Split by whether the student is in vw_DVM_Students_maxTerm, which is the"); + sb.AppendLine(" roster the admin screens are built from - a problem on a student who has"); + sb.AppendLine(" left is history nobody can reach, so only the current ones need fixing."); + sb.AppendLine(); + + var activeProblems = _report.MentorProblems.Where(m => m.StudentIsActive).ToList(); + var departedProblems = _report.MentorProblems.Where(m => !m.StudentIsActive).ToList(); + + sb.AppendLine($" -- On a CURRENT DVM student ({activeProblems.Count:N0}) - actionable --"); + foreach (var m in activeProblems) + { + sb.AppendLine($" CareerSelection_ID={m.CareerSelectionId} PIDM='{m.StudentPidm}' " + + $"MothraId='{m.MothraId}' {m.Problem} '{m.ResolvedName}'"); + } + if (activeProblems.Count == 0) + { + sb.AppendLine(" (none - every mentor problem is on a student who has left)"); + } + sb.AppendLine(); + + sb.AppendLine($" -- On a student who has left ({departedProblems.Count:N0}) - inert --"); + foreach (var m in departedProblems) + { + sb.AppendLine($" CareerSelection_ID={m.CareerSelectionId} PIDM='{m.StudentPidm}' " + + $"MothraId='{m.MothraId}' {m.Problem} '{m.ResolvedName}'"); + } + sb.AppendLine(); + + sb.AppendLine("== Orphaned option references =="); + foreach (var o in _report.OrphanedOptionReferences) + { + sb.AppendLine($" {o}"); + } + + var fileName = $"CareerSelectionAnalysis_{_analysisDate:yyyyMMdd_HHmmss}.txt"; + var path = Path.Join(_outputPath, fileName); + File.WriteAllText(path, sb.ToString()); + return path; + } + } +} diff --git a/web/Areas/Students/Scripts/CareerSelectionMigration.csproj b/web/Areas/Students/Scripts/CareerSelectionMigration.csproj new file mode 100644 index 000000000..d9ac1c88c --- /dev/null +++ b/web/Areas/Students/Scripts/CareerSelectionMigration.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + CareerSelectionMigration + Viper.Areas.Students.Scripts + + + + + + + + + + + + + + + + + + + diff --git a/web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs b/web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs new file mode 100644 index 000000000..14cacfb58 --- /dev/null +++ b/web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs @@ -0,0 +1,562 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Amazon; +using Amazon.Extensions.NETCore.Setup; + +namespace Viper.Areas.Students.Scripts +{ + /// + /// A person resolved from users.Person by legacy MothraId, plus whether AAUD still lists them + /// as a current SVM affiliate. The mentor picker validates against vw_CurrentAffiliates, so a + /// mentor who resolves but is no longer an affiliate migrates fine and then fails the first + /// time an admin tries to save that student's record - which is worth reporting separately + /// from a MothraId that names nobody at all. + /// + public sealed record MentorLookup(string FullName, bool IsCurrentAffiliate); + + /// + /// Shared utilities for the CareerSelection data migration scripts. + /// + public static class CareerSelectionScriptHelper + { + public static string GetApplicationRoot() + { + var currentDir = Directory.GetCurrentDirectory(); + + if (currentDir.Contains("Scripts")) + { + currentDir = Path.GetFullPath(Path.Join(currentDir, "..", "..", "..")); + } + + if (!File.Exists(Path.Join(currentDir, "appsettings.json"))) + { + var parentDir = Path.GetFullPath(Path.Join(currentDir, "..", "..")); + if (File.Exists(Path.Join(parentDir, "appsettings.json"))) + { + currentDir = parentDir; + } + } + + return currentDir; + } + + public static string GetConnectionString(IConfiguration configuration, string name, bool readOnly = true) + { + var connectionString = configuration.GetConnectionString(name); + + if (string.IsNullOrEmpty(connectionString)) + { + // Name the environment: the checked-in appsettings hold empty placeholders, so a + // missing value almost always means AWS Parameter Store had nothing for THIS + // environment - which is easy to miss when another environment resolved fine. + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + throw new InvalidOperationException( + $"{name} database connection string not found in configuration for environment '{environment}'. " + + $"It resolves from AWS Parameter Store (/{environment} or /Shared); the checked-in " + + $"appsettings.{environment}.json holds only an empty placeholder." + ); + } + + // SECURITY: SIS is the live student information system and this migration only ever + // reads from it. Forcing ReadOnly means a mistake in a legacy query cannot write. + if (name.Equals("SIS", StringComparison.OrdinalIgnoreCase) && readOnly) + { + var builder = new SqlConnectionStringBuilder(connectionString); + + if (builder.ApplicationIntent != ApplicationIntent.ReadOnly) + { + builder.ApplicationIntent = ApplicationIntent.ReadOnly; + connectionString = builder.ConnectionString; + Console.WriteLine(" Added ApplicationIntent=ReadOnly to SIS connection for safety"); + } + } + + return connectionString; + } + + public static string GetServerAndDatabase(string connectionString) + { + try + { + var builder = new SqlConnectionStringBuilder(connectionString); + return $"{builder.DataSource}/{builder.InitialCatalog}"; + } + catch (ArgumentException ex) + { + return $"Could not parse connection string: {ex.Message}"; + } + catch (FormatException ex) + { + return $"Could not parse connection string: {ex.Message}"; + } + } + + /// + /// The configuration the script can always read: the appsettings files next to it and the + /// environment. Built fresh on each call because a builder that has had Parameter Store + /// registered on it cannot be reused as the fallback. + /// + private static IConfigurationBuilder LocalConfiguration(string appRoot, string environment) + { + return new ConfigurationBuilder() + .SetBasePath(appRoot) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables(); + } + + private static void WarnParameterStoreUnavailable(string reason) + { + Console.WriteLine($"Warning: {reason}"); + Console.WriteLine("Continuing with appsettings.json configuration only."); + } + + /// + /// Loads configuration from appsettings.json files and AWS Parameter Store. + /// Falls back gracefully to appsettings.json only if AWS is unavailable. + /// + public static IConfiguration LoadConfiguration() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + var appRoot = GetApplicationRoot(); + + Console.WriteLine($"Loading configuration for environment: {environment}"); + Console.WriteLine($"Configuration root: {appRoot}"); + + try + { + AWSOptions awsOptions = new() + { + Region = RegionEndpoint.USWest1 + }; + + // Build() has to be inside the try. AddSystemsManager only appends a source; the + // Parameter Store call happens when Build() creates the providers and loads them. + var configuration = LocalConfiguration(appRoot, environment) + .AddSystemsManager("/" + environment, awsOptions) + .AddSystemsManager("/Shared", awsOptions) + .Build(); + + Console.WriteLine($"Successfully connected to AWS Parameter Store for environment: {environment}"); + return configuration; + } + catch (Amazon.Runtime.AmazonServiceException ex) + { + WarnParameterStoreUnavailable($"Could not connect to AWS Parameter Store: {ex.Message}"); + } + catch (Amazon.Runtime.AmazonClientException ex) + { + WarnParameterStoreUnavailable($"Could not connect to AWS Parameter Store: {ex.Message}"); + } + // Running off the network is the case this fallback exists for, and it does not arrive + // as an Amazon exception: an unreachable host surfaces as a timeout and an unresolvable + // one as a failed HTTP request. + catch (TimeoutException ex) + { + WarnParameterStoreUnavailable($"Timed out reaching AWS Parameter Store: {ex.Message}"); + } + catch (HttpRequestException ex) + { + WarnParameterStoreUnavailable($"Could not reach AWS Parameter Store: {ex.Message}"); + } + catch (ArgumentException ex) + { + WarnParameterStoreUnavailable($"AWS configuration error: {ex.Message}"); + } + + return LocalConfiguration(appRoot, environment).Build(); + } + + public static string ValidateOutputPath(string? outputPath, string defaultSubfolder) + { + if (string.IsNullOrWhiteSpace(defaultSubfolder) + || Path.IsPathRooted(defaultSubfolder) + || defaultSubfolder.Contains("..")) + { + throw new InvalidOperationException( + $"Default subfolder must be a non-empty, relative path without path traversal. Value: '{defaultSubfolder}'"); + } + + if (string.IsNullOrWhiteSpace(outputPath)) + { + return Path.Join(Directory.GetCurrentDirectory(), defaultSubfolder); + } + + var fullPath = Path.GetFullPath(outputPath); + + var currentDir = Directory.GetCurrentDirectory(); + var relative = Path.GetRelativePath(currentDir, fullPath); + if (Path.IsPathRooted(relative) + || relative.Equals("..", StringComparison.OrdinalIgnoreCase) + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Output path must be within the current directory. " + + $"Current directory: {currentDir}, Requested path: {fullPath}"); + } + + return fullPath; + } + + /// + /// Builds a MothraId -> MentorLookup map for resolving tb_CareerSelection.facultyMothraID. + /// Unlike the PhoneLists migration this does not resolve onto IamId - students.CareerSelection + /// stores the MothraId itself - so the lookup exists only to report who no longer resolves. + /// The affiliate flag comes from AAUD's vw_CurrentAffiliates, which is what + /// CareerSelectionService.IsCurrentAffiliateAsync validates a submitted mentor against. + /// + public static Dictionary BuildMentorLookupMap( + SqlConnection viperConnection, SqlConnection aaudConnection) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + const string personSql = @" + SELECT MothraId, FullName + FROM [users].[Person] + WHERE MothraId IS NOT NULL"; + + using (var cmd = new SqlCommand(personSql, viperConnection)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var mothraId = reader.GetString(0).Trim(); + var fullName = reader.IsDBNull(1) ? "" : reader.GetString(1); + map[mothraId] = new MentorLookup(fullName, false); + } + } + + const string affiliateSql = @" + SELECT DISTINCT ids_mothraid + FROM [dbo].[vw_CurrentAffiliates] + WHERE ids_mothraid IS NOT NULL"; + + using (var cmd = new SqlCommand(affiliateSql, aaudConnection)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var mothraId = reader.GetString(0).Trim(); + + // An affiliate with no users.Person row still counts as a current affiliate; + // record it so the two failure modes stay distinguishable in the report. + map[mothraId] = map.TryGetValue(mothraId, out var existing) + ? existing with { IsCurrentAffiliate = true } + : new MentorLookup("", true); + } + } + + return map; + } + + /// + /// The PIDMs of every current DVM student, as the set the front end actually works from: + /// DvmStudentLookupService reads vw_DVM_Students_maxTerm unfiltered, so being in the view + /// is what makes a student current. Keyed on the parsed int for the same reason the app + /// parses it - the view stores PIDM as a string and the new table keys on an int. + /// + public static HashSet LoadActiveStudentPidms(SqlConnection aaudConnection) + { + var pidms = new HashSet(); + + const string sql = @" + SELECT ids_pidm + FROM [dbo].[vw_DVM_Students_maxTerm] + WHERE ids_pidm IS NOT NULL"; + + using var cmd = new SqlCommand(sql, aaudConnection); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + if (TryParsePidm(reader.GetString(0), out var pidm)) + { + pidms.Add(pidm); + } + } + + return pidms; + } + + /// + /// Reports whether a legacy MothraId names a person at all. Both a blank and an all-zero + /// placeholder mean "nobody listed" - neither resolves, and neither is a data problem. + /// + public static bool HasMothraId([NotNullWhen(true)] string? mothraId) + { + return !string.IsNullOrWhiteSpace(mothraId) && mothraId.Trim().TrimStart('0').Length > 0; + } + + /// + /// Parses a legacy VARCHAR(8) PIDM into the int the new schema stores. Rejects anything + /// non-numeric rather than coercing it, so a bad value is reported instead of migrated as + /// a plausible-looking wrong number. + /// + public static bool TryParsePidm(string? raw, out int pidm) + { + pidm = 0; + var trimmed = raw?.Trim(); + + if (string.IsNullOrEmpty(trimmed)) + { + return false; + } + + foreach (var c in trimmed) + { + if (!char.IsAsciiDigit(c)) + { + return false; + } + } + + return int.TryParse(trimmed, out pidm); + } + + /// + /// A PIDM whose stored spelling carries a leading zero the int column cannot keep. + /// "00012345" and "12345" both become 12345, so two such rows would also collide on the + /// destination's unique index - worth reporting even though each one parses cleanly. + /// + public static bool HasLeadingZero(string? raw) + { + var trimmed = raw?.Trim(); + return trimmed is { Length: > 1 } && trimmed[0] == '0'; + } + + /// + /// Identifies the catch-all row in a legacy lookup table by an "Other" prefix, which in + /// practice means LK_PostGrad's "Other: Please explain in the short-term career section below". + /// LK_careers and LK_species have no such row at all - legacy marked those choices with a + /// null id plus text in the matching Other column - so the migration creates one. + /// + /// A prefix rather than an exact match, because the one real row carries trailing + /// instructions. Confirmed against the data that this catches no genuine option; the + /// analysis still prints every candidate, and the migration refuses to guess if a table + /// turns up more than one. + /// + public static bool IsOtherLabel(string? label) + { + return label?.Trim().StartsWith("Other", StringComparison.OrdinalIgnoreCase) == true; + } + + /// + /// The label every catch-all row ends up with. LK_PostGrad's row is renamed to this, and + /// the rows the migration creates for CareerOption and SpeciesOption are created with it - + /// the trailing instructions belong in the UI, not in the stored option. + /// + public const string OtherLabel = "Other"; + + /// + /// Whether a legacy row encoded "Other" the way legacy actually recorded it: no option + /// selected, but free text in the matching Other column. Those rows are re-pointed at the + /// catch-all option. A null id with no text is genuinely unanswered and stays null. + /// + public static bool IsImplicitOther(int? optionId, string? otherText) + { + return optionId is null && !string.IsNullOrWhiteSpace(otherText); + } + + /// + /// The prompt legacy's form wrote into an empty Other box on blur, which was then saved as + /// though the student had typed it. Confirmed against the data as the only form it takes, + /// in all four Other columns. + /// + public const string LegacyOtherPlaceholder = "If other, please describe here"; + + public static bool IsLegacyOtherPlaceholder(string? otherText) + { + return otherText?.Trim().Equals(LegacyOtherPlaceholder, StringComparison.OrdinalIgnoreCase) == true; + } + + /// + /// Legacy Other text as the migration reads it: trimmed, with the placeholder treated as + /// blank, so it is never mistaken for an answer or appended to a statement. + /// + public static string CleanOtherText(string? otherText) + { + return IsLegacyOtherPlaceholder(otherText) ? string.Empty : (otherText ?? string.Empty).Trim(); + } + + /// One row of a legacy lookup table. + public sealed record LookupRow(int Id, string Label); + + /// + /// What to do about one lookup table's catch-all row. being set means + /// the data is not what the migration was designed against and nothing should be written; + /// otherwise is the id the catch-all ends up with, whether it was + /// renamed in place or has to be created. + /// + public sealed record LookupOtherPlan( + int OtherId, bool IsNewRow, string? RenamedFrom, string? Error) + { + public string Describe() => Error is not null + ? $"ERROR: {Error}" + : IsNewRow + ? $"create '{OtherLabel}' at id {OtherId}" + : RenamedFrom is not null + ? $"rename id {OtherId} from '{RenamedFrom}' to '{OtherLabel}'" + : $"keep id {OtherId}, already '{OtherLabel}'"; + } + + /// + /// Decides a lookup table's catch-all row once, so the analysis pass and the transform + /// cannot disagree about which row it is or what id it lands on. + /// + /// LK_PostGrad has one already and it is renamed in place; LK_careers and LK_species have + /// none - legacy recorded that choice as a null id beside free text - so one is created at + /// MAX(id) + 1, which keeps every legacy id untouched. + /// + public static LookupOtherPlan PlanOtherRow(IReadOnlyList rows) + { + var candidates = rows.Where(r => IsOtherLabel(r.Label)).ToList(); + + if (candidates.Count > 1) + { + // Nothing here can pick the right one, and picking wrong makes a real option + // permanently un-editable: the app refuses to rename or delete whatever holds + // IsOther. + return new LookupOtherPlan(0, false, null, + $"{candidates.Count} rows start with \"{OtherLabel}\" - " + + string.Join("; ", candidates.Select(c => $"Id={c.Id} '{c.Label}'"))); + } + + if (candidates.Count == 0) + { + // No candidate means no row is labeled "Other" either, since a bare "Other" would + // have matched the prefix - so the new row cannot collide. + var maxId = rows.Count == 0 ? 0 : rows.Max(r => r.Id); + return new LookupOtherPlan(maxId + 1, true, null, null); + } + + var existing = candidates[0]; + + // The destination label column is UNIQUE, so a different row already holding the bare + // label would collide on the rename. + var colliding = rows + .Where(r => r.Id != existing.Id + && r.Label.Trim().Equals(OtherLabel, StringComparison.OrdinalIgnoreCase)) + .Select(r => r.Id) + .ToList(); + + if (colliding.Count > 0) + { + return new LookupOtherPlan(0, false, null, + $"renaming Id={existing.Id} to '{OtherLabel}' collides with Id(s) " + + string.Join(", ", colliding)); + } + + var alreadyBare = existing.Label.Trim().Equals(OtherLabel, StringComparison.OrdinalIgnoreCase); + return new LookupOtherPlan(existing.Id, false, alreadyBare ? null : existing.Label, null); + } + + /// + /// Reads one legacy lookup table. Shared so the analysis pass and the transform work from + /// the same rows, and so the catch-all decision is made against identical input. + /// + public static List ReadLookupTable( + SqlConnection legacyConn, string table, string idColumn, string labelColumn) + { + var rows = new List(); + + using var cmd = new SqlCommand($"SELECT {idColumn}, {labelColumn} FROM [dbo].[{table}]", legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var label = reader.IsDBNull(1) ? null : reader.GetString(1).Trim(); + rows.Add(new LookupRow(reader.GetInt32(0), label ?? string.Empty)); + } + + return rows; + } + + /// + /// Folds the legacy postGradOther free text into ShortTermStatement, which is where VIPER 2 + /// expects an "Other" post-graduation choice to be described - the new schema has no + /// PostGradOther column and the form says as much (see CareerSelectionService's + /// PostGradCompleted, which measures that choice against ShortTermStatement). + /// + /// A blank ShortTermStatement takes the text outright rather than gaining a leading blank + /// line, so nothing marks these rows out as migrated. + /// + public static string CombineShortTermStatement(string? shortTermStatement, string? postGradOther) + { + var shortTerm = (shortTermStatement ?? string.Empty).Trim(); + var other = (postGradOther ?? string.Empty).Trim(); + + if (other.Length == 0) + { + return shortTerm; + } + + return shortTerm.Length == 0 ? other : $"{shortTerm}\r\n\r\n{other}"; + } + + /// + /// Reads the real column widths from the destination, since the physical columns are the + /// authority rather than the EF model's HasMaxLength calls. Without this a too-long value + /// surfaces only as SQL Server's "String or binary data would be truncated", which names + /// neither the column nor the value. + /// + /// Read in characters, not bytes: callers compare these against string.Length. The + /// equivalent sys.columns.max_length is a byte count, which would report double the real + /// limit for an nvarchar column and let an over-long value through the very check this + /// exists to make. Only string columns are returned; a MAX column reports -1, which + /// callers already read as "no limit". + /// + /// + public static Dictionary GetColumnMaxLengths( + SqlConnection connection, string schema, string table, SqlTransaction? transaction = null) + { + var lengths = new Dictionary(StringComparer.OrdinalIgnoreCase); + + const string sql = @" + SELECT COLUMN_NAME, CAST(CHARACTER_MAXIMUM_LENGTH AS int) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table + AND CHARACTER_MAXIMUM_LENGTH IS NOT NULL"; + + using var cmd = new SqlCommand(sql, connection, transaction); + cmd.Parameters.AddWithValue("@schema", schema); + cmd.Parameters.AddWithValue("@table", table); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + lengths[reader.GetString(0)] = reader.GetInt32(1); + } + + return lengths; + } + + /// + /// Reports whether a destination table exists yet. The students career tables are created + /// by hand-run DDL rather than an EF migration, so the analysis can be run before they + /// exist and should say so plainly instead of failing on a missing object. + /// + public static bool TableExists(SqlConnection connection, string schema, string table) + { + const string sql = "SELECT OBJECT_ID(@qualifiedTable)"; + + using var cmd = new SqlCommand(sql, connection); + cmd.Parameters.AddWithValue("@qualifiedTable", $"[{schema}].[{table}]"); + + var result = cmd.ExecuteScalar(); + return result is not null && result is not DBNull; + } + + public static void ShowProgress(int current, int total, int interval = 5000, string itemName = "records") + { + if (current % interval == 0) + { + int percent = total > 0 ? current * 100 / total : 0; + Console.WriteLine($" Processing: {current:N0} / {total:N0} {itemName} ({percent}%)..."); + } + } + } +} diff --git a/web/Areas/Students/Scripts/MigrateCareerSelectionData.cs b/web/Areas/Students/Scripts/MigrateCareerSelectionData.cs new file mode 100644 index 000000000..8de071fd3 --- /dev/null +++ b/web/Areas/Students/Scripts/MigrateCareerSelectionData.cs @@ -0,0 +1,769 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Viper.Areas.Students.Scripts +{ + /// One legacy tb_CareerSelection row, read before any transform is applied. + public sealed record LegacyCareerSelection( + int CareerSelectionId, + string RawPidm, + DateTime DateAdded, + DateTime? DateModified, + int? Career, + string? CareerOther, + int? FirstSpecies, + string? FirstSpeciesOther, + int? SecondSpecies, + string? SecondSpeciesOther, + int? PostGrad, + string? PostGradOther, + string? ShortTermStatement, + string? LongTermStatement, + string? FacultyMothraId); + + /// + /// A value too long for the column that has to hold it. Collected rather than thrown on, so + /// one run reports every violation; a run with any violation refuses to commit, so a truncated + /// value is never persisted. + /// + public sealed record LengthViolation(int CareerSelectionId, string Column, int Length, int MaxLength); + + /// + /// Transforms and loads the legacy SIS career selection tables into the students schema. + /// + /// Everything happens inside one transaction that is rolled back unless --apply is passed, so + /// a dry run exercises every insert and every constraint without keeping the result. + /// + public class MigrateCareerSelectionData + { + private const string Schema = "students"; + + private readonly string _legacyConnectionString; + private readonly string _viperConnectionString; + private readonly bool _apply; + + private readonly List _lengthViolations = []; + private readonly Dictionary _columnMaxLengths = new(StringComparer.OrdinalIgnoreCase); + + private int _careerOtherId; + private int _speciesOtherId; + private int _postGradOtherId; + + private int _implicitOtherConverted; + private int _staleOtherTextDropped; + private int _postGradOtherMerged; + private int _placeholderDropped; + + public MigrateCareerSelectionData(bool apply, IConfiguration? configuration = null) + { + var config = configuration ?? CareerSelectionScriptHelper.LoadConfiguration(); + _viperConnectionString = CareerSelectionScriptHelper.GetConnectionString(config, "VIPER", readOnly: false); + _legacyConnectionString = CareerSelectionScriptHelper.GetConnectionString(config, "SIS"); + _apply = apply; + } + + public static void Run(string[] args) + { + var apply = args.Any(a => a.Equals("--apply", StringComparison.OrdinalIgnoreCase)); + + Console.WriteLine("==========================================="); + Console.WriteLine("CAREER SELECTION DATA MIGRATION"); + Console.WriteLine("==========================================="); + Console.WriteLine($"Started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($"Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"}"); + Console.WriteLine($"Mode: {(apply ? "APPLY (writes permanently)" : "DRY RUN (rolls back)")}"); + Console.WriteLine(); + + if (apply && !ConfirmDestructiveRun()) + { + // Exit rather than return: a plain return reaches the caller as success, and the + // runner script would close by announcing a completed migration that never ran. + Console.WriteLine("Aborted."); + Environment.Exit(2); + } + + try + { + var migrator = new MigrateCareerSelectionData(apply); + + Console.WriteLine("Connection Configuration:"); + Console.WriteLine($" SIS Database: {CareerSelectionScriptHelper.GetServerAndDatabase(migrator._legacyConnectionString)}"); + Console.WriteLine($" VIPER Database: {CareerSelectionScriptHelper.GetServerAndDatabase(migrator._viperConnectionString)}"); + Console.WriteLine(); + + migrator.Execute(); + } + catch (InvalidOperationException ex) + { + WriteFatalError(ex); + } + catch (SqlException ex) + { + WriteFatalError(ex); + } + } + + private static bool ConfirmDestructiveRun() + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("This will DELETE and rebuild the four students career tables:"); + Console.WriteLine($" {Schema}.CareerSelection, {Schema}.CareerOption, {Schema}.SpeciesOption, {Schema}.PostGradOption"); + Console.ResetColor(); + Console.Write("Type APPLY to continue: "); + + var response = Console.ReadLine(); + return string.Equals(response?.Trim(), "APPLY", StringComparison.Ordinal); + } + + private static void WriteFatalError(Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\nERROR: {ex.Message}"); + Console.WriteLine("\nStack Trace:"); + Console.WriteLine(ex.StackTrace); + Console.ResetColor(); + Environment.Exit(1); + } + + public void Execute() + { + using var legacyConn = new SqlConnection(_legacyConnectionString); + using var viperConn = new SqlConnection(_viperConnectionString); + legacyConn.Open(); + viperConn.Open(); + + VerifyDestinationTables(viperConn); + + Console.WriteLine("Reading legacy lookup tables..."); + var careerRows = CareerSelectionScriptHelper.ReadLookupTable(legacyConn, "LK_careers", "career_ID", "career"); + var speciesRows = CareerSelectionScriptHelper.ReadLookupTable(legacyConn, "LK_species", "species_id", "species"); + var postGradRows = CareerSelectionScriptHelper.ReadLookupTable(legacyConn, "LK_PostGrad", "postgrad_id", "postgrad_text"); + Console.WriteLine($" LK_careers: {careerRows.Count:N0}, LK_species: {speciesRows.Count:N0}, LK_PostGrad: {postGradRows.Count:N0}"); + Console.WriteLine(); + + Console.WriteLine("Planning the catch-all (Other) row for each lookup table..."); + var careerPlan = PlanOrAbort(careerRows, "LK_careers"); + var speciesPlan = PlanOrAbort(speciesRows, "LK_species"); + var postGradPlan = PlanOrAbort(postGradRows, "LK_PostGrad"); + _careerOtherId = careerPlan.OtherId; + _speciesOtherId = speciesPlan.OtherId; + _postGradOtherId = postGradPlan.OtherId; + Console.WriteLine($" LK_careers: {careerPlan.Describe()}"); + Console.WriteLine($" LK_species: {speciesPlan.Describe()}"); + Console.WriteLine($" LK_PostGrad: {postGradPlan.Describe()}"); + Console.WriteLine(); + + Console.WriteLine("Reading legacy career selections..."); + var legacyRows = ReadLegacyCareerSelections(legacyConn); + Console.WriteLine($" {legacyRows.Count:N0} rows."); + Console.WriteLine(); + + RunPreflightGuards(legacyConn, careerRows, speciesRows, postGradRows, legacyRows); + + using var tx = viperConn.BeginTransaction(); + try + { + LoadColumnMaxLengths(viperConn, tx); + + ClearExistingData(viperConn, tx); + InsertOptions(viperConn, tx, "CareerOption", "Career", careerRows, careerPlan); + InsertOptions(viperConn, tx, "SpeciesOption", "Species", speciesRows, speciesPlan); + InsertOptions(viperConn, tx, "PostGradOption", "PostGrad", postGradRows, postGradPlan); + InsertCareerSelections(viperConn, tx, legacyRows); + + ReportTransformCounts(); + + if (_lengthViolations.Count > 0) + { + ReportLengthViolations(); + tx.Rollback(); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("\nROLLED BACK: values too long for their destination columns (listed above)."); + Console.ResetColor(); + Environment.Exit(1); + return; + } + + ValidateMigration(viperConn, tx, careerRows, speciesRows, postGradRows, legacyRows, + careerPlan, speciesPlan, postGradPlan); + + if (_apply) + { + tx.Commit(); + } + else + { + tx.Rollback(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("\nDRY RUN - rolled back. Re-run with --apply to write permanently."); + Console.ResetColor(); + } + } + catch + { + tx.Rollback(); + throw; + } + + // Everything below is past the commit, so it sits outside the try: rolling back a + // committed transaction throws and would bury the real error, leaving the runner to + // announce that nothing was written when the rows are already in. + if (!_apply) + { + return; + } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("\nCOMMITTED."); + Console.ResetColor(); + + try + { + // Reseeded after the commit, deliberately: DBCC CHECKIDENT RESEED is not reliably + // undone by a rollback, so running it inside the transaction would let a dry run + // leave the seeds changed on tables whose rows it discarded. + ReseedIdentities(viperConn); + } + catch (SqlException ex) + { + WriteCommittedButReseedFailed(ex); + } + catch (InvalidOperationException ex) + { + WriteCommittedButReseedFailed(ex); + } + } + + /// + /// Reports a failure that happened after the data was committed. Reseeding needs more + /// rights than the inserts did - db_owner or db_ddladmin rather than just IDENTITY_INSERT - + /// so it can fail on its own. Exits 3 rather than 1 so the runner can say the rows are in, + /// which is what stops someone re-running the migration on top of them. + /// + private static void WriteCommittedButReseedFailed(Exception ex) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"\nThe migration COMMITTED, but the identity reseed failed: {ex.Message}"); + Console.WriteLine("Run DBCC CHECKIDENT on the four students career tables before the"); + Console.WriteLine("app inserts new rows. Do not re-run this migration."); + Console.ResetColor(); + Environment.Exit(3); + } + + private static CareerSelectionScriptHelper.LookupOtherPlan PlanOrAbort( + List rows, string table) + { + var plan = CareerSelectionScriptHelper.PlanOtherRow(rows); + + if (plan.Error is not null) + { + throw new InvalidOperationException( + $"{table}: {plan.Error}. Re-run the analysis and resolve this before migrating."); + } + + return plan; + } + + private static void VerifyDestinationTables(SqlConnection viperConn) + { + foreach (var table in new[] { "CareerOption", "SpeciesOption", "PostGradOption", "CareerSelection" }) + { + if (!CareerSelectionScriptHelper.TableExists(viperConn, Schema, table)) + { + throw new InvalidOperationException( + $"Destination table [{Schema}].[{table}] does not exist. Run the career selection DDL first."); + } + } + } + + // ---------- Pre-flight guards ---------- + + /// + /// Re-asserts what the analysis pass proved on Development, Test and Production. A later + /// environment whose data differs aborts here rather than silently migrating something + /// misshapen - these are the assumptions every transform below is written against. + /// + private static void RunPreflightGuards( + SqlConnection legacyConn, + List careerRows, + List speciesRows, + List postGradRows, + List legacyRows) + { + Console.WriteLine("Running pre-flight guards..."); + var failures = new List(); + + GuardLookupTable(careerRows, "LK_careers", failures); + GuardLookupTable(speciesRows, "LK_species", failures); + GuardLookupTable(postGradRows, "LK_PostGrad", failures); + + var unparseable = legacyRows + .Where(r => !CareerSelectionScriptHelper.TryParsePidm(r.RawPidm, out _)) + .ToList(); + if (unparseable.Count > 0) + { + failures.Add($"{unparseable.Count} row(s) have a PIDM that will not parse as an int: " + + string.Join(", ", unparseable.Take(10).Select(r => $"Id={r.CareerSelectionId} '{r.RawPidm}'"))); + } + + var duplicatePidms = legacyRows + .Select(r => CareerSelectionScriptHelper.TryParsePidm(r.RawPidm, out var p) ? p : (int?)null) + .Where(p => p.HasValue) + .GroupBy(p => p!.Value) + .Where(g => g.Count() > 1) + .ToList(); + if (duplicatePidms.Count > 0) + { + failures.Add($"{duplicatePidms.Count} PIDM(s) appear on more than one row, which the " + + $"destination's UQ_CareerSelection_Pidm rejects: " + + string.Join(", ", duplicatePidms.Take(10).Select(g => g.Key.ToString()))); + } + + // classYear has no destination column because it is empty everywhere it was checked. + // A populated value means that decision was made against different data than this. + using (var cmd = new SqlCommand( + "SELECT COUNT(*) FROM [dbo].[tb_CareerSelection] WHERE classYear IS NOT NULL AND RTRIM(classYear) <> ''", + legacyConn)) + { + var populated = (int)cmd.ExecuteScalar(); + if (populated > 0) + { + failures.Add($"{populated} row(s) have a populated classYear, which has no destination column."); + } + } + + if (failures.Count > 0) + { + throw new InvalidOperationException( + "Pre-flight guards failed:\n - " + string.Join("\n - ", failures)); + } + + Console.WriteLine(" All guards passed."); + Console.WriteLine(); + } + + private static void GuardLookupTable( + List rows, string table, List failures) + { + var blank = rows.Where(r => r.Label.Length == 0).ToList(); + if (blank.Count > 0) + { + failures.Add($"{table}: {blank.Count} row(s) have a null or blank label, but the " + + $"destination column is NOT NULL: " + string.Join(", ", blank.Select(r => $"Id={r.Id}"))); + } + + var duplicates = rows + .Where(r => r.Label.Length > 0) + .GroupBy(r => r.Label, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .ToList(); + if (duplicates.Count > 0) + { + failures.Add($"{table}: {duplicates.Count} duplicate label(s), but the destination " + + $"column is UNIQUE: " + string.Join(", ", duplicates.Select(g => $"'{g.Key}'"))); + } + } + + // ---------- Legacy reads ---------- + + private static List ReadLegacyCareerSelections(SqlConnection legacyConn) + { + var rows = new List(); + + const string sql = @" + SELECT CareerSelection_ID, PIDM, dateAdded, dateModified, + career, careerOther, + firstSpecies, firstSpeciesOther, + secondSpecies, secondSpeciesOther, + postGrad, postGradOther, + shortTermStatement, longTermStatement, facultyMothraID + FROM [dbo].[tb_CareerSelection] + ORDER BY CareerSelection_ID"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(new LegacyCareerSelection( + reader.GetInt32(0), + reader.IsDBNull(1) ? "" : reader.GetString(1).Trim(), + reader.GetDateTime(2), + reader.IsDBNull(3) ? null : reader.GetDateTime(3), + reader.IsDBNull(4) ? null : reader.GetInt32(4), + ReadNullableString(reader, 5), + reader.IsDBNull(6) ? null : reader.GetInt32(6), + ReadNullableString(reader, 7), + reader.IsDBNull(8) ? null : reader.GetInt32(8), + ReadNullableString(reader, 9), + reader.IsDBNull(10) ? null : reader.GetInt32(10), + ReadNullableString(reader, 11), + ReadNullableString(reader, 12), + ReadNullableString(reader, 13), + ReadNullableString(reader, 14))); + } + + return rows; + } + + private static string? ReadNullableString(SqlDataReader reader, int ordinal) + { + return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + } + + // ---------- Destination clearing ---------- + + /// + /// The four tables are new and hold only test data, so they are cleared wholesale. + /// CareerSelection goes first: the other three are its foreign key targets. + /// + private static void ClearExistingData(SqlConnection conn, SqlTransaction tx) + { + Console.WriteLine("Clearing destination tables..."); + + foreach (var table in new[] { "CareerSelection", "CareerOption", "SpeciesOption", "PostGradOption" }) + { + using var cmd = new SqlCommand($"DELETE FROM [{Schema}].[{table}]", conn, tx); + var deleted = cmd.ExecuteNonQuery(); + Console.WriteLine($" {table}: {deleted:N0} row(s) deleted"); + } + + Console.WriteLine(); + } + + // ---------- Step 1: option tables ---------- + + /// + /// Inserts one lookup table, preserving every legacy id under IDENTITY_INSERT so the four + /// foreign keys carry over without remapping. The catch-all row is either renamed in place + /// as it is written, or appended as a new row the legacy table never had. + /// + private void InsertOptions( + SqlConnection conn, + SqlTransaction tx, + string table, + string labelColumn, + List rows, + CareerSelectionScriptHelper.LookupOtherPlan plan) + { + Console.WriteLine($"Inserting {Schema}.{table}..."); + + var sql = $@" + SET IDENTITY_INSERT [{Schema}].[{table}] ON; + INSERT INTO [{Schema}].[{table}] ([{table}Id], [{labelColumn}], [IsOther]) + VALUES (@id, @label, @isOther); + SET IDENTITY_INSERT [{Schema}].[{table}] OFF;"; + + var inserted = 0; + + foreach (var row in rows) + { + var isOther = row.Id == plan.OtherId; + + // The catch-all keeps its id but loses the trailing instructions: the bare label is + // what the option list shows, and the form supplies the explanation. + var label = isOther ? CareerSelectionScriptHelper.OtherLabel : row.Label; + + ExecuteOptionInsert(conn, tx, sql, row.Id, label, isOther, table, labelColumn); + inserted++; + } + + if (plan.IsNewRow) + { + ExecuteOptionInsert(conn, tx, sql, plan.OtherId, CareerSelectionScriptHelper.OtherLabel, + isOther: true, table, labelColumn); + inserted++; + Console.WriteLine($" Created the catch-all row legacy never had, at id {plan.OtherId}."); + } + else if (plan.RenamedFrom is not null) + { + Console.WriteLine($" Renamed id {plan.OtherId} from '{plan.RenamedFrom}' to '{CareerSelectionScriptHelper.OtherLabel}'."); + } + + Console.WriteLine($" {inserted:N0} row(s) inserted."); + Console.WriteLine(); + } + + private void ExecuteOptionInsert( + SqlConnection conn, SqlTransaction tx, string sql, + int id, string label, bool isOther, string table, string labelColumn) + { + using var cmd = new SqlCommand(sql, conn, tx); + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@label", BindString(label, $"{table}.{labelColumn}", id)); + cmd.Parameters.AddWithValue("@isOther", isOther); + cmd.ExecuteNonQuery(); + } + + // ---------- Step 2: career selections ---------- + + private void InsertCareerSelections(SqlConnection conn, SqlTransaction tx, List rows) + { + Console.WriteLine($"Inserting {Schema}.CareerSelection..."); + + var sql = $@" + SET IDENTITY_INSERT [{Schema}].[CareerSelection] ON; + INSERT INTO [{Schema}].[CareerSelection] + ([CareerSelectionId], [Pidm], [DateAdded], [DateModified], + [Career], [CareerOther], [FirstSpecies], [FirstSpeciesOther], + [SecondSpecies], [SecondSpeciesOther], [PostGrad], + [ShortTermStatement], [LongTermStatement], [FacultyMothraId]) + VALUES + (@id, @pidm, @dateAdded, @dateModified, + @career, @careerOther, @firstSpecies, @firstSpeciesOther, + @secondSpecies, @secondSpeciesOther, @postGrad, + @shortTerm, @longTerm, @facultyMothraId); + SET IDENTITY_INSERT [{Schema}].[CareerSelection] OFF;"; + + var inserted = 0; + + foreach (var row in rows) + { + // Guarded above, so a failure here means the data changed under the run. + if (!CareerSelectionScriptHelper.TryParsePidm(row.RawPidm, out var pidm)) + { + throw new InvalidOperationException( + $"CareerSelection_ID={row.CareerSelectionId} has an unparseable PIDM '{row.RawPidm}'."); + } + + var (career, careerOther) = ResolveOption(row.Career, row.CareerOther, _careerOtherId); + var (firstSpecies, firstSpeciesOther) = ResolveOption(row.FirstSpecies, row.FirstSpeciesOther, _speciesOtherId); + var (secondSpecies, secondSpeciesOther) = ResolveOption(row.SecondSpecies, row.SecondSpeciesOther, _speciesOtherId); + + // Post-grad has no Other column in the destination, so its free text is folded into + // the short term statement instead - which is where the form tells students to + // describe an "Other" choice, and what PostGradCompleted measures it against. + var (postGrad, postGradOther) = ResolveOption(row.PostGrad, row.PostGradOther, _postGradOtherId); + var shortTerm = CareerSelectionScriptHelper.CombineShortTermStatement(row.ShortTermStatement, postGradOther); + if (postGradOther.Length > 0) + { + _postGradOtherMerged++; + } + + using var cmd = new SqlCommand(sql, conn, tx); + var id = row.CareerSelectionId; + + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@pidm", pidm); + cmd.Parameters.AddWithValue("@dateAdded", row.DateAdded); + cmd.Parameters.AddWithValue("@dateModified", ToDbValue(row.DateModified)); + cmd.Parameters.AddWithValue("@career", ToDbValue(career)); + cmd.Parameters.AddWithValue("@careerOther", BindString(careerOther, "CareerOther", id)); + cmd.Parameters.AddWithValue("@firstSpecies", ToDbValue(firstSpecies)); + cmd.Parameters.AddWithValue("@firstSpeciesOther", BindString(firstSpeciesOther, "FirstSpeciesOther", id)); + cmd.Parameters.AddWithValue("@secondSpecies", ToDbValue(secondSpecies)); + cmd.Parameters.AddWithValue("@secondSpeciesOther", BindString(secondSpeciesOther, "SecondSpeciesOther", id)); + cmd.Parameters.AddWithValue("@postGrad", ToDbValue(postGrad)); + cmd.Parameters.AddWithValue("@shortTerm", BindString(shortTerm, "ShortTermStatement", id)); + cmd.Parameters.AddWithValue("@longTerm", BindString((row.LongTermStatement ?? "").Trim(), "LongTermStatement", id)); + // Normalized against the same rule the analysis reports on: an all-zero placeholder + // names nobody, so it is stored as no mentor rather than as an id that resolves to + // no one. + var mentorMothraId = CareerSelectionScriptHelper.HasMothraId(row.FacultyMothraId) + ? row.FacultyMothraId + : null; + cmd.Parameters.AddWithValue("@facultyMothraId", BindNullableString(mentorMothraId, "FacultyMothraId", id)); + + cmd.ExecuteNonQuery(); + inserted++; + + CareerSelectionScriptHelper.ShowProgress(inserted, rows.Count, interval: 1000, itemName: "career selections"); + } + + Console.WriteLine($" {inserted:N0} row(s) inserted."); + Console.WriteLine(); + } + + /// + /// Reduces one legacy option column and its free-text partner to the pair the destination + /// stores, reconciling the two ways legacy recorded "Other": + /// + /// - No option but free text is how legacy recorded the catch-all before there was a row + /// for it, so the row is re-pointed at the catch-all and keeps its text. + /// - The catch-all already selected keeps its text as-is. + /// - A real option selected alongside stale text drops the text, which is what + /// CareerSelectionMapper.OtherTextFor does on every save the app makes. + /// + /// Legacy's placeholder prompt is read as blank before any of this, so a row holding only + /// the placeholder stays unanswered, and a catch-all selection keeps no text. + /// + /// Text is returned empty rather than null so every text column stores the same "unfilled" + /// value the app writes. + /// + private (int? OptionId, string OtherText) ResolveOption(int? legacyOptionId, string? legacyOtherText, int otherId) + { + if (CareerSelectionScriptHelper.IsLegacyOtherPlaceholder(legacyOtherText)) + { + _placeholderDropped++; + } + + var text = CareerSelectionScriptHelper.CleanOtherText(legacyOtherText); + + if (CareerSelectionScriptHelper.IsImplicitOther(legacyOptionId, text)) + { + _implicitOtherConverted++; + return (otherId, text); + } + + if (legacyOptionId == otherId) + { + return (otherId, text); + } + + if (legacyOptionId is not null && text.Length > 0) + { + _staleOtherTextDropped++; + } + + return (legacyOptionId, string.Empty); + } + + /// Maps a null of any type - including a nullable value type - onto DBNull. + private static object ToDbValue(object? value) => value ?? DBNull.Value; + + // ---------- Column width guarding ---------- + + private void LoadColumnMaxLengths(SqlConnection conn, SqlTransaction tx) + { + foreach (var table in new[] { "CareerSelection", "CareerOption", "SpeciesOption", "PostGradOption" }) + { + foreach (var (column, length) in CareerSelectionScriptHelper.GetColumnMaxLengths(conn, Schema, table, tx)) + { + // Qualified for the option tables, whose label columns share names with + // CareerSelection's option id columns (Career, PostGrad). + _columnMaxLengths[$"{table}.{column}"] = length; + _columnMaxLengths.TryAdd(column, length); + } + } + } + + /// + /// Binds a string, recording any value too long for its physical column. The value is cut + /// down only so the run can continue and collect every violation in one pass - a run with + /// any violation refuses to commit, so a truncated value is never persisted. + /// + private object BindString(string value, string column, int rowId) + { + if (!_columnMaxLengths.TryGetValue(column, out var maxLength) || maxLength <= 0 || value.Length <= maxLength) + { + return value; + } + + _lengthViolations.Add(new LengthViolation(rowId, column, value.Length, maxLength)); + return value[..maxLength]; + } + + private object BindNullableString(string? value, string column, int rowId) + { + var trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? DBNull.Value : BindString(trimmed, column, rowId); + } + + // ---------- Reseed, reporting and validation ---------- + + /// + /// SQL Server bumps an identity seed on its own when IDENTITY_INSERT writes a higher value, + /// but leaving it to inference is how you get a primary key collision on the first row a + /// user creates after cutover. RESEED with no value resets to the column's current maximum. + /// + /// Called only after the commit, never inside the transaction: a RESEED is not reliably + /// undone by a rollback, so a dry run would otherwise leave the seeds pointing at rows it + /// had just discarded. + /// + private static void ReseedIdentities(SqlConnection conn) + { + Console.WriteLine("Reseeding identity columns..."); + + foreach (var table in new[] { "CareerOption", "SpeciesOption", "PostGradOption", "CareerSelection" }) + { + using var cmd = new SqlCommand($"DBCC CHECKIDENT ('[{Schema}].[{table}]', RESEED)", conn); + cmd.ExecuteNonQuery(); + } + + Console.WriteLine(" Done."); + Console.WriteLine(); + } + + private void ReportTransformCounts() + { + Console.WriteLine("Transform summary:"); + Console.WriteLine($" Implicit Other re-pointed at a catch-all row: {_implicitOtherConverted:N0}"); + Console.WriteLine($" postGradOther folded into ShortTermStatement: {_postGradOtherMerged:N0}"); + Console.WriteLine($" Stale free text discarded: {_staleOtherTextDropped:N0}"); + Console.WriteLine($" Legacy placeholder text discarded: {_placeholderDropped:N0}"); + Console.WriteLine(); + } + + private void ReportLengthViolations() + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n{_lengthViolations.Count:N0} value(s) too long for their destination column:"); + foreach (var v in _lengthViolations) + { + Console.WriteLine($" CareerSelection_ID={v.CareerSelectionId} {v.Column}: {v.Length} > {v.MaxLength}"); + } + Console.ResetColor(); + } + + /// + /// Counts what landed against what was read. Runs inside the transaction, so a dry run + /// validates the same numbers an apply would commit. + /// + private void ValidateMigration( + SqlConnection conn, + SqlTransaction tx, + List careerRows, + List speciesRows, + List postGradRows, + List legacyRows, + CareerSelectionScriptHelper.LookupOtherPlan careerPlan, + CareerSelectionScriptHelper.LookupOtherPlan speciesPlan, + CareerSelectionScriptHelper.LookupOtherPlan postGradPlan) + { + Console.WriteLine("Validating..."); + var failures = new List(); + + ExpectCount(conn, tx, "CareerOption", careerRows.Count + (careerPlan.IsNewRow ? 1 : 0), failures); + ExpectCount(conn, tx, "SpeciesOption", speciesRows.Count + (speciesPlan.IsNewRow ? 1 : 0), failures); + ExpectCount(conn, tx, "PostGradOption", postGradRows.Count + (postGradPlan.IsNewRow ? 1 : 0), failures); + ExpectCount(conn, tx, "CareerSelection", legacyRows.Count, failures); + + // Exactly one catch-all per table is what the app assumes throughout - it refuses to + // rename or delete whatever holds IsOther, and sorts on it to put that row last. + foreach (var table in new[] { "CareerOption", "SpeciesOption", "PostGradOption" }) + { + using var cmd = new SqlCommand( + $"SELECT COUNT(*) FROM [{Schema}].[{table}] WHERE IsOther = 1", conn, tx); + var count = (int)cmd.ExecuteScalar(); + if (count != 1) + { + failures.Add($"{table}: expected exactly 1 IsOther row, found {count}"); + } + } + + if (failures.Count > 0) + { + throw new InvalidOperationException( + "Validation failed:\n - " + string.Join("\n - ", failures)); + } + + Console.WriteLine(" All counts match."); + } + + private static void ExpectCount( + SqlConnection conn, SqlTransaction tx, string table, int expected, List failures) + { + using var cmd = new SqlCommand($"SELECT COUNT(*) FROM [{Schema}].[{table}]", conn, tx); + var actual = (int)cmd.ExecuteScalar(); + + Console.WriteLine($" {table}: {actual:N0} (expected {expected:N0})"); + + if (actual != expected) + { + failures.Add($"{table}: expected {expected:N0} rows, found {actual:N0}"); + } + } + } +} diff --git a/web/Areas/Students/Scripts/Program.cs b/web/Areas/Students/Scripts/Program.cs new file mode 100644 index 000000000..da16faeb9 --- /dev/null +++ b/web/Areas/Students/Scripts/Program.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; + +namespace Viper.Areas.Students.Scripts +{ + /// + /// Entry point for CareerSelection migration and data scripts. + /// Routes to different operations based on command line args: + /// - analysis: Run read-only data-quality analysis against the legacy SIS career tables + /// - migrate-data: Transform and load the legacy data into the students schema + /// + public class Program + { + public static int Main(string[] args) + { + if (args.Length == 0) + { + ShowUsage(); + return 1; + } + + var command = args[0].ToLowerInvariant(); + var commandArgs = args.Skip(1).ToArray(); + + switch (command) + { + case "analysis": + CareerSelectionDataAnalysis.Run(commandArgs); + return 0; + + case "migrate-data": + MigrateCareerSelectionData.Run(commandArgs); + return 0; + + default: + Console.WriteLine($"Unknown command: {command}"); + ShowUsage(); + return 1; + } + } + + private static void ShowUsage() + { + Console.WriteLine("CareerSelection Migration Toolkit"); + Console.WriteLine(); + Console.WriteLine("Usage: dotnet run -- [options]"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + Console.WriteLine(" analysis Run read-only data-quality analysis against the legacy SIS career tables"); + Console.WriteLine(" migrate-data Migrate legacy career selection data into the students schema"); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" dotnet run -- analysis"); + Console.WriteLine(" dotnet run -- migrate-data (dry run, rolls back)"); + Console.WriteLine(" dotnet run -- migrate-data --apply (writes permanently)"); + } + } +} diff --git a/web/Areas/Students/Scripts/RunAnalysis.bat b/web/Areas/Students/Scripts/RunAnalysis.bat new file mode 100644 index 000000000..b33ace2c4 --- /dev/null +++ b/web/Areas/Students/Scripts/RunAnalysis.bat @@ -0,0 +1,98 @@ +@echo off +REM ================================================================ +REM Career Selection Migration Toolkit - Data Analysis Runner +REM ================================================================ +REM Usage: RunAnalysis.bat [environment] +REM +REM Examples: +REM RunAnalysis.bat (uses Development config) +REM RunAnalysis.bat Test (uses Test config) +REM RunAnalysis.bat Production (uses Production config) +REM +REM Reads only. The SIS connection is forced to ApplicationIntent=ReadOnly +REM by the script, so this cannot write to the legacy student system. +REM +REM Needs three connection strings for the target environment, all of which +REM the web app already uses: SIS (legacy source), VIPER (destination and +REM users.Person) and AAUD (vw_CurrentAffiliates, for the mentor check). +REM ================================================================ + +echo. +echo ==================================================== +echo CAREER SELECTION DATA MIGRATION ANALYSIS +echo ==================================================== +echo. + +REM setlocal so ASPNETCORE_ENVIRONMENT does not outlive this script. Without it, +REM "RunAnalysis.bat Production" leaves Production set in the caller's shell, where +REM a later dotnet run would pick it up without anyone noticing. +setlocal + +REM Set environment +set ASPNETCORE_ENVIRONMENT=Development +if not "%~1"=="" set ASPNETCORE_ENVIRONMENT=%1 + +echo Environment: %ASPNETCORE_ENVIRONMENT% +echo Using application configuration from appsettings.json +echo. + +REM Check if .NET is installed +dotnet --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: .NET SDK not found. Please install .NET SDK 10.0 or later + echo Download from: https://dotnet.microsoft.com/download + pause + exit /b 1 +) + +REM Check if project file exists +if not exist "CareerSelectionMigration.csproj" ( + echo ERROR: CareerSelectionMigration.csproj not found! + echo Make sure you're running this from the Scripts folder. + pause + exit /b 1 +) + +REM Restore dependencies and build +echo Installing/updating dependencies... +dotnet restore +if %errorlevel% neq 0 ( + echo ERROR: Failed to restore dependencies + pause + exit /b 1 +) + +echo Compiling analysis script... +dotnet build -c Release +if %errorlevel% neq 0 ( + echo ERROR: Failed to compile script + echo Check for compilation errors above + pause + exit /b 1 +) + +REM Run the analysis +echo. +echo Running analysis... +echo. + +dotnet run --project CareerSelectionMigration.csproj --configuration Release -- analysis + +REM Captured before anything else runs, and returned at the end, so a chained or automated +REM caller sees the failure the banner below reports. +set "RC=%errorlevel%" + +echo. +if %RC% equ 0 ( + echo ==================================================== + echo Analysis completed successfully! + echo Check the AnalysisOutput folder for detailed reports. + echo ==================================================== +) else ( + echo ==================================================== + echo Analysis failed. Check error messages above. + echo ==================================================== +) +echo. +pause +exit /b %RC% diff --git a/web/Areas/Students/Scripts/RunMigrateData.bat b/web/Areas/Students/Scripts/RunMigrateData.bat new file mode 100644 index 000000000..852226b98 --- /dev/null +++ b/web/Areas/Students/Scripts/RunMigrateData.bat @@ -0,0 +1,117 @@ +@echo off +REM ================================================================ +REM Career Selection Migration Toolkit - Data Migration Runner +REM ================================================================ +REM Usage: RunMigrateData.bat [environment] [--apply] +REM +REM Examples: +REM RunMigrateData.bat (dry run, Development config) +REM RunMigrateData.bat --apply (apply, Development config) +REM RunMigrateData.bat Test (dry run, Test config) +REM RunMigrateData.bat Test --apply (apply, Test config) +REM RunMigrateData.bat Production --apply (apply, Production config) +REM +REM Run RunAnalysis.bat first - this script re-checks its structural +REM assertions as pre-flight guards and aborts rather than writing if +REM the target environment's data violates them. +REM +REM Without --apply everything runs inside a transaction that is rolled +REM back, so a dry run exercises every insert and constraint without +REM keeping the result. The destination DDL must already have been run. +REM ================================================================ + +setlocal + +REM Parse arguments: --apply is a flag, anything else names the environment. +REM Compared directly rather than pattern-matched - pattern matching silently +REM took --apply as the environment name, which left every connection string +REM empty because AWS was then asked for parameters under /--apply. +set ASPNETCORE_ENVIRONMENT=Development +set SCRIPT_ARGS= + +:parse +if "%~1"=="" goto parsed +if /i "%~1"=="--apply" (set SCRIPT_ARGS= --apply) else (set ASPNETCORE_ENVIRONMENT=%~1) +shift +goto parse +:parsed + +echo. +echo ================================================================ +echo CAREER SELECTION DATA MIGRATION +echo ================================================================ +echo. +echo Environment: %ASPNETCORE_ENVIRONMENT% +if "%SCRIPT_ARGS%"=="" ( + echo Mode: DRY RUN - everything rolls back +) else ( + echo Mode: APPLY - writes permanently +) +echo. + +REM Check if .NET is installed +dotnet --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: .NET SDK not found. Please install .NET SDK 10.0 or later + echo Download from: https://dotnet.microsoft.com/download + pause + exit /b 1 +) + +REM Check if project file exists +if not exist "CareerSelectionMigration.csproj" ( + echo ERROR: CareerSelectionMigration.csproj not found! + echo Make sure you're running this from the Scripts folder. + pause + exit /b 1 +) + +echo Installing/updating dependencies... +dotnet restore +if %errorlevel% neq 0 ( + echo ERROR: Failed to restore dependencies + pause + exit /b 1 +) + +echo Compiling migration script... +dotnet build -c Release +if %errorlevel% neq 0 ( + echo ERROR: Failed to compile script + echo Check for compilation errors above + pause + exit /b 1 +) + +echo. +echo Running migration... +echo. + +dotnet run --project CareerSelectionMigration.csproj --configuration Release -- migrate-data%SCRIPT_ARGS% + +REM Captured before anything else runs, and returned at the end, so a chained or automated +REM caller sees the failure the banner below reports. +set "RC=%errorlevel%" + +echo. +if %RC% equ 0 ( + echo ================================================================ + echo Migration run completed. Check the output above for the summary. + echo ================================================================ +) else if %RC% equ 2 ( + echo ================================================================ + echo Aborted at the confirmation prompt. Nothing was written. + echo ================================================================ +) else if %RC% equ 3 ( + echo ================================================================ + echo The migration COMMITTED, but a step after the commit failed. + echo Read the output above. Do NOT re-run this migration. + echo ================================================================ +) else ( + echo ================================================================ + echo Migration failed. Check error messages above. Nothing was written. + echo ================================================================ +) +echo. +pause +exit /b %RC% diff --git a/web/Areas/Students/Services/CareerSelectionExportService.cs b/web/Areas/Students/Services/CareerSelectionExportService.cs new file mode 100644 index 000000000..1798374a4 --- /dev/null +++ b/web/Areas/Students/Services/CareerSelectionExportService.cs @@ -0,0 +1,307 @@ +using ClosedXML.Excel; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Viper.Areas.Students.Models; +using Viper.Classes.Utilities; +using static Viper.Classes.Utilities.PdfAccessibilityHelper; + +namespace Viper.Areas.Students.Services; + +/// +/// Exports for the student career selection app. Each report has a +/// completeness-summary "overview" variant and a full-detail variant. +/// +public interface ICareerSelectionExportService +{ + /// + /// Generate the overview Excel workbook — one row per student with a + /// completeness flag for each career selection field. + /// + MemoryStream GenerateOverviewExcel(List data); + + /// + /// Generate the overview PDF — one row per student with a completeness + /// flag for each career selection field. + /// + byte[] GenerateOverviewPdf(List data); + + /// + /// Generate the overview CSV: the same rows as the overview workbook, for a reader who wants + /// the data rather than the formatting. + /// + byte[] GenerateOverviewCsv(List data); + + /// + /// Generate the full-detail Excel workbook — one row per student with the + /// selected value for each career selection field. + /// + MemoryStream GenerateExcel(List data); + + /// + /// Generate the full-detail PDF — one row per student with the selected + /// value for each career selection field. + /// + byte[] GeneratePdf(List data); + + /// + /// Generate the full-detail CSV: the same rows as the report workbook, whole statements + /// included. + /// + byte[] GenerateCsv(List data); +} + +public class CareerSelectionExportService : ICareerSelectionExportService +{ + /// + /// Both reports carry the same columns in the same order as the grids they mirror; the + /// overview fills them with completeness flags and the report with the selected values. + /// + private static readonly string[] ColumnHeaders = + [ + "Class", "Name", "Email", "Career", "Species 1", "Species 2", + "Post Grad", "Mentor", "Short Term", "Long Term", "Last Updated" + ]; + + /// + /// Career statements run to 5000 characters, which is unreadable in a landscape table + /// and can push a single row past a whole page, so the PDF shows an opening excerpt. + /// + private const int PdfStatementMaxLength = 200; + + private const int ShortTermColumn = 9; + private const int LongTermColumn = 10; + + /// Statement columns sized to their contents would swallow the sheet. + private const double StatementColumnWidth = 60; + + public MemoryStream GenerateOverviewExcel(List data) + { + using var wb = new XLWorkbook(); + var ws = StudentExportHelper.AddReportWorksheet(wb, "Career Selection Overview", + "Student career selection overview", ColumnHeaders); + + for (int i = 0; i < data.Count; i++) + { + var row = i + 3; + var d = data[i]; + + ws.Cell(row, 1).Value = ExcelHelper.SanitizeStringCell(d.ClassLevel); + ws.Cell(row, 2).Value = ExcelHelper.SanitizeStringCell(d.FullName); + ws.Cell(row, 3).Value = ExcelHelper.SanitizeStringCell(d.Email); + ws.Cell(row, 4).Value = CompletenessLabel(d.DirectionCompleted); + ws.Cell(row, 5).Value = CompletenessLabel(d.PrimaryFocusCompleted); + ws.Cell(row, 6).Value = OptionalCompletenessLabel(d.SecondaryFocusCompleted); + ws.Cell(row, 7).Value = CompletenessLabel(d.PostGradCompleted); + ws.Cell(row, 8).Value = ExcelHelper.SanitizeStringCell(d.MentorName ?? string.Empty); + ws.Cell(row, ShortTermColumn).Value = CompletenessLabel(d.ShortTermPlansCompleted); + ws.Cell(row, LongTermColumn).Value = CompletenessLabel(d.LongTermPlansCompleted); + ws.Cell(row, 11).Value = d.LastUpdated?.ToString("M/d/yyyy") ?? ""; + } + + if (data.Count > 0) + { + ExcelAccessibilityHelper.PromoteToAccessibleTable( + ws.Range(2, 1, data.Count + 2, ColumnHeaders.Length), + "CareerSelectionOverview"); + } + + ws.Columns().AdjustToContents(); + + return ExcelHelper.SaveToStream(wb); + } + + public byte[] GenerateOverviewPdf(List data) => + StudentExportHelper.GenerateTablePdf("Career Selection Overview", "Student career selection overview", table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(); // Class + columns.RelativeColumn(2.5f); // Name + columns.RelativeColumn(2.5f); // Email + columns.RelativeColumn(1.2f); // Career + columns.RelativeColumn(1.2f); // Species 1 + columns.RelativeColumn(1.2f); // Species 2 + columns.RelativeColumn(1.2f); // Post Grad + columns.RelativeColumn(2); // Mentor + columns.RelativeColumn(1.2f); // Short Term + columns.RelativeColumn(1.2f); // Long Term + columns.RelativeColumn(1.3f); // Last Updated + }); + + PdfHeaderRow(table); + PdfEmptyRowIfNone(table, data.Count); + + foreach (var d in data) + { + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.ClassLevel)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.FullName)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.Email)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.DirectionCompleted)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.PrimaryFocusCompleted)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(OptionalCompletenessLabel(d.SecondaryFocusCompleted))); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.PostGradCompleted)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.MentorName)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.ShortTermPlansCompleted)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.LongTermPlansCompleted)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(d.LastUpdated?.ToString("M/d/yyyy") ?? "—"); + } + }); + + public byte[] GenerateOverviewCsv(List data) => + CsvExportHelper.Build(ColumnHeaders, data.Select(d => new[] + { + d.ClassLevel, + d.FullName, + d.Email, + CompletenessLabel(d.DirectionCompleted), + CompletenessLabel(d.PrimaryFocusCompleted), + OptionalCompletenessLabel(d.SecondaryFocusCompleted), + CompletenessLabel(d.PostGradCompleted), + d.MentorName ?? string.Empty, + CompletenessLabel(d.ShortTermPlansCompleted), + CompletenessLabel(d.LongTermPlansCompleted), + d.LastUpdated?.ToString("M/d/yyyy") ?? string.Empty, + })); + + public MemoryStream GenerateExcel(List data) + { + using var wb = new XLWorkbook(); + var ws = StudentExportHelper.AddReportWorksheet(wb, "Career Selection Report", + "Student career selection report", ColumnHeaders); + + for (int i = 0; i < data.Count; i++) + { + var row = i + 3; + var d = data[i]; + + ws.Cell(row, 1).Value = ExcelHelper.SanitizeStringCell(d.ClassLevel); + ws.Cell(row, 2).Value = ExcelHelper.SanitizeStringCell(d.FullName); + ws.Cell(row, 3).Value = ExcelHelper.SanitizeStringCell(d.Email); + ws.Cell(row, 4).Value = ExcelHelper.SanitizeStringCell(d.Direction); + ws.Cell(row, 5).Value = ExcelHelper.SanitizeStringCell(d.PrimaryFocus); + ws.Cell(row, 6).Value = ExcelHelper.SanitizeStringCell(d.SecondaryFocus); + ws.Cell(row, 7).Value = ExcelHelper.SanitizeStringCell(d.PostGrad); + ws.Cell(row, 8).Value = ExcelHelper.SanitizeStringCell(d.MentorName); + ws.Cell(row, ShortTermColumn).Value = ExcelHelper.SanitizeStringCell(d.ShortTermPlans); + ws.Cell(row, ShortTermColumn).Style.Alignment.WrapText = true; + ws.Cell(row, LongTermColumn).Value = ExcelHelper.SanitizeStringCell(d.LongTermPlans); + ws.Cell(row, LongTermColumn).Style.Alignment.WrapText = true; + ws.Cell(row, 11).Value = d.LastUpdated?.ToString("M/d/yyyy") ?? ""; + } + + if (data.Count > 0) + { + ExcelAccessibilityHelper.PromoteToAccessibleTable( + ws.Range(2, 1, data.Count + 2, ColumnHeaders.Length), + "CareerSelectionReport"); + } + + ws.Columns().AdjustToContents(); + ws.Column(ShortTermColumn).Width = StatementColumnWidth; + ws.Column(LongTermColumn).Width = StatementColumnWidth; + + return ExcelHelper.SaveToStream(wb); + } + + public byte[] GeneratePdf(List data) => + StudentExportHelper.GenerateTablePdf("Career Selection Report", "Student career selection report", table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(0.8f); // Class + columns.RelativeColumn(2); // Name + columns.RelativeColumn(2.2f); // Email + columns.RelativeColumn(1.5f); // Career + columns.RelativeColumn(1.5f); // Species 1 + columns.RelativeColumn(1.5f); // Species 2 + columns.RelativeColumn(1.5f); // Post Grad + columns.RelativeColumn(1.8f); // Mentor + columns.RelativeColumn(3.5f); // Short Term + columns.RelativeColumn(3.5f); // Long Term + columns.RelativeColumn(1.2f); // Last Updated + }); + + PdfHeaderRow(table); + PdfEmptyRowIfNone(table, data.Count); + + foreach (var d in data) + { + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.ClassLevel)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.FullName)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.Email)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.Direction)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.PrimaryFocus)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.SecondaryFocus)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.PostGrad)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.MentorName)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(Excerpt(d.ShortTermPlans)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(Excerpt(d.LongTermPlans)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(d.LastUpdated?.ToString("M/d/yyyy") ?? "—"); + } + }); + + public byte[] GenerateCsv(List data) => + CsvExportHelper.Build(ColumnHeaders, data.Select(d => new[] + { + d.ClassLevel, + d.FullName, + d.Email, + d.Direction, + d.PrimaryFocus, + d.SecondaryFocus, + d.PostGrad, + d.MentorName, + d.ShortTermPlans, + d.LongTermPlans, + d.LastUpdated?.ToString("M/d/yyyy") ?? string.Empty, + })); + + private static void PdfHeaderRow(TableDescriptor table) + { + var hdrStyle = TextStyle.Default.FontSize(8).SemiBold(); + table.Header(header => + { + foreach (var label in ColumnHeaders) + { + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2) + .Text(label).Style(hdrStyle); + } + }); + } + + /// + /// The grid's own wording for a search that matches nothing, so an empty PDF reads as + /// "nothing matched" rather than as a failed render. + /// + private const string NoRecordsMessage = "No matching records found"; + + private static void PdfEmptyRowIfNone(TableDescriptor table, int rowCount) + { + if (rowCount == 0) + { + table.Cell().ColumnSpan((uint)ColumnHeaders.Length).Padding(2).Text(NoRecordsMessage).Italic(); + } + } + + private static string CompletenessLabel(bool complete) => complete ? "Yes" : "No"; + + /// + /// A second species focus is optional, so the grid shows no icon at all when it is + /// unset rather than flagging it as missing. The form still prompts for it; the difference + /// is intentional. + /// + private static string OptionalCompletenessLabel(bool complete) => complete ? "Yes" : ""; + + /// + /// Trims a career statement to an opening excerpt for the PDF, marking text that was cut + /// so a reader knows to go to the Excel export or the app for the rest. + /// + private static string Excerpt(string? value) + { + var text = OrDash(value); + return text.Length <= PdfStatementMaxLength + ? text + : text[..(PdfStatementMaxLength - 1)].TrimEnd() + "…"; + } +} diff --git a/web/Areas/Students/Services/CareerSelectionOptionService.cs b/web/Areas/Students/Services/CareerSelectionOptionService.cs new file mode 100644 index 000000000..374e16502 --- /dev/null +++ b/web/Areas/Students/Services/CareerSelectionOptionService.cs @@ -0,0 +1,293 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Models.Entities; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; + +namespace Viper.Areas.Students.Services; + +/// +/// The career selection dropdown options: the lists the form offers, and their admin management. +/// +public class CareerSelectionOptionService : ICareerSelectionOptionService +{ + private readonly VIPERContext _context; + + public CareerSelectionOptionService(VIPERContext context) + { + _context = context; + } + + /// + public async Task> GetOptionsAsync(CareerOptionType type, bool includeUsage, CancellationToken ct = default) + { + var options = await OptionsQuery(type).ToListAsync(ct); + if (!includeUsage) + { + return options; + } + + var usage = await UsageCountsAsync(type, ct); + foreach (var option in options) + { + option.UsageCount = usage.GetValueOrDefault(option.Id); + } + return options; + } + + /// + public async Task CreateOptionAsync(CareerOptionType type, string label, CancellationToken ct = default) + { + var trimmed = label.Trim(); + var error = ValidateLabel(type, trimmed); + if (error != null) + { + return CareerSelectionOptionWriteResult.Invalid(error); + } + + var options = await LoadOptionsAsync(type, ct); + if (IsDuplicate(options, trimmed, excludeId: null)) + { + return CareerSelectionOptionWriteResult.Conflict(DuplicateMessage(trimmed)); + } + + var option = NewOption(type, trimmed); + _context.Add(option); + return await SaveAsync(option, ct); + } + + /// + public async Task UpdateOptionAsync(CareerOptionType type, int id, string label, CancellationToken ct = default) + { + var options = await LoadOptionsAsync(type, ct); + var option = options.FirstOrDefault(o => o.Id == id); + if (option == null) + { + return CareerSelectionOptionWriteResult.NotFound(); + } + if (option.IsOther) + { + return CareerSelectionOptionWriteResult.Invalid("The Other option cannot be renamed."); + } + + var trimmed = label.Trim(); + var error = ValidateLabel(type, trimmed); + if (error != null) + { + return CareerSelectionOptionWriteResult.Invalid(error); + } + if (IsDuplicate(options, trimmed, excludeId: id)) + { + return CareerSelectionOptionWriteResult.Conflict(DuplicateMessage(trimmed)); + } + + option.Label = trimmed; + return await SaveAsync(option, ct); + } + + /// + public async Task DeleteOptionAsync(CareerOptionType type, int id, CancellationToken ct = default) + { + var options = await LoadOptionsAsync(type, ct); + var option = options.FirstOrDefault(o => o.Id == id); + if (option == null) + { + return CareerSelectionOptionWriteResult.NotFound(); + } + if (option.IsOther) + { + return CareerSelectionOptionWriteResult.Invalid("The catch-all option cannot be deleted."); + } + + var inUseMessage = $"\"{option.Label}\" cannot be deleted because a student has selected it."; + if (await IsInUseAsync(type, id, ct)) + { + return CareerSelectionOptionWriteResult.Conflict(inUseMessage); + } + + _context.Remove(option); + try + { + await _context.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.IsDataRejection()) + { + // A student chose the option between the check above and this delete, and the + // foreign key refused it. + return CareerSelectionOptionWriteResult.Conflict(inUseMessage); + } + return CareerSelectionOptionWriteResult.Success(); + } + + /// + /// Commits a create or rename and shapes the outcome into a result. The duplicate check runs + /// before this, so a rejection here means the list changed underneath us rather than that the + /// admin did anything wrong, and the message says so. + /// + private async Task SaveAsync(ICareerSelectionOption option, CancellationToken ct) + { + try + { + await _context.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.IsDataRejection()) + { + // Only reachable if the data changed after it was checked, most likely another admin + // saving the same name at the same moment. + return CareerSelectionOptionWriteResult.Conflict( + "The option could not be saved because the list has changed. Reload the page and try again."); + } + return CareerSelectionOptionWriteResult.Success( + new CareerSelectionOptionDto { Id = option.Id, Label = option.Label, IsOther = option.IsOther }); + } + + /// + /// Checks a trimmed name against the rules every list shares, returning the message to show + /// the admin, or null when the name is acceptable. + /// + private static string? ValidateLabel(CareerOptionType type, string trimmed) + { + if (trimmed.Length == 0) + { + return "Please enter a name."; + } + var maxLength = MaxLabelLength(type); + return trimmed.Length > maxLength ? $"Name must be {maxLength} characters or fewer." : null; + } + + /// + /// Matches the label column length of each option table, so an over-long name is reported as + /// a validation message rather than truncated or rejected by the database. + /// + private static int MaxLabelLength(CareerOptionType type) => type switch + { + CareerOptionType.Career => 100, + CareerOptionType.Species => 100, + CareerOptionType.PostGrad => 200, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."), + }; + + /// + /// Whether another option in the list already has this name. Compared in memory so the rule + /// holds whatever the database collation, and so stored labels with stray whitespace still + /// count as a match. A rename passes its own id as so an option + /// is never a duplicate of itself. + /// + private static bool IsDuplicate(IEnumerable options, string trimmed, int? excludeId) => + options.Any(o => o.Id != excludeId && string.Equals(o.Label.Trim(), trimmed, StringComparison.OrdinalIgnoreCase)); + + /// + /// Shared by create and rename so both report a name collision in the same words. + /// + private static string DuplicateMessage(string trimmed) => $"An option named \"{trimmed}\" already exists."; + + /// + /// Loads every option of a type, tracked for editing. The tables are small, and + /// the shared interface members that paper over their differing column names cannot be + /// translated into SQL, so the whole list is worked on in memory. + /// + private async Task> LoadOptionsAsync(CareerOptionType type, CancellationToken ct) + { + switch (type) + { + case CareerOptionType.Career: + return [.. await _context.CareerOptions.ToListAsync(ct)]; + case CareerOptionType.Species: + return [.. await _context.SpeciesOptions.ToListAsync(ct)]; + case CareerOptionType.PostGrad: + return [.. await _context.PostGradOptions.ToListAsync(ct)]; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."); + } + } + + /// + /// Builds the entity for the table this type maps to. New options are never the catch-all: + /// that row is seeded with the schema and cannot be created from the admin screen. + /// + private static ICareerSelectionOption NewOption(CareerOptionType type, string label) => type switch + { + CareerOptionType.Career => new CareerOption { Career = label }, + CareerOptionType.Species => new SpeciesOption { Species = label }, + CareerOptionType.PostGrad => new PostGradOption { PostGrad = label }, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."), + }; + + /// + /// Whether any stored career selection points at this option. Species is checked in both of + /// its columns, so an option used only as someone's second choice still blocks a delete. + /// + private Task IsInUseAsync(CareerOptionType type, int id, CancellationToken ct) => type switch + { + CareerOptionType.Career => _context.CareerSelections.AnyAsync(c => c.Career == id, ct), + CareerOptionType.Species => _context.CareerSelections.AnyAsync(c => c.FirstSpecies == id || c.SecondSpecies == id, ct), + CareerOptionType.PostGrad => _context.CareerSelections.AnyAsync(c => c.PostGrad == id, ct), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."), + }; + + /// + /// The read query for one option list, projected to the shared DTO so the three tables' own + /// column names stay inside this class. Ordered with the catch-all last, then alphabetically, + /// which is the order the form presents. + /// + private IQueryable OptionsQuery(CareerOptionType type) => type switch + { + CareerOptionType.Career => _context.CareerOptions + .AsNoTracking() + .OrderBy(o => o.IsOther) + .ThenBy(o => o.Career) + .Select(o => new CareerSelectionOptionDto { Id = o.CareerOptionId, Label = o.Career, IsOther = o.IsOther }), + CareerOptionType.Species => _context.SpeciesOptions + .AsNoTracking() + .OrderBy(o => o.IsOther) + .ThenBy(o => o.Species) + .Select(o => new CareerSelectionOptionDto { Id = o.SpeciesOptionId, Label = o.Species, IsOther = o.IsOther }), + CareerOptionType.PostGrad => _context.PostGradOptions + .AsNoTracking() + .OrderBy(o => o.IsOther) + .ThenBy(o => o.PostGrad) + .Select(o => new CareerSelectionOptionDto { Id = o.PostGradOptionId, Label = o.PostGrad, IsOther = o.IsOther }), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."), + }; + + /// + /// How many career selections use each option of a type, keyed by option id. An option nobody + /// has chosen is absent from the dictionary rather than present with a zero. + /// + private async Task> UsageCountsAsync(CareerOptionType type, CancellationToken ct) + { + var selections = _context.CareerSelections.AsNoTracking(); + switch (type) + { + case CareerOptionType.Career: + return await CountByOptionAsync(selections.Select(c => c.Career), ct); + case CareerOptionType.PostGrad: + return await CountByOptionAsync(selections.Select(c => c.PostGrad), ct); + case CareerOptionType.Species: + // Species is referenced from two columns. A selection naming the same species in + // both counts once, so that overlap is subtracted from the per-column totals. + var first = await CountByOptionAsync(selections.Select(c => c.FirstSpecies), ct); + var second = await CountByOptionAsync(selections.Select(c => c.SecondSpecies), ct); + var both = await CountByOptionAsync( + selections.Where(c => c.FirstSpecies == c.SecondSpecies).Select(c => c.FirstSpecies), ct); + return first.Keys.Union(second.Keys).ToDictionary( + id => id, + id => first.GetValueOrDefault(id) + second.GetValueOrDefault(id) - both.GetValueOrDefault(id)); + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."); + } + } + + /// + /// Groups one nullable option-id column into per-option counts, in SQL rather than by loading + /// the selections. Unanswered rows are null and drop out of the count. + /// + private static async Task> CountByOptionAsync(IQueryable optionIds, CancellationToken ct) + { + return await optionIds + .Where(id => id != null) + .GroupBy(id => id!.Value) + .Select(g => new { OptionId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.OptionId, x => x.Count, ct); + } +} diff --git a/web/Areas/Students/Services/CareerSelectionScope.cs b/web/Areas/Students/Services/CareerSelectionScope.cs new file mode 100644 index 000000000..684a1d296 --- /dev/null +++ b/web/Areas/Students/Services/CareerSelectionScope.cs @@ -0,0 +1,17 @@ +namespace Viper.Areas.Students.Services; + +/// +/// Which students a caller may see. Resolved once per request from the caller's permissions, so +/// that the precedence between them is decided in one place rather than at each endpoint. +/// +public enum CareerSelectionScope +{ + /// No career selection access at all. + None, + /// Every student. Admin and read-only callers. + All, + /// Only the students whose career selection names this caller as their mentor. + Mentored, + /// Only the caller's own record. Students. + Own +} diff --git a/web/Areas/Students/Services/CareerSelectionService.cs b/web/Areas/Students/Services/CareerSelectionService.cs new file mode 100644 index 000000000..7dfd0bc5d --- /dev/null +++ b/web/Areas/Students/Services/CareerSelectionService.cs @@ -0,0 +1,679 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Students.Constants; +using Viper.Areas.Students.Models; +using Viper.Areas.Students.Models.Entities; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models.AAUD; + +namespace Viper.Areas.Students.Services; + +public class CareerSelectionService : ICareerSelectionService +{ + private readonly RAPSContext _rapsContext; + private readonly AAUDContext _aaudContext; + private readonly IUserHelper _userHelper; + private readonly VIPERContext _viperContext; + private readonly ILogger _logger; + private readonly IStudentAppAccessService _appAccessService; + private readonly IDvmStudentLookupService _dvmStudentLookup; + + public CareerSelectionService( + RAPSContext rapsContext, + AAUDContext aaudContext, + IUserHelper userHelper, + VIPERContext viperContext, + ILogger logger, + IStudentAppAccessService appAccessService, + IDvmStudentLookupService dvmStudentLookup) + { + _rapsContext = rapsContext; + _aaudContext = aaudContext; + _userHelper = userHelper; + _viperContext = viperContext; + _logger = logger; + _appAccessService = appAccessService; + _dvmStudentLookup = dvmStudentLookup; + } + + /// + /// + /// The join to AaudUser supplies the ids a career selection is saved and shown with. + /// + public async Task> SearchMentorsAsync(string? search, CancellationToken ct = default) + { + var normalizedSearch = PersonSearchHelper.Normalize(search); + if (normalizedSearch == null) + { + return []; + } + + var namePredicate = PersonSearchHelper + .NameMatches(a => a.LastName, a => a.FirstName, normalizedSearch) + .Or(a => a.IdsLoginid != null && a.IdsLoginid.Contains(normalizedSearch)) + .Or(a => a.IdsMailid != null && a.IdsMailid.Contains(normalizedSearch)); + + // The view carries a row per affiliation, so the same person can match more than once. + // Reducing to the identity before the join keeps duplicates out of the picker. + var affiliates = _aaudContext.VwCurrentAffiliates + .AsNoTracking() + .Where(a => a.IdsMothraid != null) + .Where(namePredicate) + .Select(a => a.IdsMothraid) + .Distinct(); + + return await affiliates + .Join(_aaudContext.AaudUsers.Where(u => u.IamId != null), + mothraId => mothraId, + u => u.MothraId, + (_, u) => new MentorOptionDto + { + PersonId = u.AaudUserId, + IamId = u.IamId!, + FullName = u.DisplayLastName + ", " + u.DisplayFirstName, + LoginId = u.LoginId, + MailId = u.MailId + }) + // Ordered after the join so the cap keeps the alphabetically first matches rather + // than whichever rows the join happened to produce. + .OrderBy(m => m.FullName) + .Take(PersonSearchHelper.MaxResults) + .ToListAsync(ct); + } + + /// + public CareerSelectionScope ResolveScope(AaudUser currentUser) + { + if (_userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Admin) + || _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.ReadOnly)) + { + return CareerSelectionScope.All; + } + + if (_userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Faculty)) + { + return CareerSelectionScope.Mentored; + } + + if (_userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Student) + || _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.ViewOwn)) + { + return CareerSelectionScope.Own; + } + + return CareerSelectionScope.None; + } + + /// + public async Task IsMentorOfAsync(string mentorMothraId, int studentPersonId) + { + if (string.IsNullOrWhiteSpace(mentorMothraId)) + { + return false; + } + + var pidm = await _dvmStudentLookup.GetCurrentDvmPidmAsync(studentPersonId); + if (pidm == null) + { + return false; + } + + // Unwrap before the query: the closure then captures a plain int, so the null check above + // does not have to survive into the expression tree. + var studentPidm = pidm.Value; + return await _viperContext.CareerSelections + .AsNoTracking() + .AnyAsync(c => c.Pidm == studentPidm && c.FacultyMothraId == mentorMothraId); + } + + /// + /// + /// Which students to return. Only is unrestricted, + /// and it has to be asked for by name, so a caller that cannot establish its own scope gets + /// an empty roster rather than everyone's. + /// + public async Task> GetStudentCareerListAsync(StudentListAccess access) + { + var (dvmStudents, mothraToPersonId, careerSelectionByPidm) = await LoadDvmStudentsWithCareerDataAsync(access); + var mentorsByMothraId = await LoadMentorsByMothraIdAsync(careerSelectionByPidm.Values); + var result = new List(); + foreach (var student in dvmStudents) + { + var item = CreateRow(student, mothraToPersonId); + + if (TryGetCareerSelection(student, careerSelectionByPidm, out var careerSelection)) + { + item.DirectionCompleted = IsSelectionComplete( + careerSelection.CareerOption, careerSelection.CareerOther); + item.PrimaryFocusCompleted = IsSelectionComplete( + careerSelection.FirstSpeciesOption, careerSelection.FirstSpeciesOther); + item.SecondaryFocusCompleted = IsSelectionComplete( + careerSelection.SecondSpeciesOption, careerSelection.SecondSpeciesOther); + // Post-graduation plans have no free-text field of their own; the form directs + // students to describe an "Other" choice in their short term plans instead. + item.PostGradCompleted = IsSelectionComplete( + careerSelection.PostGradOption, careerSelection.ShortTermStatement); + item.ShortTermPlansCompleted = !String.IsNullOrWhiteSpace(careerSelection.ShortTermStatement); + item.LongTermPlansCompleted = !String.IsNullOrWhiteSpace(careerSelection.LongTermStatement); + + if (careerSelection.FacultyMothraId != null + && mentorsByMothraId.TryGetValue(careerSelection.FacultyMothraId, out var mentor)) + { + item.MentorName = mentor.FullName; + } + + item.LastUpdated = careerSelection.DateModified ?? careerSelection.DateAdded; + } + + result.Add(item); + } + + return result; + } + + public async Task GetStudentCareerDetailAsync(int personId, bool canEdit, bool canViewStudentList) + { + var student = await _dvmStudentLookup.GetDvmStudentAsync(personId); + if (student == null) + { + return null; + } + + var dto = new StudentCareerDetailDto + { + PersonId = student.PersonId, + FullName = student.FullName, + ClassLevel = student.ClassLevel, + CanEdit = canEdit, + CanViewStudentList = canViewStudentList + }; + + var pidm = student.Pidm; + if (pidm == null) + { + // Student exists but has no PIDM mapping — return empty career selection shell + return dto; + } + + var studentPidm = pidm.Value; + var career = await _viperContext.CareerSelections + .Include(c => c.CareerOption) + .Include(c => c.FirstSpeciesOption) + .Include(c => c.SecondSpeciesOption) + .Include(c => c.PostGradOption) + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Pidm == studentPidm); + + if (career != null) + { + dto.StudentInfo.Direction = ToDropdownOption(career.CareerOption); + dto.StudentInfo.DirectionOther = career.CareerOther; + dto.StudentInfo.PrimaryFocus = ToDropdownOption(career.FirstSpeciesOption); + dto.StudentInfo.PrimaryFocusOther = career.FirstSpeciesOther; + dto.StudentInfo.SecondaryFocus = ToDropdownOption(career.SecondSpeciesOption); + dto.StudentInfo.SecondaryFocusOther = career.SecondSpeciesOther; + dto.StudentInfo.PostGrad = ToDropdownOption(career.PostGradOption); + dto.StudentInfo.ShortTermPlans = career.ShortTermStatement; + dto.StudentInfo.LongTermPlans = career.LongTermStatement; + dto.LastUpdated = career.DateModified ?? career.DateAdded; + + // The mentor is stored as a MothraId; the client works in PersonIds. + if (!string.IsNullOrWhiteSpace(career.FacultyMothraId)) + { + var mentor = await _aaudContext.AaudUsers + .Where(u => u.MothraId == career.FacultyMothraId) + // "Last, First" rather than the stored DisplayFullName, so the mentor reads + // the same here, in the picker, and in the list and report. + .Select(u => new { u.AaudUserId, u.IamId, FullName = u.DisplayLastName + ", " + u.DisplayFirstName }) + .AsNoTracking() + .FirstOrDefaultAsync(); + if (mentor != null) + { + dto.StudentInfo.MentorId = mentor.AaudUserId; + dto.StudentInfo.MentorName = mentor.FullName; + dto.StudentInfo.MentorIamId = mentor.IamId; + } + } + } + + return dto; + } + + /// + public async Task> UpdateStudentCareerSelectionAsync(int personId, StudentCareerInfoDto request, bool isAdmin) + { + if (!await _dvmStudentLookup.IsCurrentDvmStudentAsync(personId)) + { + throw new InvalidOperationException($"PersonId {personId} is not a current DVM student"); + } + + var pidm = await _dvmStudentLookup.GetCurrentDvmPidmAsync(personId); + if (pidm == null) + { + throw new InvalidOperationException($"No PIDM found for PersonId {personId}"); + } + + var studentPidm = pidm.Value; + + // A cleared dropdown can arrive as a placeholder option rather than an absent one. + // Drop the whole option. + request.Direction = NormalizeSelection(request.Direction); + request.PrimaryFocus = NormalizeSelection(request.PrimaryFocus); + request.SecondaryFocus = NormalizeSelection(request.SecondaryFocus); + request.PostGrad = NormalizeSelection(request.PostGrad); + if (request.MentorId <= 0) + { + request.MentorId = null; + } + + var errors = await ValidateCareerSelectionAsync(request, isAdmin); + if (errors.Count > 0) + { + return errors; + } + + // The mentor is admin-managed and read-only to the student, so a student's own save + // must leave whatever is already stored untouched. + var mentorMothraId = isAdmin ? await ResolveMentorMothraIdAsync(request.MentorId) : null; + + void ApplyTo(CareerSelection target) + { + CareerSelectionMapper.ApplyStudentInfoToEntity(request, target); + if (isAdmin) + { + target.FacultyMothraId = mentorMothraId; + } + target.DateModified = DateTime.Now; + } + + var careerSelection = await _viperContext.CareerSelections + .FirstOrDefaultAsync(c => c.Pidm == studentPidm); + + var isNew = careerSelection == null; + if (careerSelection == null) + { + careerSelection = new CareerSelection + { + Pidm = studentPidm, + DateAdded = DateTime.Now + }; + _viperContext.CareerSelections.Add(careerSelection); + } + + ApplyTo(careerSelection); + + try + { + await _viperContext.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (isNew && ex.IsUniqueKeyViolation()) + { + // Another save created this student's row between the read above and this insert, + // and the unique index on Pidm refused the second one. Fold the answers into the row + // that won rather than failing a save the student has no way to retry differently. + // Any other rejection (a deleted option, an over-long value) propagates to the caller. + _viperContext.Entry(careerSelection).State = EntityState.Detached; + + var stored = await _viperContext.CareerSelections + .FirstOrDefaultAsync(c => c.Pidm == studentPidm); + if (stored == null) + { + // Rejected for some other reason, so it is not ours to absorb. + throw; + } + + ApplyTo(stored); + await _viperContext.SaveChangesAsync(); + } + + return errors; + } + + /// + /// Reduces a submitted dropdown to nothing selected, or a real option id. Every choice a + /// student can make is a stored row now, "Other" included, so anything without an id is a + /// placeholder left behind by a cleared field. + /// + private static CareerDropdownOption? NormalizeSelection(CareerDropdownOption? option) + => option?.Value > 0 ? option : null; + + /// + /// A choice counts as answered unless it is the catch-all, which also needs its free text. + /// Mirrors isSelectionComplete in CareerSelectionForm.vue, which drives the form's missing + /// fields warning. + /// + private static bool IsSelectionComplete(ICareerSelectionOption? option, string? otherText) + => option != null && (!option.IsOther || !String.IsNullOrWhiteSpace(otherText)); + + /// + /// Checks the submitted option ids against their lookup tables so a bad id is reported as + /// a validation error rather than reaching the database as a foreign key failure. + /// + private async Task> ValidateCareerSelectionAsync(StudentCareerInfoDto request, bool isAdmin) + { + var errors = new List(); + + await ValidateSelectionAsync(request.Direction, CareerOptionType.Career, + "The selected career direction is not a valid option.", errors); + await ValidateSelectionAsync(request.PrimaryFocus, CareerOptionType.Species, + "The selected primary species focus is not a valid option.", errors); + await ValidateSelectionAsync(request.SecondaryFocus, CareerOptionType.Species, + "The selected secondary species focus is not a valid option.", errors); + await ValidateSelectionAsync(request.PostGrad, CareerOptionType.PostGrad, + "The selected post-graduation plan is not a valid option.", errors); + var mentorId = request.MentorId; + if (isAdmin && mentorId != null + && !await IsCurrentAffiliateAsync(mentorId.Value)) + { + errors.Add("The selected mentor is not a current SVM affiliate."); + } + + return errors; + } + + /// + /// Checks one submitted dropdown against its option table, reporting an unknown id as an error. + /// The lookup doubles as the source of truth for IsOther: the submitted flag decides whether + /// the free text is kept, so it is re-read from the stored row rather than taken on the + /// client's word. + /// + private async Task ValidateSelectionAsync(CareerDropdownOption? selection, CareerOptionType type, + string invalidMessage, List errors) + { + if (selection == null) + { + return; + } + + var selectedId = selection.Value; + if (selectedId == null) + { + return; + } + + var isOther = await LookupIsOtherAsync(type, selectedId.Value); + if (isOther == null) + { + errors.Add(invalidMessage); + } + else + { + selection.IsOther = isOther.Value; + } + } + + /// + /// Reads IsOther for one option, or null if no such option exists. Keyed on each table's own + /// id column, since EF cannot translate into a query. + /// + private Task LookupIsOtherAsync(CareerOptionType type, int id) => type switch + { + CareerOptionType.Career => _viperContext.CareerOptions + .Where(o => o.CareerOptionId == id) + .Select(o => (bool?)o.IsOther) + .FirstOrDefaultAsync(), + CareerOptionType.Species => _viperContext.SpeciesOptions + .Where(o => o.SpeciesOptionId == id) + .Select(o => (bool?)o.IsOther) + .FirstOrDefaultAsync(), + CareerOptionType.PostGrad => _viperContext.PostGradOptions + .Where(o => o.PostGradOptionId == id) + .Select(o => (bool?)o.IsOther) + .FirstOrDefaultAsync(), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."), + }; + + /// + /// A stored option as the form's dropdown value, or null when nothing is selected. + /// + private static CareerDropdownOption? ToDropdownOption(ICareerSelectionOption? option) + => option == null ? null : new CareerDropdownOption { Label = option.Label, Value = option.Id, IsOther = option.IsOther }; + + /// + /// Resolves a mentor PersonId to the MothraId the table stores. A null id clears the mentor. + /// + private async Task ResolveMentorMothraIdAsync(int? mentorId) + { + if (mentorId == null) + { + return null; + } + + return await _aaudContext.AaudUsers + .Where(u => u.AaudUserId == mentorId) + .Select(u => u.MothraId) + .FirstOrDefaultAsync(); + } + + /// + /// + /// Which students to report on, on the same terms as + /// . + /// + public async Task> GetStudentCareerReportAsync(StudentListAccess access) + { + var (dvmStudents, mothraToPersonId, careerSelectionByPidm) = await LoadDvmStudentsWithCareerDataAsync(access); + var mentorsByMothraId = await LoadMentorsByMothraIdAsync(careerSelectionByPidm.Values); + + var result = new List(); + foreach (var student in dvmStudents) + { + var dto = CreateRow(student, mothraToPersonId); + + if (TryGetCareerSelection(student, careerSelectionByPidm, out var career)) + { + if (career.CareerOption != null) + { + dto.Direction = career.CareerOption.IsOther ? career.CareerOther : career.CareerOption.Career; + } + if (career.FirstSpeciesOption != null) + { + dto.PrimaryFocus = career.FirstSpeciesOption.IsOther ? career.FirstSpeciesOther : career.FirstSpeciesOption.Species; + } + if (career.SecondSpeciesOption != null) + { + dto.SecondaryFocus = career.SecondSpeciesOption.IsOther ? career.SecondSpeciesOther : career.SecondSpeciesOption.Species; + } + if (career.PostGradOption != null) + { + // No Other field; display "Other" instead. + dto.PostGrad = career.PostGradOption.PostGrad; + } + dto.ShortTermPlans = career.ShortTermStatement; + dto.LongTermPlans = career.LongTermStatement; + + if (career.FacultyMothraId != null + && mentorsByMothraId.TryGetValue(career.FacultyMothraId, out var mentor)) + { + dto.MentorName = mentor.FullName; + } + + dto.LastUpdated = career.DateModified ?? career.DateAdded; + } + + result.Add(dto); + } + + return result; + } + + public Task ToggleAppAccessAsync() => + _appAccessService.ToggleAppAccessAsync(CareerSelectionPermissions.Student); + + /// + public bool CanEdit(int personId, AaudUser currentUser) + { + // Admin can always edit + if (_userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Admin)) + { + return true; + } + + // ReadOnly users cannot edit (view only) + if (_userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.ReadOnly)) + { + return false; + } + + // Students can edit their own record if they have the Student permission, signaling + // that the app is open for student self-service. AaudUserId is PersonId. + return personId == currentUser.AaudUserId && _userHelper.HasPermission(_rapsContext, currentUser, CareerSelectionPermissions.Student); + } + + public Task IsAppOpenAsync() => + _appAccessService.IsAppOpenAsync(CareerSelectionPermissions.Student); + + #region Private Helpers + + /// + /// Loads DVM students from the AAUD view together with their career data, + /// used by both the list and report endpoints. + /// + private async Task<(List DvmStudents, Dictionary MothraToPersonId, Dictionary careerSelectionByPidm)> + LoadDvmStudentsWithCareerDataAsync(StudentListAccess access) + { + if (access.IsDenied) + { + // Every endpoint refuses a denied scope before calling in, so this + // only fires if a future one forgets. Returning nothing keeps that mistake harmless. + return ([], [], []); + } + + var (dvmStudents, mothraToPersonId) = await _dvmStudentLookup.LoadDvmStudentsAsync(); + + // The view has no inherent order, so without this the roster, report and exports could + // change order between loads. Case-insensitive ordinal matches the grid's own name sort, + // and MothraId keeps two students with the same name in a fixed order. + dvmStudents = dvmStudents + .OrderBy(s => s.PersonLastName, StringComparer.OrdinalIgnoreCase) + .ThenBy(s => s.PersonFirstName, StringComparer.OrdinalIgnoreCase) + .ThenBy(s => s.IdsMothraId, StringComparer.Ordinal) + .ToList(); + + // The view contains PIDM as a string and the table keys on an int, so parse once here + // rather than at each lookup below; a student whose PIDM will not parse simply has no + // career selection to match. + var pidms = dvmStudents + .Select(s => DvmStudentLookupService.ParsePidm(s.IdsPidm)) + .Where(p => p.HasValue) + .Select(p => p!.Value) + .ToList(); + + var careerSelectionQuery = _viperContext.CareerSelections + .Include(c => c.CareerOption) + .Include(c => c.FirstSpeciesOption) + .Include(c => c.SecondSpeciesOption) + .Include(c => c.PostGradOption) + .Where(c => EF.Parameter(pidms).Contains(c.Pidm)); + + var isMentorView = access.TryGetMentor(out var mentorMothraId); + if (isMentorView) + { + careerSelectionQuery = careerSelectionQuery.Where(c => c.FacultyMothraId == mentorMothraId); + } + + var careerSelections = await careerSelectionQuery + .AsNoTracking() + .ToListAsync(); + + var careerSelectionByPidm = careerSelections.ToDictionary(c => c.Pidm); + + if (isMentorView) + { + // The mentor is recorded on the career selection, so narrowing the selections above + // has already identified the mentees; drop every other student from the roster rather + // than listing them with empty career data. + dvmStudents = dvmStudents + .Where(s => DvmStudentLookupService.TryParsePidm(s.IdsPidm, out var p) + && careerSelectionByPidm.ContainsKey(p)) + .ToList(); + } + + return (dvmStudents, mothraToPersonId, careerSelectionByPidm); + } + + /// + /// Resolves the mentors referenced by a set of career selections in one query, keyed by the + /// MothraId the career selection stores. Names are read through rather than copied, so a + /// mentor who changes their name reads correctly everywhere without a data fix. + /// + private async Task> LoadMentorsByMothraIdAsync( + IEnumerable careerSelections) + { + var mentorMothraIds = careerSelections + .Select(c => c.FacultyMothraId) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .Distinct() + .ToList(); + + if (mentorMothraIds.Count == 0) + { + return []; + } + + return await _aaudContext.AaudUsers + .Where(u => EF.Parameter(mentorMothraIds).Contains(u.MothraId)) + .Select(u => new MentorIdentity( + u.MothraId, + u.DisplayLastName + ", " + u.DisplayFirstName)) + .AsNoTracking() + .ToDictionaryAsync(m => m.MothraId); + } + + private sealed record MentorIdentity(string MothraId, string FullName); + + /// + /// Starts a roster row with the student's identity columns. A student with no AaudUser + /// mapping is still listed, with PersonId 0 and no detail route. + /// + private T CreateRow(VwDvmStudentsMaxTerm student, Dictionary mothraToPersonId) + where T : StudentCareerRowDto, new() + { + if (!mothraToPersonId.TryGetValue(student.IdsMothraId, out var personId)) + { + _logger.LogWarning( + "DVM student MothraId {MothraId} has no AaudUser mapping — included with PersonId 0", + LogSanitizer.SanitizeId(student.IdsMothraId)); + } + + return new T + { + PersonId = personId, + RowKey = personId > 0 ? personId.ToString() : student.IdsMothraId, + HasDetailRoute = personId > 0, + FullName = $"{student.PersonLastName}, {student.PersonFirstName}", + ClassLevel = student.StudentsClassLevel ?? string.Empty, + Email = DvmStudentLookupService.FormatEmail(student.IdsMailid) + }; + } + + private static bool TryGetCareerSelection(VwDvmStudentsMaxTerm student, + Dictionary careerSelectionByPidm, + [NotNullWhen(true)] out CareerSelection? careerSelection) + { + careerSelection = null; + return DvmStudentLookupService.TryParsePidm(student.IdsPidm, out var pidm) + && careerSelectionByPidm.TryGetValue(pidm, out careerSelection); + } + + /// + /// Only current SVM affiliates may be recorded as a mentor. The picker offers nobody else, so + /// a PersonId outside that set reached the API from something other than the form. + /// If a current mentor becomes a former affiliate, they are no longer selectable as a mentor. + /// Student edits are unaffected, and admin edits will need to replace the old mentor. + /// + private async Task IsCurrentAffiliateAsync(int personId) + { + return await _aaudContext.AaudUsers + .Where(u => u.AaudUserId == personId) + .Join(_aaudContext.VwCurrentAffiliates, + u => u.MothraId, + a => a.IdsMothraid, + (u, _) => u.AaudUserId) + .AnyAsync(); + } + + #endregion +} diff --git a/web/Areas/Students/Services/DvmStudentLookupService.cs b/web/Areas/Students/Services/DvmStudentLookupService.cs new file mode 100644 index 000000000..0bbda5746 --- /dev/null +++ b/web/Areas/Students/Services/DvmStudentLookupService.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.Areas.Students.Services; + +/// +/// Looks up current DVM students through the AAUD students view, the one source for who counts +/// as a current student across the student self-service apps. +/// +public class DvmStudentLookupService : IDvmStudentLookupService +{ + private readonly AAUDContext _aaudContext; + + public DvmStudentLookupService(AAUDContext aaudContext) + { + _aaudContext = aaudContext; + } + + /// + /// Loads every current DVM student, with a map from MothraId to PersonId. A student with no + /// AaudUser row is absent from the map. + /// + public async Task<(List DvmStudents, Dictionary MothraToPersonId)> LoadDvmStudentsAsync() + { + var dvmStudents = await _aaudContext.VwDvmStudentsMaxTerms + .AsNoTracking() + .ToListAsync(); + + // Built from the rows already loaded rather than a second pass over the view. Distinct only + // keeps the parameter list short if a student appears twice in the view. + var mothraIds = dvmStudents + .Select(s => s.IdsMothraId) + .Distinct() + .ToList(); + var mothraToPersonId = await _aaudContext.AaudUsers + .Where(u => EF.Parameter(mothraIds).Contains(u.MothraId)) + .Select(u => new { u.MothraId, u.AaudUserId }) + .AsNoTracking() + .ToDictionaryAsync(u => u.MothraId, u => u.AaudUserId); + + return (dvmStudents, mothraToPersonId); + } + + /// + /// The name, class level and PIDM of one current DVM student, or null if the person is not one. + /// + public async Task GetDvmStudentAsync(int personId) + { + var student = await _aaudContext.AaudUsers + .Where(u => u.AaudUserId == personId) + .Join(_aaudContext.VwDvmStudentsMaxTerms, + u => u.MothraId, + s => s.IdsMothraId, + (u, s) => new { PersonId = u.AaudUserId, s.PersonLastName, s.PersonFirstName, s.StudentsClassLevel, s.IdsPidm }) + .AsNoTracking() + .FirstOrDefaultAsync(); + + if (student == null) + { + return null; + } + + return new DvmStudentIdentity( + student.PersonId, + $"{student.PersonLastName}, {student.PersonFirstName}", + student.StudentsClassLevel ?? string.Empty, + ParsePidm(student.IdsPidm)); + } + + public async Task IsCurrentDvmStudentAsync(int personId) + { + return await _aaudContext.AaudUsers + .Where(u => u.AaudUserId == personId) + .Join(_aaudContext.VwDvmStudentsMaxTerms, + u => u.MothraId, + s => s.IdsMothraId, + (u, s) => u.AaudUserId) + .AnyAsync(); + } + + /// + /// Resolves PIDM for a person through the DVM students view, consistent with + /// the list/report paths that read IdsPidm from VwDvmStudentsMaxTerms. + /// + public async Task GetCurrentDvmPidmAsync(int personId) + { + var pidmStr = await _aaudContext.AaudUsers + .Where(u => u.AaudUserId == personId) + .Join(_aaudContext.VwDvmStudentsMaxTerms, + u => u.MothraId, + s => s.IdsMothraId, + (_, s) => s.IdsPidm) + .FirstOrDefaultAsync(); + + return ParsePidm(pidmStr); + } + + /// + /// The view holds a bare mail ID for most students; an address that already has a domain is + /// kept as it is. + /// + public static string FormatEmail(string? mailId) + { + if (string.IsNullOrEmpty(mailId)) + { + return string.Empty; + } + + return mailId.Contains('@') ? mailId : $"{mailId}@ucdavis.edu"; + } + + /// + /// The AAUD views carry PIDM as a string; every table that keys on one stores an int. This is + /// the single conversion between the two, so no caller can key on a form of its own. + /// + public static int? ParsePidm(string? pidm) => int.TryParse(pidm, out var value) ? value : null; + + /// + /// in out-parameter form, for callers that test and use the PIDM in + /// one boolean expression and so have nowhere to put a null check. + /// + public static bool TryParsePidm(string? pidm, out int value) => int.TryParse(pidm, out value); +} + +public sealed record DvmStudentIdentity(int PersonId, string FullName, string ClassLevel, int? Pidm); diff --git a/web/Areas/Students/Services/EmergencyContactExportService.cs b/web/Areas/Students/Services/EmergencyContactExportService.cs index 2447703ad..7bc15a7bf 100644 --- a/web/Areas/Students/Services/EmergencyContactExportService.cs +++ b/web/Areas/Students/Services/EmergencyContactExportService.cs @@ -4,6 +4,7 @@ using QuestPDF.Infrastructure; using Viper.Areas.Students.Models; using Viper.Classes.Utilities; +using static Viper.Classes.Utilities.PdfAccessibilityHelper; namespace Viper.Areas.Students.Services; @@ -40,33 +41,17 @@ public interface IEmergencyContactExportService public class EmergencyContactExportService : IEmergencyContactExportService { - private static string BuildGeneratedLabel(DateTime generatedAt) => - $"Generated {generatedAt:M/d/yyyy h:mm tt}"; - public MemoryStream GenerateOverviewExcel(List data) { using var wb = new XLWorkbook(); - const string title = "Emergency Contact Overview"; - ExcelAccessibilityHelper.SetCoreProperties(wb, title, - subject: "Student emergency contact overview"); - - var ws = wb.Worksheets.Add(title); - - ws.Cell(1, 1).Value = BuildGeneratedLabel(DateTime.Now); - ws.Cell(1, 1).Style.Font.Italic = true; - var headers = new[] { "Name", "Class Level", "Email", "Phone", "Student Info", "Local Contact", "Emergency Contact", "Permanent Contact", "Last Updated" }; - - for (int col = 0; col < headers.Length; col++) - { - ws.Cell(2, col + 1).Value = headers[col]; - ws.Cell(2, col + 1).Style.Font.Bold = true; - } + var ws = StudentExportHelper.AddReportWorksheet(wb, "Emergency Contact Overview", + "Student emergency contact overview", headers); for (int i = 0; i < data.Count; i++) { @@ -93,110 +78,59 @@ public MemoryStream GenerateOverviewExcel(List data) ws.Columns().AdjustToContents(); - var stream = new MemoryStream(); - wb.SaveAs(stream); - stream.Position = 0; - return stream; + return ExcelHelper.SaveToStream(wb); } - public byte[] GenerateOverviewPdf(List data) - { - // Capture once so per-page header delegates don't drift across pages. - var generatedLabel = BuildGeneratedLabel(DateTime.Now); - const string title = "Emergency Contact Overview"; - - var document = Document.Create(container => + public byte[] GenerateOverviewPdf(List data) => + StudentExportHelper.GenerateTablePdf("Emergency Contact Overview", "Student emergency contact overview", table => { - container.Page(page => + table.ColumnsDefinition(columns => { - page.Size(PageSizes.A4.Landscape()); - page.Margin(0.5f, Unit.Inch); - page.DefaultTextStyle(x => x.FontSize(8)); - - page.Header().Column(col => - { - col.Item().SemanticHeader1().Text(title) - .SemiBold().FontSize(14).AlignCenter(); - col.Item().Text(generatedLabel) - .FontSize(8).Italic().AlignCenter(); - }); - - page.Content().SemanticTable().Table(table => - { - table.ColumnsDefinition(columns => - { - columns.RelativeColumn(2.5f); // Name - columns.RelativeColumn(); // Class - columns.RelativeColumn(2.5f); // Email - columns.RelativeColumn(1.5f); // Phone - columns.RelativeColumn(); // Student Info - columns.RelativeColumn(); // Local - columns.RelativeColumn(); // Emergency - columns.RelativeColumn(); // Permanent - columns.RelativeColumn(1.2f); // Last Updated - }); - - var hdrStyle = TextStyle.Default.FontSize(8).SemiBold(); - table.Header(header => - { - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Name").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Class").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Email").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Phone").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Student Info").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Local").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Emergency").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Permanent").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Updated").Style(hdrStyle); - }); - - foreach (var d in data) - { - table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.FullName)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.ClassLevel)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.Email)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.CellPhone)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.StudentInfoComplete, d.StudentInfoTotal)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.LocalContactComplete, d.LocalContactTotal)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.EmergencyContactComplete, d.EmergencyContactTotal)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.PermanentContactComplete, d.PermanentContactTotal)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(d.LastUpdated?.ToString("M/d/yyyy") ?? "—"); - } - }); + columns.RelativeColumn(2.5f); // Name + columns.RelativeColumn(); // Class + columns.RelativeColumn(2.5f); // Email + columns.RelativeColumn(1.5f); // Phone + columns.RelativeColumn(); // Student Info + columns.RelativeColumn(); // Local + columns.RelativeColumn(); // Emergency + columns.RelativeColumn(); // Permanent + columns.RelativeColumn(1.2f); // Last Updated + }); - page.Footer().SemanticIgnore().AlignCenter().Text(x => - { - x.Span("Page "); - x.CurrentPageNumber(); - x.Span(" of "); - x.TotalPages(); - }); + var hdrStyle = TextStyle.Default.FontSize(8).SemiBold(); + table.Header(header => + { + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Name").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Class").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Email").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Phone").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Student Info").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Local").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Emergency").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Permanent").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Updated").Style(hdrStyle); }); - }) - .WithAccessibility(title, subject: "Student emergency contact overview"); - return document.GeneratePdf(); - } + foreach (var d in data) + { + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.FullName)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.ClassLevel)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.Email)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.CellPhone)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.StudentInfoComplete, d.StudentInfoTotal)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.LocalContactComplete, d.LocalContactTotal)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.EmergencyContactComplete, d.EmergencyContactTotal)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(CompletenessLabel(d.PermanentContactComplete, d.PermanentContactTotal)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(d.LastUpdated?.ToString("M/d/yyyy") ?? "—"); + } + }); public MemoryStream GenerateExcel(List data) { using var wb = new XLWorkbook(); - const string title = "Emergency Contact Report"; - ExcelAccessibilityHelper.SetCoreProperties(wb, title, - subject: "Student emergency contact report"); - - var ws = wb.Worksheets.Add(title); - - ws.Cell(1, 1).Value = BuildGeneratedLabel(DateTime.Now); - ws.Cell(1, 1).Style.Font.Italic = true; - var headers = new[] { "Class", "Name", "Student Info", "Local Contact", "Emergency Contact", "Permanent Contact" }; - - for (int col = 0; col < headers.Length; col++) - { - ws.Cell(2, col + 1).Value = headers[col]; - ws.Cell(2, col + 1).Style.Font.Bold = true; - } + var ws = StudentExportHelper.AddReportWorksheet(wb, "Emergency Contact Report", + "Student emergency contact report", headers); for (int i = 0; i < data.Count; i++) { @@ -224,80 +158,43 @@ public MemoryStream GenerateExcel(List data) ws.Columns().AdjustToContents(); - var stream = new MemoryStream(); - wb.SaveAs(stream); - stream.Position = 0; - return stream; + return ExcelHelper.SaveToStream(wb); } - public byte[] GeneratePdf(List data) - { - var generatedLabel = BuildGeneratedLabel(DateTime.Now); - const string title = "Emergency Contact Report"; - - var document = Document.Create(container => + public byte[] GeneratePdf(List data) => + StudentExportHelper.GenerateTablePdf("Emergency Contact Report", "Student emergency contact report", table => { - container.Page(page => + table.ColumnsDefinition(columns => { - page.Size(PageSizes.A4.Landscape()); - page.Margin(0.5f, Unit.Inch); - page.DefaultTextStyle(x => x.FontSize(8)); - - page.Header().Column(col => - { - col.Item().SemanticHeader1().Text(title) - .SemiBold().FontSize(14).AlignCenter(); - col.Item().Text(generatedLabel) - .FontSize(8).Italic().AlignCenter(); - }); - - page.Content().SemanticTable().Table(table => - { - table.ColumnsDefinition(columns => - { - columns.RelativeColumn(); // Class - columns.RelativeColumn(2); // Name - columns.RelativeColumn(2.5f); // Student Info - columns.RelativeColumn(2.5f); // Local Contact - columns.RelativeColumn(2.5f); // Emergency Contact - columns.RelativeColumn(2.5f); // Permanent Contact - }); - - var hdrStyle = TextStyle.Default.FontSize(8).SemiBold(); - table.Header(header => - { - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Class").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Name").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Student Info").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Local Contact").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Emergency Contact").Style(hdrStyle); - header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Permanent Contact").Style(hdrStyle); - }); - - foreach (var d in data) - { - table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.ClassLevel)); - table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.FullName)); - PdfMultiLineCell(table, FormatStudentInfoLines(d)); - PdfMultiLineCell(table, FormatContactLines(d.LocalContact)); - PdfMultiLineCell(table, FormatContactLines(d.EmergencyContact)); - PdfMultiLineCell(table, FormatContactLines(d.PermanentContact)); - } - }); + columns.RelativeColumn(); // Class + columns.RelativeColumn(2); // Name + columns.RelativeColumn(2.5f); // Student Info + columns.RelativeColumn(2.5f); // Local Contact + columns.RelativeColumn(2.5f); // Emergency Contact + columns.RelativeColumn(2.5f); // Permanent Contact + }); - page.Footer().SemanticIgnore().AlignCenter().Text(x => - { - x.Span("Page "); - x.CurrentPageNumber(); - x.Span(" of "); - x.TotalPages(); - }); + var hdrStyle = TextStyle.Default.FontSize(8).SemiBold(); + table.Header(header => + { + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Class").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Name").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Student Info").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Local Contact").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Emergency Contact").Style(hdrStyle); + header.Cell().Background(Colors.Grey.Lighten3).BorderBottom(1).Padding(2).Text("Permanent Contact").Style(hdrStyle); }); - }) - .WithAccessibility(title, subject: "Student emergency contact report"); - return document.GeneratePdf(); - } + foreach (var d in data) + { + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.ClassLevel)); + table.Cell().BorderBottom(0.5f).Padding(2).Text(OrDash(d.FullName)); + PdfMultiLineCell(table, FormatStudentInfoLines(d)); + PdfMultiLineCell(table, FormatContactLines(d.LocalContact)); + PdfMultiLineCell(table, FormatContactLines(d.EmergencyContact)); + PdfMultiLineCell(table, FormatContactLines(d.PermanentContact)); + } + }); private static string CompletenessLabel(int complete, int total) { @@ -306,14 +203,6 @@ private static string CompletenessLabel(int complete, int total) return "Partial"; } - /// - /// Returns an em-dash placeholder when the value is null or empty so that - /// QuestPDF emits the cell as a tagged TD (whitespace-only and empty Text - /// elements are dropped from the structure tree, breaking PDF/UA clause 7.2). - /// - private static string OrDash(string? value) => - string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); - /// Formats student info as a multi-line block for Excel cells. private static string FormatStudentInfoBlock(StudentContactReportDto d) { diff --git a/web/Areas/Students/Services/EmergencyContactService.cs b/web/Areas/Students/Services/EmergencyContactService.cs index cffbed1da..cd7effffa 100644 --- a/web/Areas/Students/Services/EmergencyContactService.cs +++ b/web/Areas/Students/Services/EmergencyContactService.cs @@ -18,13 +18,17 @@ public class EmergencyContactService : IEmergencyContactService private readonly IUserHelper _userHelper; private readonly ILogger _logger; private readonly RAPSAuditService _rapsAuditService; + private readonly IStudentAppAccessService _appAccessService; + private readonly IDvmStudentLookupService _dvmStudentLookup; public EmergencyContactService( SISContext sisContext, RAPSContext rapsContext, AAUDContext aaudContext, IUserHelper userHelper, - ILogger logger) + ILogger logger, + IStudentAppAccessService appAccessService, + IDvmStudentLookupService dvmStudentLookup) { _sisContext = sisContext; _rapsContext = rapsContext; @@ -32,6 +36,8 @@ public EmergencyContactService( _userHelper = userHelper; _logger = logger; _rapsAuditService = new RAPSAuditService(rapsContext, userHelper); + _appAccessService = appAccessService; + _dvmStudentLookup = dvmStudentLookup; } public async Task> GetStudentContactListAsync() @@ -56,7 +62,7 @@ public async Task> GetStudentContactListAsync() // View already applies display name logic (display_last_name AS person_last_name) FullName = $"{student.PersonLastName}, {student.PersonFirstName}", ClassLevel = student.StudentsClassLevel ?? string.Empty, - Email = FormatEmail(student.IdsMailid) + Email = DvmStudentLookupService.FormatEmail(student.IdsMailid) }; if (int.TryParse(student.IdsPidm, out var pidm) @@ -89,46 +95,32 @@ public async Task> GetStudentContactListAsync() public async Task GetStudentContactDetailAsync(int personId, bool canEdit) { - // Use AAUD view (consistent with list/report) instead of VIPER StudentList - var studentInfo = await _aaudContext.AaudUsers - .Where(u => u.AaudUserId == personId) - .Join(_aaudContext.VwDvmStudentsMaxTerms, - u => u.MothraId, - s => s.IdsMothraId, - (u, s) => new { PersonId = u.AaudUserId, s.PersonLastName, s.PersonFirstName, s.StudentsClassLevel }) - .AsNoTracking() - .FirstOrDefaultAsync(); - - if (studentInfo == null) + var student = await _dvmStudentLookup.GetDvmStudentAsync(personId); + if (student == null) { return null; } - var pidm = await GetCurrentDvmPidmAsync(personId); + var dto = new StudentContactDetailDto + { + PersonId = student.PersonId, + FullName = student.FullName, + ClassLevel = student.ClassLevel, + CanEdit = canEdit + }; + + var pidm = student.Pidm; if (pidm == null) { // Student exists but has no PIDM mapping — return empty contact shell - return new StudentContactDetailDto - { - PersonId = studentInfo.PersonId, - FullName = $"{studentInfo.PersonLastName}, {studentInfo.PersonFirstName}", - ClassLevel = studentInfo.StudentsClassLevel ?? string.Empty, - CanEdit = canEdit - }; + return dto; } + var studentPidm = pidm.Value; var contact = await _sisContext.StudentContacts .Include(c => c.EmergencyContacts) .AsNoTracking() - .FirstOrDefaultAsync(c => c.Pidm == pidm.Value); - - var dto = new StudentContactDetailDto - { - PersonId = studentInfo.PersonId, - FullName = $"{studentInfo.PersonLastName}, {studentInfo.PersonFirstName}", - ClassLevel = studentInfo.StudentsClassLevel ?? string.Empty, - CanEdit = canEdit - }; + .FirstOrDefaultAsync(c => c.Pidm == studentPidm); if (contact != null) { @@ -150,17 +142,19 @@ public async Task> GetStudentContactListAsync() public async Task UpdateStudentContactAsync(int personId, UpdateStudentContactRequest request, string updatedBy) { - if (!await IsCurrentDvmStudentAsync(personId)) + if (!await _dvmStudentLookup.IsCurrentDvmStudentAsync(personId)) { throw new InvalidOperationException($"PersonId {personId} is not a current DVM student"); } - var pidm = await GetCurrentDvmPidmAsync(personId); + var pidm = await _dvmStudentLookup.GetCurrentDvmPidmAsync(personId); if (pidm == null) { throw new InvalidOperationException($"No PIDM found for PersonId {personId}"); } + var studentPidm = pidm.Value; + // Validate all phone fields server-side before persisting var invalidFields = new List(); ValidatePhone(request.StudentInfo.HomePhone, "Student Home Phone", invalidFields); @@ -184,11 +178,11 @@ public async Task UpdateStudentContactAsync(int personId, UpdateStudentContactRe var contact = await _sisContext.StudentContacts .Include(c => c.EmergencyContacts) - .FirstOrDefaultAsync(c => c.Pidm == pidm.Value); + .FirstOrDefaultAsync(c => c.Pidm == studentPidm); if (contact == null) { - contact = new StudentContact { Pidm = pidm.Value }; + contact = new StudentContact { Pidm = studentPidm }; _sisContext.StudentContacts.Add(contact); } @@ -289,57 +283,12 @@ public async Task GetAccessStatusAsync() }; } - public async Task ToggleAppAccessAsync() - { - var permissionId = await GetStudentPermissionIdAsync(); - var roleId = await GetStudentRoleIdAsync(); - var currentLoginId = _userHelper.GetCurrentUser()?.LoginId; - - var rolePermission = await _rapsContext.TblRolePermissions - .FirstOrDefaultAsync(rp => rp.RoleId == roleId - && rp.PermissionId == permissionId); - - // Closing the app REMOVES the role-permission row rather than setting - // Access = 0. In RAPS, a role Deny (Access = 0) overrides an individual - // member Allow, which would silently break individual grants. - var isCurrentlyOpen = rolePermission != null && rolePermission.Access == 1; - - if (isCurrentlyOpen) - { - _rapsAuditService.AuditRolePermissionChange(rolePermission!, RAPSAuditService.AuditActionType.Delete); - _rapsContext.TblRolePermissions.Remove(rolePermission!); - await _rapsContext.SaveChangesAsync(); - return false; - } - - if (rolePermission == null) - { - rolePermission = new TblRolePermission - { - RoleId = roleId, - PermissionId = permissionId, - Access = 1, - ModTime = DateTime.Now, - ModBy = currentLoginId - }; - _rapsContext.TblRolePermissions.Add(rolePermission); - _rapsAuditService.AuditRolePermissionChange(rolePermission, RAPSAuditService.AuditActionType.Create); - } - else - { - // Legacy row with Access = 0 from previous toggle behavior — flip to 1. - rolePermission.Access = 1; - rolePermission.ModTime = DateTime.Now; - rolePermission.ModBy = currentLoginId; - _rapsAuditService.AuditRolePermissionChange(rolePermission, RAPSAuditService.AuditActionType.Update); - } - await _rapsContext.SaveChangesAsync(); - return true; - } + public Task ToggleAppAccessAsync() => + _appAccessService.ToggleAppAccessAsync(EmergencyContactPermissions.Student); public async Task ToggleIndividualAccessAsync(int personId) { - if (!await IsCurrentDvmStudentAsync(personId)) + if (!await _dvmStudentLookup.IsCurrentDvmStudentAsync(personId)) { throw new InvalidOperationException($"PersonId {personId} is not a current DVM student"); } @@ -397,6 +346,7 @@ public async Task CanEditAsync(int personId, string? currentLoginId) } var currentUser = await _aaudContext.AaudUsers + .AsNoTracking() .FirstOrDefaultAsync(u => u.LoginId == currentLoginId); if (currentUser == null) { @@ -437,15 +387,8 @@ public async Task CanEditAsync(int personId, string? currentLoginId) return false; } - public async Task IsAppOpenAsync() - { - var permissionId = await GetStudentPermissionIdAsync(); - var roleId = await GetStudentRoleIdAsync(); - return await _rapsContext.TblRolePermissions - .AnyAsync(rp => rp.RoleId == roleId - && rp.PermissionId == permissionId - && rp.Access == 1); - } + public Task IsAppOpenAsync() => + _appAccessService.IsAppOpenAsync(EmergencyContactPermissions.Student); #region Completeness Calculation @@ -503,23 +446,8 @@ public static List GetContactMissingFields(StudentEmergencyContact? cont #region RAPS Lookups - private async Task GetStudentPermissionIdAsync() - { - var permission = await _rapsContext.TblPermissions - .FirstOrDefaultAsync(p => p.Permission == EmergencyContactPermissions.Student); - return permission?.PermissionId - ?? throw new InvalidOperationException( - $"RAPS permission '{EmergencyContactPermissions.Student}' not found"); - } - - private async Task GetStudentRoleIdAsync() - { - var role = await _rapsContext.TblRoles - .FirstOrDefaultAsync(r => r.Role == EmergencyContactPermissions.StudentRoleName); - return role?.RoleId - ?? throw new InvalidOperationException( - $"RAPS role '{EmergencyContactPermissions.StudentRoleName}' not found"); - } + private Task GetStudentPermissionIdAsync() => + _appAccessService.GetPermissionIdAsync(EmergencyContactPermissions.Student); #endregion @@ -532,16 +460,7 @@ private async Task GetStudentRoleIdAsync() private async Task<(List DvmStudents, Dictionary MothraToPersonId, Dictionary ContactsByPidm)> LoadDvmStudentsWithContactsAsync() { - var dvmStudents = await _aaudContext.VwDvmStudentsMaxTerms - .AsNoTracking() - .ToListAsync(); - - var mothraIds = dvmStudents.Select(s => s.IdsMothraId).ToList(); - var mothraToPersonId = await _aaudContext.AaudUsers - .Where(u => EF.Parameter(mothraIds).Contains(u.MothraId)) - .Select(u => new { u.MothraId, u.AaudUserId }) - .AsNoTracking() - .ToDictionaryAsync(u => u.MothraId, u => u.AaudUserId); + var (dvmStudents, mothraToPersonId) = await _dvmStudentLookup.LoadDvmStudentsAsync(); var pidms = dvmStudents .Select(s => int.TryParse(s.IdsPidm, out var p) ? p : 0) @@ -559,39 +478,6 @@ private async Task GetStudentRoleIdAsync() return (dvmStudents, mothraToPersonId, contactsByPidm); } - private async Task IsCurrentDvmStudentAsync(int personId) - { - // Use AAUD view (consistent with list/report) instead of VIPER StudentList - return await _aaudContext.AaudUsers - .Where(u => u.AaudUserId == personId) - .Join(_aaudContext.VwDvmStudentsMaxTerms, - u => u.MothraId, - s => s.IdsMothraId, - (u, s) => u.AaudUserId) - .AnyAsync(); - } - - /// - /// Resolves PIDM for a person through the DVM students view, consistent with - /// the list/report paths that read IdsPidm from VwDvmStudentsMaxTerms. - /// - private async Task GetCurrentDvmPidmAsync(int personId) - { - var pidmStr = await _aaudContext.AaudUsers - .Where(u => u.AaudUserId == personId) - .Join(_aaudContext.VwDvmStudentsMaxTerms, - u => u.MothraId, - s => s.IdsMothraId, - (_, s) => s.IdsPidm) - .FirstOrDefaultAsync(); - - if (pidmStr != null && int.TryParse(pidmStr, out var pidm)) - { - return pidm; - } - return null; - } - private static void ValidatePhone(string? value, string fieldName, List invalidFields) { if (!PhoneHelper.IsValidPhone(value)) @@ -615,15 +501,5 @@ private void UpsertEmergencyContact(StudentContact contact, string type, Contact } } - private static string FormatEmail(string? mailId) - { - if (string.IsNullOrEmpty(mailId)) - { - return string.Empty; - } - - return mailId.Contains('@') ? mailId : $"{mailId}@ucdavis.edu"; - } - #endregion } diff --git a/web/Areas/Students/Services/ICareerSelectionOptionService.cs b/web/Areas/Students/Services/ICareerSelectionOptionService.cs new file mode 100644 index 000000000..2c7324811 --- /dev/null +++ b/web/Areas/Students/Services/ICareerSelectionOptionService.cs @@ -0,0 +1,42 @@ +using Viper.Areas.Students.Models; + +namespace Viper.Areas.Students.Services; + +/// +/// The career selection dropdown lists - career directions, species and post-graduation plans - +/// and their admin management. Each list is its own table, and +/// selects between them, so callers work with one set of methods rather than three. +/// +/// The writes report a rejected name as a rather +/// than by throwing: a duplicate or an option still in use is something an admin can correct, so +/// it reaches the controller as a message to show rather than an error. +/// +/// +public interface ICareerSelectionOptionService +{ + /// + /// Every option of one type, catch-all last. With each option + /// carries the number of career selections using it; otherwise the counts are left at zero and + /// never queried. Usage covers every stored selection, not just current students: a graduate's + /// record still holds the foreign key that would block a delete. + /// + Task> GetOptionsAsync(CareerOptionType type, bool includeUsage, CancellationToken ct = default); + + /// + /// Adds an option. The name is trimmed and must be unique within its list, ignoring case. + /// + Task CreateOptionAsync(CareerOptionType type, string label, CancellationToken ct = default); + + /// + /// Renames an option. The catch-all cannot be renamed. Changing only the case of a name is + /// allowed, since the option does not count as a duplicate of itself. The returned option + /// carries no usage count; the caller reloads the list for that. + /// + Task UpdateOptionAsync(CareerOptionType type, int id, string label, CancellationToken ct = default); + + /// + /// Deletes an option no career selection uses. The catch-all cannot be deleted, and an option + /// a student has already chosen comes back as a conflict rather than being removed. + /// + Task DeleteOptionAsync(CareerOptionType type, int id, CancellationToken ct = default); +} diff --git a/web/Areas/Students/Services/ICareerSelectionService.cs b/web/Areas/Students/Services/ICareerSelectionService.cs new file mode 100644 index 000000000..1abc578b9 --- /dev/null +++ b/web/Areas/Students/Services/ICareerSelectionService.cs @@ -0,0 +1,78 @@ +using Viper.Areas.Students.Models; +using Viper.Models.AAUD; + +namespace Viper.Areas.Students.Services; + +/// +/// Reads and writes student career selections, and the app-wide access that lets students edit +/// their own. Who may see or change a record is decided here rather than by permissions alone, +/// because a faculty mentor's reach depends on the data: see and +/// . +/// +public interface ICareerSelectionService +{ + /// + /// Searches current SVM affiliates for the mentor picker. The affiliates view decides who is + /// eligible, so someone who has left the school stops being offered without a data fix. + /// A search term shorter than the minimum returns nothing rather than everyone. + /// + Task> SearchMentorsAsync(string? search, CancellationToken ct = default); + + /// + /// Decides which students a caller may see. Admin and read-only outrank Faculty, so someone + /// who holds both still sees everyone; a faculty mentor is narrowed to their own mentees. + /// + CareerSelectionScope ResolveScope(AaudUser currentUser); + + /// + /// Whether a faculty member is the recorded mentor for one student. A mentor's access depends + /// on the data rather than on their permissions, so it cannot be answered from RAPS alone. + /// + Task IsMentorOfAsync(string mentorMothraId, int studentPersonId); + + /// + /// The roster view of career selections: one row per student with completion flags rather + /// than the answers themselves. + /// + /// Which students to return. A denied scope returns nothing. + Task> GetStudentCareerListAsync(StudentListAccess access); + + /// + /// The full report of career selections, carrying each student's stored answers for export. + /// + /// Which students to return. A denied scope returns nothing. + Task> GetStudentCareerReportAsync(StudentListAccess access); + + /// + /// One student's record, or null if they are not a current DVM student. A student who has no + /// answers yet, or no PIDM to key them on, comes back as an empty shell rather than null, so + /// the form has something to render. The two flags are carried through to the DTO for the + /// client to lay the page out with; they do not themselves grant anything. + /// + Task GetStudentCareerDetailAsync(int personId, bool canEdit, bool canViewStudentList); + + /// + /// Creates or updates a student's career selection. Returns the validation errors that stopped + /// the save, or an empty list once it is saved. Only an admin save may change the mentor; a + /// student's own save leaves whatever is stored untouched. + /// + Task> UpdateStudentCareerSelectionAsync(int personId, StudentCareerInfoDto request, bool isAdmin); + + /// + /// Whether the caller may edit this record. Takes the caller rather than their login id: the + /// controller has already resolved them, and re-reading the row here answered the same + /// question from a second, different read of it. + /// + bool CanEdit(int personId, AaudUser currentUser); + + /// + /// Whether students can currently edit their own career selection. + /// + Task IsAppOpenAsync(); + + /// + /// Opens student editing if it is closed and closes it if it is open. Returns whether it is + /// now open. + /// + Task ToggleAppAccessAsync(); +} diff --git a/web/Areas/Students/Services/IDvmStudentLookupService.cs b/web/Areas/Students/Services/IDvmStudentLookupService.cs new file mode 100644 index 000000000..446cc4c0f --- /dev/null +++ b/web/Areas/Students/Services/IDvmStudentLookupService.cs @@ -0,0 +1,38 @@ +using Viper.Models.AAUD; + +namespace Viper.Areas.Students.Services; + +/// +/// Looks up current DVM students through the AAUD students view, the one source for who counts +/// as a current student across the student self-service apps. +/// +/// The PIDM and email conversions stay static on rather +/// than appearing here: they are pure string handling with no dependencies, and callers use them +/// without an instance. +/// +/// +public interface IDvmStudentLookupService +{ + /// + /// Loads every current DVM student, with a map from MothraId to PersonId. A student with no + /// AaudUser row is absent from the map. + /// + Task<(List DvmStudents, Dictionary MothraToPersonId)> LoadDvmStudentsAsync(); + + /// + /// The name, class level and PIDM of one current DVM student, or null if the person is not one. + /// + Task GetDvmStudentAsync(int personId); + + /// + /// Whether this person appears in the current DVM students view. The authority on who may be + /// written to as a student, so a save checks it before touching anything. + /// + Task IsCurrentDvmStudentAsync(int personId); + + /// + /// The PIDM the student records key on, or null if the person is not a current DVM student or + /// the view holds a PIDM that will not parse as a number. + /// + Task GetCurrentDvmPidmAsync(int personId); +} diff --git a/web/Areas/Students/Services/IStudentAppAccessService.cs b/web/Areas/Students/Services/IStudentAppAccessService.cs new file mode 100644 index 000000000..f38427a0a --- /dev/null +++ b/web/Areas/Students/Services/IStudentAppAccessService.cs @@ -0,0 +1,27 @@ +namespace Viper.Areas.Students.Services; + +/// +/// Opens and closes a student self-service app, such as emergency contacts or career selection. +/// An app is open while the DVM student role holds its student permission, so each method takes +/// that permission's name. +/// +public interface IStudentAppAccessService +{ + /// + /// Whether students can currently reach the app. True only while the DVM student role holds + /// the permission and the row grants rather than denies it. + /// + Task IsAppOpenAsync(string studentPermission); + + /// + /// Opens the app if it is closed and closes it if it is open. Returns whether it is now open. + /// + Task ToggleAppAccessAsync(string studentPermission); + + /// + /// The RAPS id of a permission, for callers that grant or revoke it themselves. Throws if the + /// permission does not exist: the names are compile-time constants, so a miss means the RAPS + /// row is missing rather than that the caller asked for something optional. + /// + Task GetPermissionIdAsync(string permissionName); +} diff --git a/web/Areas/Students/Services/StudentAppAccessService.cs b/web/Areas/Students/Services/StudentAppAccessService.cs new file mode 100644 index 000000000..2326dbc45 --- /dev/null +++ b/web/Areas/Students/Services/StudentAppAccessService.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.RAPS.Services; +using Viper.Areas.Students.Constants; +using Viper.Classes.SQLContext; +using Viper.Models.RAPS; + +namespace Viper.Areas.Students.Services; + +/// +/// Opens and closes a student self-service app, such as emergency contacts or career selection. +/// An app is open while the DVM student role holds its student permission, so each method takes +/// that permission's name. +/// +public class StudentAppAccessService : IStudentAppAccessService +{ + private readonly RAPSContext _rapsContext; + private readonly IUserHelper _userHelper; + private readonly RAPSAuditService _rapsAuditService; + + public StudentAppAccessService(RAPSContext rapsContext, IUserHelper userHelper) + { + _rapsContext = rapsContext; + _userHelper = userHelper; + _rapsAuditService = new RAPSAuditService(rapsContext, userHelper); + } + + public async Task IsAppOpenAsync(string studentPermission) + { + var permissionId = await GetPermissionIdAsync(studentPermission); + var roleId = await GetDvmStudentRoleIdAsync(); + return await _rapsContext.TblRolePermissions + .AnyAsync(rp => rp.RoleId == roleId + && rp.PermissionId == permissionId + && rp.Access == 1); + } + + /// + /// Opens the app if it is closed and closes it if it is open. Returns whether it is now open. + /// + public async Task ToggleAppAccessAsync(string studentPermission) + { + var permissionId = await GetPermissionIdAsync(studentPermission); + var roleId = await GetDvmStudentRoleIdAsync(); + var currentLoginId = _userHelper.GetCurrentUser()?.LoginId; + + var rolePermission = await _rapsContext.TblRolePermissions + .FirstOrDefaultAsync(rp => rp.RoleId == roleId + && rp.PermissionId == permissionId); + + // Closing the app REMOVES the role-permission row rather than setting + // Access = 0. In RAPS, a role Deny (Access = 0) overrides an individual + // member Allow, which would silently break individual grants. + var isCurrentlyOpen = rolePermission != null && rolePermission.Access == 1; + + if (isCurrentlyOpen) + { + _rapsAuditService.AuditRolePermissionChange(rolePermission!, RAPSAuditService.AuditActionType.Delete); + _rapsContext.TblRolePermissions.Remove(rolePermission!); + await _rapsContext.SaveChangesAsync(); + return false; + } + + if (rolePermission == null) + { + rolePermission = new TblRolePermission + { + RoleId = roleId, + PermissionId = permissionId, + Access = 1, + ModTime = DateTime.Now, + ModBy = currentLoginId + }; + _rapsContext.TblRolePermissions.Add(rolePermission); + _rapsAuditService.AuditRolePermissionChange(rolePermission, RAPSAuditService.AuditActionType.Create); + } + else + { + // Legacy row with Access = 0 from previous toggle behavior — flip to 1. + rolePermission.Access = 1; + rolePermission.ModTime = DateTime.Now; + rolePermission.ModBy = currentLoginId; + _rapsAuditService.AuditRolePermissionChange(rolePermission, RAPSAuditService.AuditActionType.Update); + } + await _rapsContext.SaveChangesAsync(); + return true; + } + + public async Task GetPermissionIdAsync(string permissionName) + { + var permission = await _rapsContext.TblPermissions + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Permission == permissionName); + return permission?.PermissionId + ?? throw new InvalidOperationException( + $"RAPS permission '{permissionName}' not found"); + } + + private async Task GetDvmStudentRoleIdAsync() + { + var role = await _rapsContext.TblRoles + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Role == StudentRoles.DvmStudents); + return role?.RoleId + ?? throw new InvalidOperationException( + $"RAPS role '{StudentRoles.DvmStudents}' not found"); + } +} diff --git a/web/Areas/Students/Services/StudentExportHelper.cs b/web/Areas/Students/Services/StudentExportHelper.cs new file mode 100644 index 000000000..6022b9a5a --- /dev/null +++ b/web/Areas/Students/Services/StudentExportHelper.cs @@ -0,0 +1,78 @@ +using ClosedXML.Excel; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Viper.Classes.Utilities; + +namespace Viper.Areas.Students.Services; + +/// +/// Layout shared by the student self-service app exports (emergency contacts, career selection): +/// a "Generated" timestamp under the title, and single-table landscape PDFs. +/// +public static class StudentExportHelper +{ + private static string BuildGeneratedLabel(DateTime generatedAt) => + $"Generated {generatedAt:M/d/yyyy h:mm tt}"; + + /// + /// Add the report's worksheet, with the generated timestamp in row 1 and bold column headers + /// in row 2. Data rows start at row 3. + /// + public static IXLWorksheet AddReportWorksheet(XLWorkbook workbook, string title, string subject, IReadOnlyList headers) + { + ExcelAccessibilityHelper.SetCoreProperties(workbook, title, subject: subject); + + var ws = workbook.Worksheets.Add(title); + + ws.Cell(1, 1).Value = BuildGeneratedLabel(DateTime.Now); + ws.Cell(1, 1).Style.Font.Italic = true; + + for (int col = 0; col < headers.Count; col++) + { + ws.Cell(2, col + 1).Value = headers[col]; + ws.Cell(2, col + 1).Style.Font.Bold = true; + } + + return ws; + } + + /// + /// Generate an accessible A4 landscape PDF holding one table under a title and generated + /// timestamp, with page numbers in the footer. + /// + /// Heading printed on every page, and the document title recorded in the + /// PDF's accessibility metadata. + /// The document subject recorded in the PDF's accessibility metadata. + /// It is not drawn on the page. + /// Defines the columns, header row and body rows of the table. + public static byte[] GenerateTablePdf(string title, string subject, Action buildTable) + { + // Capture once so per-page header delegates don't drift across pages. + var generatedLabel = BuildGeneratedLabel(DateTime.Now); + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4.Landscape()); + page.Margin(0.5f, Unit.Inch); + page.DefaultTextStyle(x => x.FontSize(8)); + + page.Header().Column(col => + { + col.Item().SemanticHeader1().Text(title) + .SemiBold().FontSize(14).AlignCenter(); + col.Item().Text(generatedLabel) + .FontSize(8).Italic().AlignCenter(); + }); + + page.Content().SemanticTable().Table(buildTable); + + page.Footer().PageNumberFooter(); + }); + }) + .WithAccessibility(title, subject: subject) + .GeneratePdf(); + } +} diff --git a/web/Areas/Students/Services/StudentListAccess.cs b/web/Areas/Students/Services/StudentListAccess.cs new file mode 100644 index 000000000..51367e66c --- /dev/null +++ b/web/Areas/Students/Services/StudentListAccess.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Viper.Areas.Students.Services; + +/// +/// Which students a roster, report or export may return. +/// +public sealed record StudentListAccess +{ + public enum AccessKind + { + /// No roster at all. Students reach their own record directly instead. + Denied, + /// Every current DVM student. Admin and read-only callers. + AllStudents, + /// Only the students whose career selection names this caller as their mentor. + Mentored + } + + public AccessKind Kind { get; } + + private string? MentorMothraId { get; } + + private StudentListAccess(AccessKind kind, string? mentorMothraId = null) + { + Kind = kind; + MentorMothraId = mentorMothraId; + } + + public static StudentListAccess Denied { get; } = new(AccessKind.Denied); + + public static StudentListAccess AllStudents { get; } = new(AccessKind.AllStudents); + + /// + /// A faculty member's view of their own mentees. A blank MothraId mentors nobody, so it is + /// refused here, matching on the + /// single-record path. + /// + public static StudentListAccess MentoredBy(string? mentorMothraId) => + string.IsNullOrWhiteSpace(mentorMothraId) + ? Denied + : new StudentListAccess(AccessKind.Mentored, mentorMothraId); + + public bool IsDenied => Kind == AccessKind.Denied; + + /// + /// True, with the mentor to narrow to, only when this is a mentor's view. Every other case + /// leaves the id unset, so there is no way to read one out of an unrestricted scope. + /// + public bool TryGetMentor([NotNullWhen(true)] out string? mentorMothraId) + { + mentorMothraId = Kind == AccessKind.Mentored ? MentorMothraId : null; + return mentorMothraId != null; + } +} diff --git a/web/Classes/ApiController.cs b/web/Classes/ApiController.cs index 89ba942bd..e571a4583 100644 --- a/web/Classes/ApiController.cs +++ b/web/Classes/ApiController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Net.Http.Headers; +using Viper.Classes.Utilities; using Viper.Models; namespace Viper.Classes @@ -70,5 +71,40 @@ protected FileContentResult InlineFile(byte[] bytes, string contentType, string Response.Headers.Expires = "0"; return File(bytes, contentType); } + + /// + /// Returns an Excel workbook as a download, named by . + /// + protected FileStreamResult ExcelFile(MemoryStream stream, ExportFilenameOptions filenameOptions) + { + return File(stream, ExcelContentType, ExcelHelper.BuildExportFilename(filenameOptions)); + } + + /// + protected FileStreamResult ExcelFile(MemoryStream stream, string reportName) + { + return ExcelFile(stream, new ExportFilenameOptions { ReportName = reportName }); + } + + /// + /// Returns a CSV as a download, named by . + /// + protected FileContentResult CsvFile(byte[] bytes, string reportName) + { + return File(bytes, CsvContentType, + ExcelHelper.BuildExportFilename(new ExportFilenameOptions { ReportName = reportName, Extension = ".csv" })); + } + + /// + /// Builds an export from , or returns 204 No Content when there is + /// nothing to export so the client can tell the user rather than hand them an empty file. + /// + protected ActionResult FileOrNoContent(List data, Func, ActionResult> buildFile) + { + return data.Count == 0 ? NoContent() : buildFile(data); + } + + private const string ExcelContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private const string CsvContentType = "text/csv"; } } diff --git a/web/Classes/SQLContext/StudentsContext.cs b/web/Classes/SQLContext/StudentsContext.cs index 067a9b17e..6d41d2f04 100644 --- a/web/Classes/SQLContext/StudentsContext.cs +++ b/web/Classes/SQLContext/StudentsContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Viper.Models.Students; +using Viper.Areas.Students.Models.Entities; namespace Viper.Classes.SQLContext; @@ -8,6 +9,10 @@ public partial class VIPERContext : DbContext public DbSet AaudStudents { get; set; } public DbSet StudentClassYears { get; set; } public DbSet ClassYearLeftReasons { get; set; } + public DbSet CareerOptions { get; set; } + public DbSet SpeciesOptions { get; set; } + public DbSet PostGradOptions { get; set; } + public DbSet CareerSelections { get; set; } partial void OnModelCreatingStudents(ModelBuilder modelBuilder) { @@ -30,5 +35,91 @@ partial void OnModelCreatingStudents(ModelBuilder modelBuilder) entity.ToTable("vwStudents", "students"); entity.HasKey(e => new { e.TermCode, e.SpridenId }); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.CareerOptionId); + entity.ToTable("CareerOption", "students"); + + entity.Property(e => e.CareerOptionId).HasColumnName("CareerOptionId"); + // IsUnicode(false) on every string column: these are varchar in the schema, and without + // it EF sends nvarchar parameters, which SQL Server resolves by converting the column + // rather than the parameter - turning index seeks into scans. + entity.Property(e => e.Career).HasColumnName("Career").HasMaxLength(100).IsUnicode(false); + entity.Property(e => e.IsOther).HasColumnName("IsOther"); + entity.HasIndex(e => e.Career) + .IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.SpeciesOptionId); + entity.ToTable("SpeciesOption", "students"); + + entity.Property(e => e.SpeciesOptionId).HasColumnName("SpeciesOptionId"); + entity.Property(e => e.Species).HasColumnName("Species").HasMaxLength(100).IsUnicode(false); + entity.Property(e => e.IsOther).HasColumnName("IsOther"); + entity.HasIndex(e => e.Species) + .IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PostGradOptionId); + entity.ToTable("PostGradOption", "students"); + + entity.Property(e => e.PostGradOptionId).HasColumnName("PostGradOptionId"); + entity.Property(e => e.PostGrad).HasColumnName("PostGrad").HasMaxLength(200).IsUnicode(false); + entity.Property(e => e.IsOther).HasColumnName("IsOther"); + entity.HasIndex(e => e.PostGrad) + .IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.CareerSelectionId); + entity.ToTable("CareerSelection", "students"); + + entity.Property(e => e.CareerSelectionId).HasColumnName("CareerSelectionId"); + entity.Property(e => e.Pidm).HasColumnName("Pidm"); + entity.Property(e => e.DateAdded).HasColumnName("DateAdded"); + entity.Property(e => e.DateModified).HasColumnName("DateModified"); + entity.Property(e => e.Career).HasColumnName("Career"); + // See the CareerOption block above for why every string column sets IsUnicode(false). + entity.Property(e => e.CareerOther).HasColumnName("CareerOther").HasMaxLength(200).IsUnicode(false); + entity.Property(e => e.FirstSpecies).HasColumnName("FirstSpecies"); + entity.Property(e => e.FirstSpeciesOther).HasColumnName("FirstSpeciesOther").HasMaxLength(200).IsUnicode(false); + entity.Property(e => e.SecondSpecies).HasColumnName("SecondSpecies"); + entity.Property(e => e.SecondSpeciesOther).HasColumnName("SecondSpeciesOther").HasMaxLength(200).IsUnicode(false); + entity.Property(e => e.PostGrad).HasColumnName("PostGrad"); + entity.Property(e => e.ShortTermStatement).HasColumnName("ShortTermStatement").HasMaxLength(5000).IsUnicode(false); + entity.Property(e => e.LongTermStatement).HasColumnName("LongTermStatement").HasMaxLength(5000).IsUnicode(false); + entity.Property(e => e.FacultyMothraId).HasColumnName("FacultyMothraId").HasMaxLength(8).IsUnicode(false); + // Restrict, not EF's default ClientSetNull for an optional key: deleting an option that + // is in use must fail, rather than silently clearing the answer from any selection + // that happens to be tracked at the time. + entity.HasOne(e => e.CareerOption) + .WithMany() + .HasForeignKey(e => e.Career) + .HasPrincipalKey(e => e.CareerOptionId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.FirstSpeciesOption) + .WithMany() + .HasForeignKey(e => e.FirstSpecies) + .HasPrincipalKey(e => e.SpeciesOptionId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.SecondSpeciesOption) + .WithMany() + .HasForeignKey(e => e.SecondSpecies) + .HasPrincipalKey(e => e.SpeciesOptionId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.PostGradOption) + .WithMany() + .HasForeignKey(e => e.PostGrad) + .HasPrincipalKey(e => e.PostGradOptionId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasIndex(e => e.Pidm) + .IsUnique(); + }); } } diff --git a/web/Classes/Utilities/CsvExportHelper.cs b/web/Classes/Utilities/CsvExportHelper.cs new file mode 100644 index 000000000..bd0c058b8 --- /dev/null +++ b/web/Classes/Utilities/CsvExportHelper.cs @@ -0,0 +1,46 @@ +using System.Text; + +namespace Viper.Classes.Utilities; + +/// +/// Builds RFC 4180 CSV for file exports. +/// +public static class CsvExportHelper +{ + /// + /// Writes a header row followed by one row per record, as UTF-8 with a byte order mark. + /// The BOM is what tells Excel the file is UTF-8; without it Excel falls back to the local + /// ANSI codepage and mangles any accented name. + /// + public static byte[] Build(IEnumerable headers, IEnumerable> rows) + { + var csv = new StringBuilder(); + csv.Append(Row(headers)); + foreach (var row in rows) + { + csv.Append(Row(row)); + } + + // GetBytes never writes the preamble itself, so the BOM is prepended here. + var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + return [.. utf8.GetPreamble(), .. utf8.GetBytes(csv.ToString())]; + } + + private static string Row(IEnumerable cells) => string.Join(",", cells.Select(Cell)) + "\r\n"; + + /// + /// Quotes every cell and doubles any quote inside it. Line breaks are left alone: inside the + /// quotes they are valid RFC 4180 and Excel reads them as a wrapped cell, which is what a + /// multi-line career statement should look like. + /// + /// The value goes through first. A CSV is opened + /// in Excel at least as often as it is parsed, so a student-entered value starting with =, +, + /// - or @ (even behind leading whitespace) would otherwise run as a formula (OWASP CWE-1236). + /// Quoting the cell does not prevent this; Excel still evaluates a quoted formula. + /// + private static string Cell(string? value) + { + var sanitized = ExcelHelper.SanitizeStringCell(value); + return $"\"{sanitized.Replace("\"", "\"\"")}\""; + } +} diff --git a/web/Classes/Utilities/DbUpdateExceptionExtensions.cs b/web/Classes/Utilities/DbUpdateExceptionExtensions.cs index 500fe0d80..e8a507196 100644 --- a/web/Classes/Utilities/DbUpdateExceptionExtensions.cs +++ b/web/Classes/Utilities/DbUpdateExceptionExtensions.cs @@ -34,4 +34,17 @@ public static bool IsDataRejection(this DbUpdateException ex) => /// public static bool IsDataRejectionNumber(int number) => number is 2601 or 2627 or 547 or 8152 or 2628; + + /// + /// True when the insert lost a race for a unique key (2601 or 2627), the one rejection a + /// caller can resolve by re-reading the row that won and saving over it. + /// + public static bool IsUniqueKeyViolation(this DbUpdateException ex) => + ex.InnerException is SqlException sqlException && IsUniqueKeyViolationNumber(sqlException.Number); + + /// + /// The number set on its own, split out for the same reason as . + /// + public static bool IsUniqueKeyViolationNumber(int number) => + number is 2601 or 2627; } diff --git a/web/Classes/Utilities/ExcelHelper.cs b/web/Classes/Utilities/ExcelHelper.cs index 8aac538b1..965957666 100644 --- a/web/Classes/Utilities/ExcelHelper.cs +++ b/web/Classes/Utilities/ExcelHelper.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using ClosedXML.Excel; namespace Viper.Classes.Utilities; @@ -19,12 +20,26 @@ public static partial class ExcelHelper { /// /// Prevent CSV/Excel formula injection (OWASP CWE-1236). - /// Prefixes strings starting with =, +, -, or @ with a leading apostrophe. + /// Prefixes a leading apostrophe when the first character that is not whitespace or a control + /// character is =, +, -, or @. Leading tabs, line breaks and spaces are skipped rather than + /// trusted, because some spreadsheet apps strip them on import and then evaluate the formula. /// public static string SanitizeStringCell(string? value) { if (string.IsNullOrEmpty(value)) return value ?? string.Empty; - return value[0] is '=' or '+' or '-' or '@' ? "'" + value : value; + var first = value.FirstOrDefault(c => !char.IsWhiteSpace(c) && !char.IsControl(c)); + return first is '=' or '+' or '-' or '@' ? "'" + value : value; + } + + /// + /// Save a workbook to a new stream, rewound so it can be returned as a file download. + /// + public static MemoryStream SaveToStream(XLWorkbook workbook) + { + var stream = new MemoryStream(); + workbook.SaveAs(stream); + stream.Position = 0; + return stream; } /// diff --git a/web/Classes/Utilities/PdfAccessibilityHelper.cs b/web/Classes/Utilities/PdfAccessibilityHelper.cs index 54b13db43..ec63a4f3f 100644 --- a/web/Classes/Utilities/PdfAccessibilityHelper.cs +++ b/web/Classes/Utilities/PdfAccessibilityHelper.cs @@ -53,4 +53,27 @@ public static Document WithAccessibility( PDFUA_Conformance = PDFUA_Conformance.PDFUA_1 }); } + + /// + /// Returns an em-dash placeholder when the value is null or empty so that + /// QuestPDF emits the cell as a tagged TD (whitespace-only and empty Text + /// elements are dropped from the structure tree, breaking PDF/UA clause 7.2). + /// + public static string OrDash(string? value) => + string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); + + /// + /// Render a centered "Page N of M" counter. It is marked as an artifact, since a + /// repeating page number is not content assistive tech should read. + /// + public static void PageNumberFooter(this IContainer container) + { + container.SemanticIgnore().AlignCenter().Text(x => + { + x.Span("Page "); + x.CurrentPageNumber(); + x.Span(" of "); + x.TotalPages(); + }); + } } diff --git a/web/Classes/Utilities/PersonSearchHelper.cs b/web/Classes/Utilities/PersonSearchHelper.cs index 1835e7190..0bab8b022 100644 --- a/web/Classes/Utilities/PersonSearchHelper.cs +++ b/web/Classes/Utilities/PersonSearchHelper.cs @@ -27,10 +27,13 @@ public static class PersonSearchHelper /// /// Builds the "Last, First" / "First Last" contains-match predicate for a search term. /// lastName/firstName must be plain property accessors (e.g. t => t.LastName). + /// The selectors accept nullable names so that database views can be searched directly; + /// EF coalesces a missing name to an empty string, so a row with only one name still + /// matches on the name it has. /// public static Expression> NameMatches( - Expression> lastName, - Expression> firstName, + Expression> lastName, + Expression> firstName, string search) { var param = Expression.Parameter(typeof(T), "p"); @@ -59,8 +62,8 @@ public static Expression> NameMatches( /// public static IQueryable OrderAndCap( IQueryable query, - Expression> lastName, - Expression> firstName) + Expression> lastName, + Expression> firstName) => query.OrderBy(lastName).ThenBy(firstName).Take(MaxResults); /// @@ -75,7 +78,7 @@ public static Expression> Or( return Expression.Lambda>(Expression.OrElse(predicate.Body, rebound), predicate.Parameters[0]); } - private static string PropertyName(Expression> selector) + private static string PropertyName(Expression> selector) { if (selector.Body is MemberExpression member) { diff --git a/web/Viper.csproj b/web/Viper.csproj index 6b671a5ea..c0f8d1e41 100644 --- a/web/Viper.csproj +++ b/web/Viper.csproj @@ -25,6 +25,10 @@ + + + + From 5f368ebd0c91ed1da0a61f8168ac3fb91ddaddfa Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Wed, 30 Sep 2026 15:11:33 -0700 Subject: [PATCH 2/5] VPR-62 feat(student): Handle mentor missing from AAUD --- test/Students/CareerSelectionServiceTests.cs | 51 ++++++++++++++- test/Students/TestableAAUDContext.cs | 6 ++ .../Scripts/CareerSelectionDataAnalysis.cs | 34 +++++----- .../Scripts/CareerSelectionScriptHelper.cs | 64 ++++++++----------- web/Areas/Students/Scripts/RunAnalysis.bat | 4 +- .../Services/CareerSelectionService.cs | 28 ++++++-- 6 files changed, 127 insertions(+), 60 deletions(-) diff --git a/test/Students/CareerSelectionServiceTests.cs b/test/Students/CareerSelectionServiceTests.cs index eea3ce659..5e43af23e 100644 --- a/test/Students/CareerSelectionServiceTests.cs +++ b/test/Students/CareerSelectionServiceTests.cs @@ -467,6 +467,43 @@ public async Task UpdateStudentCareerSelectionAsync_AdminSave_ClearsTheMentor() Assert.Null(saved.FacultyMothraId); } + [Fact] + public async Task UpdateStudentCareerSelectionAsync_AdminSave_KeepsAMentorWithNoAaudUser() + { + // The form cannot show a mentor it cannot resolve, so the null it sends back is not a + // decision to clear one. + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + var errors = await _service.UpdateStudentCareerSelectionAsync(100, new StudentCareerInfoDto + { + ShortTermPlans = "Internship", + MentorId = null, + }, isAdmin: true); + + Assert.Empty(errors); + var saved = await _viperContext.CareerSelections.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(MentorMothraId, saved.FacultyMothraId); + Assert.Equal("Internship", saved.ShortTermStatement); + } + + [Fact] + public async Task UpdateStudentCareerSelectionAsync_AdminSave_ReplacesAMentorWithNoAaudUser() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(501, OtherMentorMothraId, "Doc", "Bea", isCurrentAffiliate: true); + await SeedOptionsAsync(); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + var errors = await _service.UpdateStudentCareerSelectionAsync(100, + new StudentCareerInfoDto { MentorId = 501 }, isAdmin: true); + + Assert.Empty(errors); + var saved = await _viperContext.CareerSelections.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(OtherMentorMothraId, saved.FacultyMothraId); + } + #endregion #region CanEdit @@ -575,8 +612,20 @@ private async Task SeedStudentAsync(int personId, string mothraId, str return user; } - private async Task SeedMentorAsync(int personId, string mothraId, string lastName, string firstName) + private async Task SeedMentorAsync(int personId, string mothraId, string lastName, string firstName, + bool isCurrentAffiliate = false) { + if (isCurrentAffiliate) + { + _aaudContext.VwCurrentAffiliates.Add(new VwCurrentAffiliate + { + IdsMothraid = mothraId, + LastName = lastName, + FirstName = firstName, + MiddleName = "" + }); + } + _aaudContext.AaudUsers.Add(new AaudUser { AaudUserId = personId, diff --git a/test/Students/TestableAAUDContext.cs b/test/Students/TestableAAUDContext.cs index 452576e2a..998c13cbf 100644 --- a/test/Students/TestableAAUDContext.cs +++ b/test/Students/TestableAAUDContext.cs @@ -21,5 +21,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.IdsMothraId); entity.ToTable("VwDvmStudentsMaxTerm"); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.IdsMothraid); + entity.ToTable("VwCurrentAffiliate"); + }); } } diff --git a/web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs b/web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs index ba411854b..48b2e3151 100644 --- a/web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs +++ b/web/Areas/Students/Scripts/CareerSelectionDataAnalysis.cs @@ -17,7 +17,7 @@ public sealed record OverLengthValue(string Column, int CareerSelectionId, int L /// /// A facultyMothraID the new app would not accept, plus whether the student it belongs to is /// one an admin can actually open. A problem on a student who has left is inert history; one - /// on a current student is a record an admin will be blocked from saving. + /// on a current student is a record whose mentor an admin will have to deal with. /// public sealed record MentorProblem( int CareerSelectionId, string MothraId, string Problem, string ResolvedName, @@ -76,8 +76,8 @@ public class CareerAnalysisReport /// /// Read-only data-quality analysis for the SIS career selection -> students schema migration. - /// Connects to the legacy "SIS" database (read-only), "VIPER" (for users.Person and the - /// destination column widths) and "AAUD" (for the current-affiliate check), and reports every + /// Connects to the legacy "SIS" database (read-only), "VIPER" (for the destination column + /// widths) and "AAUD" (for aaudUser and the current-affiliate check), and reports every /// conflict/risk identified while planning the migration. Writes no data anywhere - this is /// the dry-run pass that precedes the real transform/apply script. /// @@ -228,10 +228,10 @@ public void RunFullAnalysis() WriteColoredCount(" Rows with a populated classYear", populatedClassYears.Sum(v => v.Count), isCritical: true); Console.WriteLine(); - Console.WriteLine("Resolving facultyMothraID against users.Person and vw_CurrentAffiliates..."); - AnalyzeMentors(legacyConn, viperConn, aaudConn); + Console.WriteLine("Resolving facultyMothraID against aaudUser and vw_CurrentAffiliates..."); + AnalyzeMentors(legacyConn, aaudConn); // Only the ones on a current student are actionable: those are records an admin can - // open, and the mentor picker will block the first save of each. + // open, where the mentor either shows as missing or blocks the first save. var activeMentorProblems = _report.MentorProblems.Count(m => m.StudentIsActive); WriteColoredCount(" On a current DVM student", activeMentorProblems, isCritical: true); WriteColoredCount(" On a student who has left (inert history)", @@ -581,13 +581,15 @@ private void AnalyzeClassYear(SqlConnection legacyConn) // Check 6: a blank or all-zero facultyMothraID migrates as NULL (no mentor), and any other // value migrates unchanged, so an unresolvable value is not a migration failure. It matters - // later: the admin mentor picker validates a submitted mentor against vw_CurrentAffiliates, - // so a mentor who is no longer an affiliate blocks the first save of that student's record. - // The two cases are reported separately. - private void AnalyzeMentors(SqlConnection legacyConn, SqlConnection viperConn, SqlConnection aaudConn) + // later, in two different ways. The app resolves a mentor through aaudUser, so one with no + // aaudUser row shows as no mentor at all, though admin saves keep it. One that resolves but + // is no longer an affiliate fails the picker's vw_CurrentAffiliates check, blocking the first + // admin save of that student's record until the mentor is replaced or cleared. The two are + // reported separately. + private void AnalyzeMentors(SqlConnection legacyConn, SqlConnection aaudConn) { - var mentorLookup = CareerSelectionScriptHelper.BuildMentorLookupMap(viperConn, aaudConn); - Console.WriteLine($" Loaded {mentorLookup.Count:N0} person/affiliate records."); + var mentorLookup = CareerSelectionScriptHelper.BuildMentorLookupMap(aaudConn); + Console.WriteLine($" Loaded {mentorLookup.Count:N0} aaudUser records."); var activePidms = CareerSelectionScriptHelper.LoadActiveStudentPidms(aaudConn); Console.WriteLine($" Loaded {activePidms.Count:N0} current DVM students."); @@ -617,7 +619,7 @@ FROM [dbo].[tb_CareerSelection] if (!mentorLookup.TryGetValue(mothraId, out var mentor)) { _report.MentorProblems.Add(new MentorProblem( - id, mothraId, "no users.Person row", "", rawPidm, isActive)); + id, mothraId, "no aaudUser row", "", rawPidm, isActive)); continue; } @@ -792,8 +794,10 @@ private string WriteTextReport() sb.AppendLine(); sb.AppendLine("== facultyMothraID problems =="); - sb.AppendLine(" NOTE: these migrate as-is. A mentor who is not a current affiliate blocks"); - sb.AppendLine(" the first admin save of that student's record, not the migration."); + sb.AppendLine(" NOTE: these migrate as-is. A mentor with no aaudUser row shows as no mentor"); + sb.AppendLine(" in the app; admin saves keep it, and can replace it but not clear it. A mentor"); + sb.AppendLine(" who is not a current affiliate blocks the first admin save of that student's"); + sb.AppendLine(" record until replaced or cleared. Neither blocks the migration."); sb.AppendLine(" Split by whether the student is in vw_DVM_Students_maxTerm, which is the"); sb.AppendLine(" roster the admin screens are built from - a problem on a student who has"); sb.AppendLine(" left is history nobody can reach, so only the current ones need fixing."); diff --git a/web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs b/web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs index 14cacfb58..d25acfdf4 100644 --- a/web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs +++ b/web/Areas/Students/Scripts/CareerSelectionScriptHelper.cs @@ -12,11 +12,11 @@ namespace Viper.Areas.Students.Scripts { /// - /// A person resolved from users.Person by legacy MothraId, plus whether AAUD still lists them + /// A person resolved from AAUD's aaudUser by legacy MothraId, plus whether AAUD still lists them /// as a current SVM affiliate. The mentor picker validates against vw_CurrentAffiliates, so a /// mentor who resolves but is no longer an affiliate migrates fine and then fails the first /// time an admin tries to save that student's record - which is worth reporting separately - /// from a MothraId that names nobody at all. + /// from a MothraId the app cannot resolve at all. /// public sealed record MentorLookup(string FullName, bool IsCurrentAffiliate); @@ -208,48 +208,40 @@ public static string ValidateOutputPath(string? outputPath, string defaultSubfol /// Builds a MothraId -> MentorLookup map for resolving tb_CareerSelection.facultyMothraID. /// Unlike the PhoneLists migration this does not resolve onto IamId - students.CareerSelection /// stores the MothraId itself - so the lookup exists only to report who no longer resolves. - /// The affiliate flag comes from AAUD's vw_CurrentAffiliates, which is what - /// CareerSelectionService.IsCurrentAffiliateAsync validates a submitted mentor against. + /// Mirrors how CareerSelectionService resolves a mentor: by MothraId against aaudUser, and + /// as a current affiliate only when that aaudUser row also joins to vw_CurrentAffiliates, + /// as in IsCurrentAffiliateAsync. An affiliate with no aaudUser row is left out of the map, + /// because the app cannot show, pick or keep them either. /// - public static Dictionary BuildMentorLookupMap( - SqlConnection viperConnection, SqlConnection aaudConnection) + public static Dictionary BuildMentorLookupMap(SqlConnection aaudConnection) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - const string personSql = @" - SELECT MothraId, FullName - FROM [users].[Person] - WHERE MothraId IS NOT NULL"; - - using (var cmd = new SqlCommand(personSql, viperConnection)) - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - var mothraId = reader.GetString(0).Trim(); - var fullName = reader.IsDBNull(1) ? "" : reader.GetString(1); - map[mothraId] = new MentorLookup(fullName, false); - } - } - - const string affiliateSql = @" - SELECT DISTINCT ids_mothraid - FROM [dbo].[vw_CurrentAffiliates] - WHERE ids_mothraid IS NOT NULL"; + // Named "Last, First" as the app displays a mentor, so the report reads the same. + const string sql = @" + SELECT u.mothraID, u.display_last_name, u.display_first_name, + CASE WHEN EXISTS ( + SELECT 1 FROM [dbo].[vw_CurrentAffiliates] a + WHERE a.ids_mothraid = u.mothraID) THEN 1 ELSE 0 END + FROM [dbo].[aaudUser] u + WHERE u.mothraID IS NOT NULL"; - using (var cmd = new SqlCommand(affiliateSql, aaudConnection)) - using (var reader = cmd.ExecuteReader()) + using var cmd = new SqlCommand(sql, aaudConnection); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) { - while (reader.Read()) + var mothraId = reader.GetString(0).Trim(); + var lastName = reader.IsDBNull(1) ? "" : reader.GetString(1); + var firstName = reader.IsDBNull(2) ? "" : reader.GetString(2); + var isCurrentAffiliate = reader.GetInt32(3) == 1; + + // Should a MothraId have more than one aaudUser row, reporting it as an affiliate + // if any row is keeps the result independent of the order rows come back in. + if (map.TryGetValue(mothraId, out var existing) && existing.IsCurrentAffiliate) { - var mothraId = reader.GetString(0).Trim(); - - // An affiliate with no users.Person row still counts as a current affiliate; - // record it so the two failure modes stay distinguishable in the report. - map[mothraId] = map.TryGetValue(mothraId, out var existing) - ? existing with { IsCurrentAffiliate = true } - : new MentorLookup("", true); + continue; } + map[mothraId] = new MentorLookup($"{lastName}, {firstName}", isCurrentAffiliate); } return map; diff --git a/web/Areas/Students/Scripts/RunAnalysis.bat b/web/Areas/Students/Scripts/RunAnalysis.bat index b33ace2c4..1842d3800 100644 --- a/web/Areas/Students/Scripts/RunAnalysis.bat +++ b/web/Areas/Students/Scripts/RunAnalysis.bat @@ -13,8 +13,8 @@ REM Reads only. The SIS connection is forced to ApplicationIntent=ReadOnly REM by the script, so this cannot write to the legacy student system. REM REM Needs three connection strings for the target environment, all of which -REM the web app already uses: SIS (legacy source), VIPER (destination and -REM users.Person) and AAUD (vw_CurrentAffiliates, for the mentor check). +REM the web app already uses: SIS (legacy source), VIPER (destination) and +REM AAUD (aaudUser and vw_CurrentAffiliates, for the mentor check). REM ================================================================ echo. diff --git a/web/Areas/Students/Services/CareerSelectionService.cs b/web/Areas/Students/Services/CareerSelectionService.cs index 7dfd0bc5d..d20fa8bb7 100644 --- a/web/Areas/Students/Services/CareerSelectionService.cs +++ b/web/Areas/Students/Services/CareerSelectionService.cs @@ -277,10 +277,15 @@ public async Task> UpdateStudentCareerSelectionAsync(int personId, // must leave whatever is already stored untouched. var mentorMothraId = isAdmin ? await ResolveMentorMothraIdAsync(request.MentorId) : null; - void ApplyTo(CareerSelection target) + // Asked of the row actually being saved, which after a lost insert race is not the one + // first loaded. + async Task KeepsStoredMentorAsync(CareerSelection target) + => isAdmin && request.MentorId == null && await IsUnresolvedMentorAsync(target.FacultyMothraId); + + void ApplyTo(CareerSelection target, bool keepStoredMentor) { CareerSelectionMapper.ApplyStudentInfoToEntity(request, target); - if (isAdmin) + if (isAdmin && !keepStoredMentor) { target.FacultyMothraId = mentorMothraId; } @@ -301,7 +306,7 @@ void ApplyTo(CareerSelection target) _viperContext.CareerSelections.Add(careerSelection); } - ApplyTo(careerSelection); + ApplyTo(careerSelection, await KeepsStoredMentorAsync(careerSelection)); try { @@ -323,7 +328,7 @@ void ApplyTo(CareerSelection target) throw; } - ApplyTo(stored); + ApplyTo(stored, await KeepsStoredMentorAsync(stored)); await _viperContext.SaveChangesAsync(); } @@ -431,7 +436,8 @@ private async Task ValidateSelectionAsync(CareerDropdownOption? selection, Caree => option == null ? null : new CareerDropdownOption { Label = option.Label, Value = option.Id, IsOther = option.IsOther }; /// - /// Resolves a mentor PersonId to the MothraId the table stores. A null id clears the mentor. + /// Resolves a mentor PersonId to the MothraId the table stores. A null id clears the mentor, + /// unless the stored one cannot be resolved (see ). /// private async Task ResolveMentorMothraIdAsync(int? mentorId) { @@ -446,6 +452,15 @@ private async Task ValidateSelectionAsync(CareerDropdownOption? selection, Caree .FirstOrDefaultAsync(); } + /// + /// Whether a stored mentor MothraId has no AaudUser row. The detail lookup cannot resolve + /// such a mentor, so the form shows no mentor and sends none back. This means it cannot + /// be cleared from the form, only overridden explicitly. + /// + private async Task IsUnresolvedMentorAsync(string? mentorMothraId) + => !string.IsNullOrWhiteSpace(mentorMothraId) + && !await _aaudContext.AaudUsers.AnyAsync(u => u.MothraId == mentorMothraId); + /// /// /// Which students to report on, on the same terms as @@ -662,7 +677,8 @@ private static bool TryGetCareerSelection(VwDvmStudentsMaxTerm student, /// Only current SVM affiliates may be recorded as a mentor. The picker offers nobody else, so /// a PersonId outside that set reached the API from something other than the form. /// If a current mentor becomes a former affiliate, they are no longer selectable as a mentor. - /// Student edits are unaffected, and admin edits will need to replace the old mentor. + /// Student edits are unaffected, and an admin save is refused until the old mentor is replaced + /// or cleared. /// private async Task IsCurrentAffiliateAsync(int personId) { From 2cdfbdc6a5c3c8865f6e87bfc904a37f2903b0c1 Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Thu, 1 Oct 2026 10:45:16 -0700 Subject: [PATCH 3/5] VPR-62 feat(student): Remove backend CSV code --- .../CareerSelectionControllerTests.cs | 70 +----------- test/Students/ExportServiceTests.cs | 102 ------------------ .../Controllers/CareerSelectionController.cs | 22 ---- .../Services/CareerSelectionExportService.cs | 44 -------- web/Classes/ApiController.cs | 10 -- web/Classes/Utilities/CsvExportHelper.cs | 46 -------- 6 files changed, 5 insertions(+), 289 deletions(-) delete mode 100644 web/Classes/Utilities/CsvExportHelper.cs diff --git a/test/Students/CareerSelectionControllerTests.cs b/test/Students/CareerSelectionControllerTests.cs index dccdcaebe..59a1db42d 100644 --- a/test/Students/CareerSelectionControllerTests.cs +++ b/test/Students/CareerSelectionControllerTests.cs @@ -630,68 +630,7 @@ public async Task ExportPdf_FacultyScope_ExportsOnlyTheirMentees() } [Fact] - public async Task ExportOverviewCsv_WithStudents_ReturnsACsvDownload() - { - var admin = CreateUser(1, "admin", "ADMIN001"); - _userHelper.GetCurrentUser().Returns(admin); - _service.ResolveScope(admin).Returns(CareerSelectionScope.All); - _service.GetStudentCareerListAsync(StudentListAccess.AllStudents) - .Returns([new StudentCareerListItemDto { PersonId = 5, FullName = "Student, Test" }]); - _exportService.GenerateOverviewCsv(Arg.Any>()).Returns([1, 2, 3]); - - var result = await _controller.ExportOverviewCsv(); - - var file = Assert.IsType(result); - Assert.Equal("text/csv", file.ContentType); - Assert.Equal("CareerSelectionOverview.csv", file.FileDownloadName); - } - - [Fact] - public async Task ExportCsv_WithStudents_ReturnsTheReportCsv() - { - var admin = CreateUser(1, "admin", "ADMIN001"); - _userHelper.GetCurrentUser().Returns(admin); - _service.ResolveScope(admin).Returns(CareerSelectionScope.All); - _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents) - .Returns([new StudentCareerReportDto { PersonId = 5, FullName = "Student, Test" }]); - _exportService.GenerateCsv(Arg.Any>()).Returns([1, 2, 3]); - - var result = await _controller.ExportCsv(); - - var file = Assert.IsType(result); - Assert.Equal("CareerSelection.csv", file.FileDownloadName); - } - - [Fact] - public async Task ExportCsv_WithoutListAccess_ReturnsForbid() - { - var student = CreateUser(100, "student", "STU001"); - _userHelper.GetCurrentUser().Returns(student); - _service.ResolveScope(student).Returns(CareerSelectionScope.Own); - - var result = await _controller.ExportCsv(); - - AssertForbidden(result); - await _service.DidNotReceive().GetStudentCareerReportAsync(Arg.Any()); - } - - [Fact] - public async Task ExportOverviewCsv_NoStudents_ReturnsAHeadersOnlyCsv() - { - var admin = CreateUser(1, "admin", "ADMIN001"); - _userHelper.GetCurrentUser().Returns(admin); - _service.ResolveScope(admin).Returns(CareerSelectionScope.All); - _service.GetStudentCareerListAsync(StudentListAccess.AllStudents).Returns([]); - _exportService.GenerateOverviewCsv(Arg.Any>()).Returns([1, 2, 3]); - - var result = await _controller.ExportOverviewCsv(); - - Assert.IsType(result); - _exportService.Received(1).GenerateOverviewCsv(Arg.Is>(d => d.Count == 0)); - } - - [Fact] - public async Task ExportCsv_WithRowKeys_ExportsThoseRowsInTheGridsOrder() + public async Task ExportExcel_WithRowKeys_ExportsThoseRowsInTheGridsOrder() { var admin = CreateUser(1, "admin", "ADMIN001"); _userHelper.GetCurrentUser().Returns(admin); @@ -702,11 +641,12 @@ public async Task ExportCsv_WithRowKeys_ExportsThoseRowsInTheGridsOrder() new StudentCareerReportDto { PersonId = 6, RowKey = "6" }, new StudentCareerReportDto { PersonId = 7, RowKey = "7" }, ]); - _exportService.GenerateCsv(Arg.Any>()).Returns([1, 2, 3]); + using var stream = new MemoryStream([1, 2, 3]); + _exportService.GenerateExcel(Arg.Any>()).Returns(stream); - await _controller.ExportCsv(new CareerSelectionExportRequest { RowKeys = ["7", "5"] }); + await _controller.ExportExcel(new CareerSelectionExportRequest { RowKeys = ["7", "5"] }); - _exportService.Received(1).GenerateCsv(Arg.Is>( + _exportService.Received(1).GenerateExcel(Arg.Is>( d => d.Select(r => r.PersonId).SequenceEqual(new[] { 7, 5 }))); } diff --git a/test/Students/ExportServiceTests.cs b/test/Students/ExportServiceTests.cs index 4c902ba07..4a43054e6 100644 --- a/test/Students/ExportServiceTests.cs +++ b/test/Students/ExportServiceTests.cs @@ -103,95 +103,6 @@ public void GeneratePdf_LongStatements_ProducesAPdf() AssertIsPdf(_careerExports.GeneratePdf(data)); } - [Fact] - public void GenerateOverviewCsv_WritesHeadersAndCompletenessRows() - { - var data = new List - { - new() - { - PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", Email = "tstudent@ucdavis.edu", - DirectionCompleted = true, SecondaryFocusCompleted = false, MentorName = "Vet, Ann", - } - }; - - var lines = ReadCsv(_careerExports.GenerateOverviewCsv(data)); - - Assert.StartsWith("\"Class\",\"Name\",\"Email\"", lines[0], StringComparison.Ordinal); - // Asserted as a whole row rather than by Contains: the overview reports whether each field - // is answered rather than what was chosen, and Species 2 - unanswered and optional - is - // blank. A Contains check for a blank field also matches the equally blank Last Updated - // column, so it would pass even if the optional field started reporting a miss. - Assert.Equal( - "\"V1\",\"Student, Test\",\"tstudent@ucdavis.edu\",\"Yes\",\"No\",\"\",\"No\",\"Vet, Ann\",\"No\",\"No\",\"\"", - lines[1]); - } - - [Fact] - public void GenerateCsv_WritesWholeStatements() - { - var statement = new string('x', 5000); - var data = new List - { - new() - { - PersonId = 100, FullName = "Student, Test", ClassLevel = "V1", - Direction = "Private Practice", ShortTermPlans = statement, - } - }; - - var csv = System.Text.Encoding.UTF8.GetString(_careerExports.GenerateCsv(data)); - - // The CSV carries the whole statement; only the PDF works from an excerpt. - Assert.Contains(statement, csv, StringComparison.Ordinal); - Assert.Contains("\"Private Practice\"", csv, StringComparison.Ordinal); - } - - [Fact] - public void GenerateCsv_QuotesAndCommasInAValue_StayInOneField() - { - var data = new List - { - new() { PersonId = 100, FullName = "Student, Test", ShortTermPlans = "She said \"hello\", twice" } - }; - - var csv = System.Text.Encoding.UTF8.GetString(_careerExports.GenerateCsv(data)); - - Assert.Contains("\"She said \"\"hello\"\", twice\"", csv, StringComparison.Ordinal); - } - - [Fact] - public void GenerateCsv_StatementStartingWithAnEquals_IsNotAFormula() - { - var data = new List - { - new() { PersonId = 100, FullName = "Student, Test", ShortTermPlans = "=1+1" } - }; - - var csv = System.Text.Encoding.UTF8.GetString(_careerExports.GenerateCsv(data)); - - // Student-entered text opened in Excel must not run as a formula (OWASP CWE-1236). - Assert.Contains("\"'=1+1\"", csv, StringComparison.Ordinal); - } - - [Fact] - public void GenerateCsv_NoStudents_StillWritesTheHeaderRow() - { - var lines = ReadCsv(_careerExports.GenerateCsv([])); - - Assert.Single(lines); - Assert.Contains("\"Last Updated\"", lines[0], StringComparison.Ordinal); - } - - [Fact] - public void GenerateCsv_IsUtf8WithABom() - { - var bytes = _careerExports.GenerateCsv([]); - - // Without the BOM Excel reads the file as the local codepage and mangles accented names. - Assert.Equal(System.Text.Encoding.UTF8.GetPreamble(), bytes.Take(3)); - } - [Fact] public void GenerateOverviewExcel_NoStudents_StillWritesTheHeaderRow() { @@ -202,15 +113,6 @@ public void GenerateOverviewExcel_NoStudents_StillWritesTheHeaderRow() Assert.Equal(string.Empty, sheet.Cell(3, 1).GetString()); } - [Fact] - public void GenerateOverviewCsv_NoStudents_StillWritesTheHeaderRow() - { - var lines = ReadCsv(_careerExports.GenerateOverviewCsv([])); - - Assert.Single(lines); - Assert.Contains("\"Last Updated\"", lines[0], StringComparison.Ordinal); - } - [Fact] public void GenerateOverviewPdf_NoStudents_ProducesAPdf() { @@ -297,10 +199,6 @@ public void GeneratePdf_ContactsWithNoDetails_ProducesAPdf() #endregion - /// The CSV rows, header first, with the BOM and the trailing newline dropped. - private static string[] ReadCsv(byte[] bytes) => - System.Text.Encoding.UTF8.GetString(bytes).Trim('\uFEFF').Trim().Split("\r\n"); - private static void AssertIsPdf(byte[] bytes) { Assert.NotEmpty(bytes); diff --git a/web/Areas/Students/Controllers/CareerSelectionController.cs b/web/Areas/Students/Controllers/CareerSelectionController.cs index 7db53a6d2..48dfe34b8 100644 --- a/web/Areas/Students/Controllers/CareerSelectionController.cs +++ b/web/Areas/Students/Controllers/CareerSelectionController.cs @@ -392,17 +392,6 @@ public Task ExportOverviewPdf( data => File(_exportService.GenerateOverviewPdf(data), "application/pdf", $"CareerSelectionOverview_{DateTime.Now:yyyyMMdd}.pdf")); - /// - /// Export the overview (completeness summary) as a CSV file. - /// - [HttpPost("export/overview/csv")] - [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] - public Task ExportOverviewCsv( - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( - request, - _service.GetStudentCareerListAsync, - data => CsvFile(_exportService.GenerateOverviewCsv(data), "CareerSelectionOverview")); - /// /// Export all career selections as an Excel file. /// @@ -427,17 +416,6 @@ public Task ExportPdf( data => File(_exportService.GeneratePdf(data), "application/pdf", $"CareerSelection_{DateTime.Now:yyyyMMdd}.pdf")); - /// - /// Export all career selections as a CSV file. - /// - [HttpPost("export/csv")] - [Permission(Allow = CareerSelectionPermissions.StudentListViewers)] - public Task ExportCsv( - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CareerSelectionExportRequest? request = null) => ExportAsync( - request, - _service.GetStudentCareerReportAsync, - data => CsvFile(_exportService.GenerateCsv(data), "CareerSelection")); - /// /// Runs an export over the students the caller may see: a faculty mentor's export is narrowed /// to their mentees, the same as the roster it mirrors, and then to the grid's rows when the diff --git a/web/Areas/Students/Services/CareerSelectionExportService.cs b/web/Areas/Students/Services/CareerSelectionExportService.cs index 1798374a4..bd0470a89 100644 --- a/web/Areas/Students/Services/CareerSelectionExportService.cs +++ b/web/Areas/Students/Services/CareerSelectionExportService.cs @@ -26,12 +26,6 @@ public interface ICareerSelectionExportService /// byte[] GenerateOverviewPdf(List data); - /// - /// Generate the overview CSV: the same rows as the overview workbook, for a reader who wants - /// the data rather than the formatting. - /// - byte[] GenerateOverviewCsv(List data); - /// /// Generate the full-detail Excel workbook — one row per student with the /// selected value for each career selection field. @@ -43,12 +37,6 @@ public interface ICareerSelectionExportService /// value for each career selection field. /// byte[] GeneratePdf(List data); - - /// - /// Generate the full-detail CSV: the same rows as the report workbook, whole statements - /// included. - /// - byte[] GenerateCsv(List data); } public class CareerSelectionExportService : ICareerSelectionExportService @@ -148,22 +136,6 @@ public byte[] GenerateOverviewPdf(List data) => } }); - public byte[] GenerateOverviewCsv(List data) => - CsvExportHelper.Build(ColumnHeaders, data.Select(d => new[] - { - d.ClassLevel, - d.FullName, - d.Email, - CompletenessLabel(d.DirectionCompleted), - CompletenessLabel(d.PrimaryFocusCompleted), - OptionalCompletenessLabel(d.SecondaryFocusCompleted), - CompletenessLabel(d.PostGradCompleted), - d.MentorName ?? string.Empty, - CompletenessLabel(d.ShortTermPlansCompleted), - CompletenessLabel(d.LongTermPlansCompleted), - d.LastUpdated?.ToString("M/d/yyyy") ?? string.Empty, - })); - public MemoryStream GenerateExcel(List data) { using var wb = new XLWorkbook(); @@ -241,22 +213,6 @@ public byte[] GeneratePdf(List data) => } }); - public byte[] GenerateCsv(List data) => - CsvExportHelper.Build(ColumnHeaders, data.Select(d => new[] - { - d.ClassLevel, - d.FullName, - d.Email, - d.Direction, - d.PrimaryFocus, - d.SecondaryFocus, - d.PostGrad, - d.MentorName, - d.ShortTermPlans, - d.LongTermPlans, - d.LastUpdated?.ToString("M/d/yyyy") ?? string.Empty, - })); - private static void PdfHeaderRow(TableDescriptor table) { var hdrStyle = TextStyle.Default.FontSize(8).SemiBold(); diff --git a/web/Classes/ApiController.cs b/web/Classes/ApiController.cs index e571a4583..d5b8194c8 100644 --- a/web/Classes/ApiController.cs +++ b/web/Classes/ApiController.cs @@ -86,15 +86,6 @@ protected FileStreamResult ExcelFile(MemoryStream stream, string reportName) return ExcelFile(stream, new ExportFilenameOptions { ReportName = reportName }); } - /// - /// Returns a CSV as a download, named by . - /// - protected FileContentResult CsvFile(byte[] bytes, string reportName) - { - return File(bytes, CsvContentType, - ExcelHelper.BuildExportFilename(new ExportFilenameOptions { ReportName = reportName, Extension = ".csv" })); - } - /// /// Builds an export from , or returns 204 No Content when there is /// nothing to export so the client can tell the user rather than hand them an empty file. @@ -105,6 +96,5 @@ protected ActionResult FileOrNoContent(List data, Func, ActionResu } private const string ExcelContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - private const string CsvContentType = "text/csv"; } } diff --git a/web/Classes/Utilities/CsvExportHelper.cs b/web/Classes/Utilities/CsvExportHelper.cs deleted file mode 100644 index bd0c058b8..000000000 --- a/web/Classes/Utilities/CsvExportHelper.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text; - -namespace Viper.Classes.Utilities; - -/// -/// Builds RFC 4180 CSV for file exports. -/// -public static class CsvExportHelper -{ - /// - /// Writes a header row followed by one row per record, as UTF-8 with a byte order mark. - /// The BOM is what tells Excel the file is UTF-8; without it Excel falls back to the local - /// ANSI codepage and mangles any accented name. - /// - public static byte[] Build(IEnumerable headers, IEnumerable> rows) - { - var csv = new StringBuilder(); - csv.Append(Row(headers)); - foreach (var row in rows) - { - csv.Append(Row(row)); - } - - // GetBytes never writes the preamble itself, so the BOM is prepended here. - var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); - return [.. utf8.GetPreamble(), .. utf8.GetBytes(csv.ToString())]; - } - - private static string Row(IEnumerable cells) => string.Join(",", cells.Select(Cell)) + "\r\n"; - - /// - /// Quotes every cell and doubles any quote inside it. Line breaks are left alone: inside the - /// quotes they are valid RFC 4180 and Excel reads them as a wrapped cell, which is what a - /// multi-line career statement should look like. - /// - /// The value goes through first. A CSV is opened - /// in Excel at least as often as it is parsed, so a student-entered value starting with =, +, - /// - or @ (even behind leading whitespace) would otherwise run as a formula (OWASP CWE-1236). - /// Quoting the cell does not prevent this; Excel still evaluates a quoted formula. - /// - private static string Cell(string? value) - { - var sanitized = ExcelHelper.SanitizeStringCell(value); - return $"\"{sanitized.Replace("\"", "\"\"")}\""; - } -} From c048d25741882147a9029270cdb9fbaab4f9b460 Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Thu, 1 Oct 2026 15:12:23 -0700 Subject: [PATCH 4/5] VPR-62 feat(student): Make backend code review changes --- .../CareerSelectionControllerTests.cs | 16 ++++- test/Students/CareerSelectionMapperTests.cs | 50 ++++++++++++++- test/Students/CareerSelectionServiceTests.cs | 50 ++++++++++++++- test/Students/DvmStudentLookupServiceTests.cs | 64 ++++++++++++++++++- .../Controllers/CareerSelectionController.cs | 2 +- .../Students/Models/CareerSelectionMapper.cs | 24 +++++++ .../Services/CareerSelectionService.cs | 41 +++--------- .../Services/DvmStudentLookupService.cs | 34 ++++++++-- .../Services/ICareerSelectionService.cs | 3 + .../Services/IDvmStudentLookupService.cs | 8 +++ .../Services/StudentNotFoundException.cs | 11 ++++ 11 files changed, 259 insertions(+), 44 deletions(-) create mode 100644 web/Areas/Students/Services/StudentNotFoundException.cs diff --git a/test/Students/CareerSelectionControllerTests.cs b/test/Students/CareerSelectionControllerTests.cs index 59a1db42d..e712622a7 100644 --- a/test/Students/CareerSelectionControllerTests.cs +++ b/test/Students/CareerSelectionControllerTests.cs @@ -304,13 +304,27 @@ public async Task UpdateStudentCareerSelection_StudentNoLongerCurrent_ReturnsNot var admin = GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); _service.CanEdit(100, admin).Returns(true); _service.UpdateStudentCareerSelectionAsync(100, Arg.Any(), true) - .ThrowsAsync(new InvalidOperationException("PersonId 100 is not a current DVM student")); + .ThrowsAsync(new StudentNotFoundException("PersonId 100 is not a current DVM student")); var result = await _controller.UpdateStudentCareerSelection(100, new StudentCareerInfoDto()); Assert.IsType(result.Result); } + [Fact] + public async Task UpdateStudentCareerSelection_OtherInvalidOperation_Propagates() + { + // An EF Core failure of the same base type is a defect, not a missing student, so it + // must reach the 500 path rather than be read as a 404. + var admin = GrantPermission(CreateUser(1, "admin", "ADMIN001"), CareerSelectionPermissions.Admin); + _service.CanEdit(100, admin).Returns(true); + _service.UpdateStudentCareerSelectionAsync(100, Arg.Any(), true) + .ThrowsAsync(new InvalidOperationException("A second operation was started on this context")); + + await Assert.ThrowsAsync( + () => _controller.UpdateStudentCareerSelection(100, new StudentCareerInfoDto())); + } + [Fact] public async Task UpdateStudentCareerSelection_Saved_ReturnsRefreshedDetail() { diff --git a/test/Students/CareerSelectionMapperTests.cs b/test/Students/CareerSelectionMapperTests.cs index 9be156fae..9349c2f40 100644 --- a/test/Students/CareerSelectionMapperTests.cs +++ b/test/Students/CareerSelectionMapperTests.cs @@ -5,7 +5,8 @@ namespace Viper.test.Students; /// /// Tests for CareerSelectionMapper: how a submitted form becomes the stored row, in particular -/// which free text survives and what an unfilled field is stored as. +/// which free text survives and what an unfilled field is stored as, and how the stored row +/// becomes the form's answers again. /// public class CareerSelectionMapperTests { @@ -117,6 +118,53 @@ public void ApplyStudentInfoToEntity_LeavesTheMentorAlone() Assert.Equal("FAC00001", entity.FacultyMothraId); } + [Fact] + public void ToStudentInfo_ReadsTheOptionsAndTextBack() + { + var entity = NewEntity(); + entity.CareerOption = new CareerOption { CareerOptionId = 9, Career = "Other", IsOther = true }; + entity.CareerOther = "Wildlife rehabilitation"; + entity.FirstSpeciesOption = new SpeciesOption { SpeciesOptionId = 2, Species = "Equine" }; + entity.SecondSpeciesOption = new SpeciesOption { SpeciesOptionId = 3, Species = "Bovine" }; + entity.PostGradOption = new PostGradOption { PostGradOptionId = 4, PostGrad = "Residency" }; + entity.ShortTermStatement = "Internship"; + entity.LongTermStatement = "Practice ownership"; + + var info = CareerSelectionMapper.ToStudentInfo(entity); + + Assert.Equivalent(Option("Other", 9, isOther: true), info.Direction, strict: true); + Assert.Equal("Wildlife rehabilitation", info.DirectionOther); + Assert.Equivalent(Option("Equine", 2), info.PrimaryFocus, strict: true); + Assert.Equivalent(Option("Bovine", 3), info.SecondaryFocus, strict: true); + Assert.Equivalent(Option("Residency", 4), info.PostGrad, strict: true); + Assert.Equal("Internship", info.ShortTermPlans); + Assert.Equal("Practice ownership", info.LongTermPlans); + } + + [Fact] + public void ToStudentInfo_NothingSelected_LeavesTheDropdownsEmpty() + { + var info = CareerSelectionMapper.ToStudentInfo(NewEntity()); + + Assert.Null(info.Direction); + Assert.Null(info.PrimaryFocus); + Assert.Null(info.SecondaryFocus); + Assert.Null(info.PostGrad); + } + + [Fact] + public void ToStudentInfo_LeavesTheMentorUnset() + { + // The row stores a MothraId and the form works in PersonIds; the service resolves it. + var entity = NewEntity(); + entity.FacultyMothraId = "FAC00001"; + + var info = CareerSelectionMapper.ToStudentInfo(entity); + + Assert.Null(info.MentorId); + Assert.Null(info.MentorName); + } + private static CareerSelection NewEntity() => new() { Pidm = 20000001, DateAdded = DateTime.Now }; private static CareerDropdownOption Option(string label, int value, bool isOther = false) => diff --git a/test/Students/CareerSelectionServiceTests.cs b/test/Students/CareerSelectionServiceTests.cs index 5e43af23e..3ebf023d1 100644 --- a/test/Students/CareerSelectionServiceTests.cs +++ b/test/Students/CareerSelectionServiceTests.cs @@ -225,6 +225,33 @@ public async Task GetStudentCareerListAsync_ResolvesMentorIdentity() Assert.Equal("Vet, Ann", student.MentorName); } + [Fact] + public async Task GetStudentCareerListAsync_MentorWithTwoAaudUserRows_ShowsTheCurrentOne() + { + // The MothraId index on aaudUser is not unique; a second row must not fail the roster. + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(500, MentorMothraId, "Former", "Name"); + await SeedMentorAsync(501, MentorMothraId, "Vet", "Ann", current: 1); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + var student = Assert.Single(await _service.GetStudentCareerListAsync(StudentListAccess.AllStudents)); + + Assert.Equal("Vet, Ann", student.MentorName); + } + + [Fact] + public async Task GetStudentCareerReportAsync_MentorWithTwoAaudUserRows_ShowsTheCurrentOne() + { + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(500, MentorMothraId, "Former", "Name"); + await SeedMentorAsync(501, MentorMothraId, "Vet", "Ann", current: 1); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + var student = Assert.Single(await _service.GetStudentCareerReportAsync(StudentListAccess.AllStudents)); + + Assert.Equal("Vet, Ann", student.MentorName); + } + [Fact] public async Task GetStudentCareerListAsync_StudentWithNoAaudUser_IsListedWithoutARoute() { @@ -390,6 +417,22 @@ await SeedSelectionAsync("20000001", s => Assert.Equal("Vet, Ann", detail.StudentInfo.MentorName); } + [Fact] + public async Task GetStudentCareerDetailAsync_MentorWithTwoAaudUserRows_ShowsTheCurrentOne() + { + // Must pick the same row the list and report do, or the pages disagree on the mentor. + await SeedStudentAsync(100, "STU00001", pidm: "20000001"); + await SeedMentorAsync(500, MentorMothraId, "Former", "Name"); + await SeedMentorAsync(501, MentorMothraId, "Vet", "Ann", current: 1); + await SeedSelectionAsync("20000001", s => s.FacultyMothraId = MentorMothraId); + + var detail = await _service.GetStudentCareerDetailAsync(100, canEdit: false, canViewStudentList: true); + + Assert.NotNull(detail); + Assert.Equal(501, detail.StudentInfo.MentorId); + Assert.Equal("Vet, Ann", detail.StudentInfo.MentorName); + } + #endregion #region UpdateStudentCareerSelectionAsync @@ -397,7 +440,7 @@ await SeedSelectionAsync("20000001", s => [Fact] public async Task UpdateStudentCareerSelectionAsync_NotACurrentStudent_Throws() { - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => _service.UpdateStudentCareerSelectionAsync(999, new StudentCareerInfoDto(), isAdmin: true)); } @@ -613,7 +656,7 @@ private async Task SeedStudentAsync(int personId, string mothraId, str } private async Task SeedMentorAsync(int personId, string mothraId, string lastName, string firstName, - bool isCurrentAffiliate = false) + bool isCurrentAffiliate = false, int current = 0) { if (isCurrentAffiliate) { @@ -637,7 +680,8 @@ private async Task SeedMentorAsync(int personId, string mothraId, string lastNam DisplayFirstName = firstName, DisplayLastName = lastName, LastName = lastName, - FirstName = firstName + FirstName = firstName, + Current = current }); await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); } diff --git a/test/Students/DvmStudentLookupServiceTests.cs b/test/Students/DvmStudentLookupServiceTests.cs index d72a3de53..665db3ef2 100644 --- a/test/Students/DvmStudentLookupServiceTests.cs +++ b/test/Students/DvmStudentLookupServiceTests.cs @@ -68,6 +68,64 @@ public async Task LoadDvmStudentsAsync_IgnoresNonStudentUsers() Assert.Single(mothraToPersonId); } + [Fact] + public async Task LoadDvmStudentsAsync_StudentWithTwoAaudUserRows_MapsToTheCurrentOne() + { + // The MothraId index on aaudUser is not unique; a second row must not fail the roster. + await SeedStudentAsync("STU00001", 100, pidm: "20000001"); + _aaudContext.AaudUsers.Add(NewUser("STU00001", 101, current: 1)); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var (_, mothraToPersonId) = await _service.LoadDvmStudentsAsync(); + + Assert.Equal(101, Assert.Single(mothraToPersonId).Value); + } + + #endregion + + #region LoadAaudUsersByMothraIdAsync + + [Fact] + public async Task LoadAaudUsersByMothraIdAsync_ReadsNameAsLastCommaFirst() + { + _aaudContext.AaudUsers.Add(NewUser("FAC00001", 500, lastName: "Vet", firstName: "Ann")); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var users = await _service.LoadAaudUsersByMothraIdAsync(["FAC00001"]); + + Assert.Equal(new AaudPersonIdentity("FAC00001", 500, "Vet, Ann"), users["FAC00001"]); + } + + [Fact] + public async Task LoadAaudUsersByMothraIdAsync_TwoRowsNeitherCurrent_PicksTheLowestId() + { + _aaudContext.AaudUsers.AddRange(NewUser("FAC00001", 502), NewUser("FAC00001", 501)); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var users = await _service.LoadAaudUsersByMothraIdAsync(["FAC00001"]); + + Assert.Equal(501, users["FAC00001"].PersonId); + } + + [Fact] + public async Task LoadAaudUsersByMothraIdAsync_CurrentRowWinsOverALowerId() + { + _aaudContext.AaudUsers.AddRange(NewUser("FAC00001", 501), NewUser("FAC00001", 502, current: 1)); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var users = await _service.LoadAaudUsersByMothraIdAsync(["FAC00001"]); + + Assert.Equal(502, users["FAC00001"].PersonId); + } + + [Fact] + public async Task LoadAaudUsersByMothraIdAsync_UnknownMothraId_IsAbsent() + { + var users = await _service.LoadAaudUsersByMothraIdAsync(["NOBODY01"]); + + Assert.Empty(users); + } + #endregion #region GetDvmStudentAsync @@ -192,7 +250,8 @@ private async Task SeedStudentAsync(string mothraId, int? personId, string pidm, await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); } - private static AaudUser NewUser(string mothraId, int personId, string lastName = "Person", string firstName = "Test") => + private static AaudUser NewUser(string mothraId, int personId, string lastName = "Person", string firstName = "Test", + int current = 0) => new() { AaudUserId = personId, @@ -203,6 +262,7 @@ private static AaudUser NewUser(string mothraId, int personId, string lastName = DisplayFirstName = firstName, DisplayLastName = lastName, LastName = lastName, - FirstName = firstName + FirstName = firstName, + Current = current }; } diff --git a/web/Areas/Students/Controllers/CareerSelectionController.cs b/web/Areas/Students/Controllers/CareerSelectionController.cs index 48dfe34b8..eb9f59f75 100644 --- a/web/Areas/Students/Controllers/CareerSelectionController.cs +++ b/web/Areas/Students/Controllers/CareerSelectionController.cs @@ -284,7 +284,7 @@ public async Task> UpdateStudentCareerSelec return ValidationProblem(ModelState); } } - catch (InvalidOperationException ex) + catch (StudentNotFoundException ex) { _logger.LogWarning(ex, "Cannot update career selection for PersonId {PersonId}: {Message}", diff --git a/web/Areas/Students/Models/CareerSelectionMapper.cs b/web/Areas/Students/Models/CareerSelectionMapper.cs index c177364fe..7c52e81b8 100644 --- a/web/Areas/Students/Models/CareerSelectionMapper.cs +++ b/web/Areas/Students/Models/CareerSelectionMapper.cs @@ -30,6 +30,30 @@ public static void ApplyStudentInfoToEntity(StudentCareerInfoDto source, CareerS target.LongTermStatement = Normalize(source.LongTermPlans); } + /// + /// The stored row as the form's answers. The option navigations must be loaded, or every + /// dropdown reads as unanswered. The mentor is left unset, since the row stores a MothraId + /// and the form works in PersonIds, so the service resolves it. + /// + public static StudentCareerInfoDto ToStudentInfo(CareerSelection source) => new() + { + Direction = ToDropdownOption(source.CareerOption), + DirectionOther = source.CareerOther, + PrimaryFocus = ToDropdownOption(source.FirstSpeciesOption), + PrimaryFocusOther = source.FirstSpeciesOther, + SecondaryFocus = ToDropdownOption(source.SecondSpeciesOption), + SecondaryFocusOther = source.SecondSpeciesOther, + PostGrad = ToDropdownOption(source.PostGradOption), + ShortTermPlans = source.ShortTermStatement, + LongTermPlans = source.LongTermStatement, + }; + + /// + /// A stored option as the form's dropdown value, or null when nothing is selected. + /// + private static CareerDropdownOption? ToDropdownOption(ICareerSelectionOption? option) + => option == null ? null : new CareerDropdownOption { Label = option.Label, Value = option.Id, IsOther = option.IsOther }; + /// /// The free text means something only while the catch-all option is selected. Anything else /// clears it, so a stale value cannot reappear when the student switches back to "Other". diff --git a/web/Areas/Students/Services/CareerSelectionService.cs b/web/Areas/Students/Services/CareerSelectionService.cs index d20fa8bb7..9c4a714af 100644 --- a/web/Areas/Students/Services/CareerSelectionService.cs +++ b/web/Areas/Students/Services/CareerSelectionService.cs @@ -207,15 +207,7 @@ public async Task> GetStudentCareerListAsync(Stud if (career != null) { - dto.StudentInfo.Direction = ToDropdownOption(career.CareerOption); - dto.StudentInfo.DirectionOther = career.CareerOther; - dto.StudentInfo.PrimaryFocus = ToDropdownOption(career.FirstSpeciesOption); - dto.StudentInfo.PrimaryFocusOther = career.FirstSpeciesOther; - dto.StudentInfo.SecondaryFocus = ToDropdownOption(career.SecondSpeciesOption); - dto.StudentInfo.SecondaryFocusOther = career.SecondSpeciesOther; - dto.StudentInfo.PostGrad = ToDropdownOption(career.PostGradOption); - dto.StudentInfo.ShortTermPlans = career.ShortTermStatement; - dto.StudentInfo.LongTermPlans = career.LongTermStatement; + dto.StudentInfo = CareerSelectionMapper.ToStudentInfo(career); dto.LastUpdated = career.DateModified ?? career.DateAdded; // The mentor is stored as a MothraId; the client works in PersonIds. @@ -223,6 +215,10 @@ public async Task> GetStudentCareerListAsync(Stud { var mentor = await _aaudContext.AaudUsers .Where(u => u.MothraId == career.FacultyMothraId) + // The same row LoadAaudUsersByMothraIdAsync picks for the list and report + // when a MothraId has more than one aaudUser row. + .OrderByDescending(u => u.Current) + .ThenBy(u => u.AaudUserId) // "Last, First" rather than the stored DisplayFullName, so the mentor reads // the same here, in the picker, and in the list and report. .Select(u => new { u.AaudUserId, u.IamId, FullName = u.DisplayLastName + ", " + u.DisplayFirstName }) @@ -245,13 +241,13 @@ public async Task> UpdateStudentCareerSelectionAsync(int personId, { if (!await _dvmStudentLookup.IsCurrentDvmStudentAsync(personId)) { - throw new InvalidOperationException($"PersonId {personId} is not a current DVM student"); + throw new StudentNotFoundException($"PersonId {personId} is not a current DVM student"); } var pidm = await _dvmStudentLookup.GetCurrentDvmPidmAsync(personId); if (pidm == null) { - throw new InvalidOperationException($"No PIDM found for PersonId {personId}"); + throw new StudentNotFoundException($"No PIDM found for PersonId {personId}"); } var studentPidm = pidm.Value; @@ -429,12 +425,6 @@ private async Task ValidateSelectionAsync(CareerDropdownOption? selection, Caree _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown career option type."), }; - /// - /// A stored option as the form's dropdown value, or null when nothing is selected. - /// - private static CareerDropdownOption? ToDropdownOption(ICareerSelectionOption? option) - => option == null ? null : new CareerDropdownOption { Label = option.Label, Value = option.Id, IsOther = option.IsOther }; - /// /// Resolves a mentor PersonId to the MothraId the table stores. A null id clears the mentor, /// unless the stored one cannot be resolved (see ). @@ -613,7 +603,7 @@ public Task IsAppOpenAsync() => /// MothraId the career selection stores. Names are read through rather than copied, so a /// mentor who changes their name reads correctly everywhere without a data fix. /// - private async Task> LoadMentorsByMothraIdAsync( + private Task> LoadMentorsByMothraIdAsync( IEnumerable careerSelections) { var mentorMothraIds = careerSelections @@ -623,22 +613,9 @@ private async Task> LoadMentorsByMothraIdAsyn .Distinct() .ToList(); - if (mentorMothraIds.Count == 0) - { - return []; - } - - return await _aaudContext.AaudUsers - .Where(u => EF.Parameter(mentorMothraIds).Contains(u.MothraId)) - .Select(u => new MentorIdentity( - u.MothraId, - u.DisplayLastName + ", " + u.DisplayFirstName)) - .AsNoTracking() - .ToDictionaryAsync(m => m.MothraId); + return _dvmStudentLookup.LoadAaudUsersByMothraIdAsync(mentorMothraIds); } - private sealed record MentorIdentity(string MothraId, string FullName); - /// /// Starts a roster row with the student's identity columns. A student with no AaudUser /// mapping is still listed, with PersonId 0 and no detail route. diff --git a/web/Areas/Students/Services/DvmStudentLookupService.cs b/web/Areas/Students/Services/DvmStudentLookupService.cs index 0bbda5746..650d6b24a 100644 --- a/web/Areas/Students/Services/DvmStudentLookupService.cs +++ b/web/Areas/Students/Services/DvmStudentLookupService.cs @@ -33,13 +33,37 @@ public DvmStudentLookupService(AAUDContext aaudContext) .Select(s => s.IdsMothraId) .Distinct() .ToList(); - var mothraToPersonId = await _aaudContext.AaudUsers + var users = await LoadAaudUsersByMothraIdAsync(mothraIds); + var mothraToPersonId = users.ToDictionary(u => u.Key, u => u.Value.PersonId); + + return (dvmStudents, mothraToPersonId); + } + + /// + /// One AaudUser per MothraId, for the MothraIds given. A MothraId with no AaudUser row is + /// absent from the map. + /// + public async Task> LoadAaudUsersByMothraIdAsync(List mothraIds) + { + if (mothraIds.Count == 0) + { + return []; + } + + var users = await _aaudContext.AaudUsers .Where(u => EF.Parameter(mothraIds).Contains(u.MothraId)) - .Select(u => new { u.MothraId, u.AaudUserId }) + // "Last, First" rather than the stored DisplayFullName, so a person reads the same in + // the roster, the report and the mentor picker. + .Select(u => new { u.MothraId, u.AaudUserId, u.Current, FullName = u.DisplayLastName + ", " + u.DisplayFirstName }) .AsNoTracking() - .ToDictionaryAsync(u => u.MothraId, u => u.AaudUserId); + .ToListAsync(); - return (dvmStudents, mothraToPersonId); + // The MothraId index on aaudUser is not unique, so one person can have more than one row. + // Prefer the current row, then the lowest id, so every load picks the same one. + return users + .GroupBy(u => u.MothraId) + .Select(g => g.OrderByDescending(u => u.Current).ThenBy(u => u.AaudUserId).First()) + .ToDictionary(u => u.MothraId, u => new AaudPersonIdentity(u.MothraId, u.AaudUserId, u.FullName)); } /// @@ -124,3 +148,5 @@ public static string FormatEmail(string? mailId) } public sealed record DvmStudentIdentity(int PersonId, string FullName, string ClassLevel, int? Pidm); + +public sealed record AaudPersonIdentity(string MothraId, int PersonId, string FullName); diff --git a/web/Areas/Students/Services/ICareerSelectionService.cs b/web/Areas/Students/Services/ICareerSelectionService.cs index 1abc578b9..f6fece3ca 100644 --- a/web/Areas/Students/Services/ICareerSelectionService.cs +++ b/web/Areas/Students/Services/ICareerSelectionService.cs @@ -56,6 +56,9 @@ public interface ICareerSelectionService /// the save, or an empty list once it is saved. Only an admin save may change the mentor; a /// student's own save leaves whatever is stored untouched. /// + /// + /// The person is not a current DVM student, or has no PIDM. + /// Task> UpdateStudentCareerSelectionAsync(int personId, StudentCareerInfoDto request, bool isAdmin); /// diff --git a/web/Areas/Students/Services/IDvmStudentLookupService.cs b/web/Areas/Students/Services/IDvmStudentLookupService.cs index 446cc4c0f..46307a283 100644 --- a/web/Areas/Students/Services/IDvmStudentLookupService.cs +++ b/web/Areas/Students/Services/IDvmStudentLookupService.cs @@ -19,6 +19,14 @@ public interface IDvmStudentLookupService /// Task<(List DvmStudents, Dictionary MothraToPersonId)> LoadDvmStudentsAsync(); + /// + /// One AaudUser per MothraId, for the MothraIds given, whether or not they are students. The + /// MothraId index on aaudUser is not unique, so where a person has more than one row the + /// current one wins, then the lowest id. Anything keyed on MothraId goes through here so every + /// page picks the same row. A MothraId with no AaudUser row is absent from the map. + /// + Task> LoadAaudUsersByMothraIdAsync(List mothraIds); + /// /// The name, class level and PIDM of one current DVM student, or null if the person is not one. /// diff --git a/web/Areas/Students/Services/StudentNotFoundException.cs b/web/Areas/Students/Services/StudentNotFoundException.cs new file mode 100644 index 000000000..f1d655f8a --- /dev/null +++ b/web/Areas/Students/Services/StudentNotFoundException.cs @@ -0,0 +1,11 @@ +namespace Viper.Areas.Students.Services; + +/// +/// Thrown when a person is not a current DVM student, or is one with no PIDM to key a record on. +/// Controllers translate this to 404 Not Found. A type of its own so that EF Core's internal +/// InvalidOperationExceptions still reach the 500 path rather than reading as a missing student. +/// +public sealed class StudentNotFoundException : InvalidOperationException +{ + public StudentNotFoundException(string message) : base(message) { } +} From 48299b99223988fac997402e4bacf6ed41f30cfa Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Thu, 1 Oct 2026 16:02:58 -0700 Subject: [PATCH 5/5] VPR-62 refactor(student): Drop unused MothraId from AaudPersonIdentity --- test/Students/DvmStudentLookupServiceTests.cs | 2 +- web/Areas/Students/Services/DvmStudentLookupService.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Students/DvmStudentLookupServiceTests.cs b/test/Students/DvmStudentLookupServiceTests.cs index 665db3ef2..fc292b122 100644 --- a/test/Students/DvmStudentLookupServiceTests.cs +++ b/test/Students/DvmStudentLookupServiceTests.cs @@ -93,7 +93,7 @@ public async Task LoadAaudUsersByMothraIdAsync_ReadsNameAsLastCommaFirst() var users = await _service.LoadAaudUsersByMothraIdAsync(["FAC00001"]); - Assert.Equal(new AaudPersonIdentity("FAC00001", 500, "Vet, Ann"), users["FAC00001"]); + Assert.Equal(new AaudPersonIdentity(500, "Vet, Ann"), users["FAC00001"]); } [Fact] diff --git a/web/Areas/Students/Services/DvmStudentLookupService.cs b/web/Areas/Students/Services/DvmStudentLookupService.cs index 650d6b24a..c987ae34e 100644 --- a/web/Areas/Students/Services/DvmStudentLookupService.cs +++ b/web/Areas/Students/Services/DvmStudentLookupService.cs @@ -63,7 +63,7 @@ public async Task> LoadAaudUsersByMothraI return users .GroupBy(u => u.MothraId) .Select(g => g.OrderByDescending(u => u.Current).ThenBy(u => u.AaudUserId).First()) - .ToDictionary(u => u.MothraId, u => new AaudPersonIdentity(u.MothraId, u.AaudUserId, u.FullName)); + .ToDictionary(u => u.MothraId, u => new AaudPersonIdentity(u.AaudUserId, u.FullName)); } /// @@ -149,4 +149,4 @@ public static string FormatEmail(string? mailId) public sealed record DvmStudentIdentity(int PersonId, string FullName, string ClassLevel, int? Pidm); -public sealed record AaudPersonIdentity(string MothraId, int PersonId, string FullName); +public sealed record AaudPersonIdentity(int PersonId, string FullName);