From 4c91db37c254ce0f23a9a050fe3c3ff121c5a837 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Fri, 28 Aug 2026 23:41:13 -0400 Subject: [PATCH] feat: add CSV import for student event rankings Let advisors load preference ranks from a converted CSV with fuzzy matching, known aliases, and a parse-preview-save page instead of relying on the ranking editor. Co-authored-by: Cursor --- Core/Models/StudentEventRankingParseResult.cs | 107 ++++++ Core/Parsers/StudentEventRankingParser.cs | 272 +++++++++++--- .../IStudentEventRankingImportService.cs | 15 + .../StudentEventRankingImportService.cs | 19 + .../StudentEventRankingParser_Tests.cs | 260 +++++++++++++ Tests/Parsers/TestEntityHandler.cs | 6 +- .../Features/Students/EventRanking.razor | 8 + .../Students/EventRankingImport.razor | 345 ++++++++++++++++++ WebApp/Program.cs | 2 + .../IStudentEventRankingSaveService.cs | 34 ++ .../StudentEventRankingSaveService.cs | 111 ++++++ docs/instructions/event-ranking-import.md | 32 ++ docs/instructions/year-rollover.md | 4 +- 13 files changed, 1156 insertions(+), 59 deletions(-) create mode 100644 Core/Models/StudentEventRankingParseResult.cs create mode 100644 Core/Services/IStudentEventRankingImportService.cs create mode 100644 Core/Services/StudentEventRankingImportService.cs create mode 100644 Tests/Parsers/StudentEventRankingParser_Tests.cs create mode 100644 WebApp/Components/Features/Students/EventRankingImport.razor create mode 100644 WebApp/Services/IStudentEventRankingSaveService.cs create mode 100644 WebApp/Services/StudentEventRankingSaveService.cs create mode 100644 docs/instructions/event-ranking-import.md diff --git a/Core/Models/StudentEventRankingParseResult.cs b/Core/Models/StudentEventRankingParseResult.cs new file mode 100644 index 0000000..19d767d --- /dev/null +++ b/Core/Models/StudentEventRankingParseResult.cs @@ -0,0 +1,107 @@ +using Core.Entities; + +namespace Core.Models; + +/// +/// Result of parsing a student event ranking CSV. +/// +public class StudentEventRankingParseResult +{ + /// + /// Accepted ranking matches, including the raw CSV text and fuzzy scores. + /// + public List Matches { get; set; } = []; + + /// + /// Unmatched students, unmatched or ambiguous events, and other row-level issues. + /// + public List Issues { get; set; } = []; + + /// + /// Critical errors that prevented parsing (for example a missing header). + /// + public List Errors { get; set; } = []; + + /// + /// Non-critical warnings about the file as a whole. + /// + public List Warnings { get; set; } = []; + + /// + /// Accepted rankings without match metadata. + /// + public IReadOnlyList Rankings => [.. Matches.Select(m => m.Ranking)]; + + /// + /// Number of accepted ranking rows. + /// + public int TotalParsed => Matches.Count; + + /// + /// Distinct students who have at least one accepted rank. + /// Uses Id when assigned, otherwise first and last name, so unsaved parsed students stay distinct. + /// + public IReadOnlyList StudentsWithAcceptedRanks => + [.. Matches + .Select(m => m.Ranking.Student) + .GroupBy(s => s.Id != 0 ? $"id:{s.Id}" : $"name:{s.FirstName}|{s.LastName}") + .Select(g => g.First())]; + + /// + /// True when no critical parse errors were recorded. + /// + public bool IsSuccess => Errors.Count == 0; +} + +/// +/// A successfully matched ranking cell from the CSV. +/// +public class StudentEventRankingMatch +{ + public required StudentEventRanking Ranking { get; set; } + + public string RawStudentName { get; set; } = string.Empty; + + public string RawEventName { get; set; } = string.Empty; + + public int StudentScore { get; set; } + + public int EventScore { get; set; } + + public int RowNumber { get; set; } +} + +/// +/// A row-level problem encountered while parsing rankings. +/// +public class StudentEventRankingIssue +{ + public int RowNumber { get; set; } + + public int Rank { get; set; } + + public string RawStudentName { get; set; } = string.Empty; + + public string RawEventName { get; set; } = string.Empty; + + public StudentEventRankingIssueType IssueType { get; set; } + + public string Message { get; set; } = string.Empty; + + public string? SuggestedEventName { get; set; } + + public int? Score { get; set; } +} + +/// +/// Types of issues reported while parsing student event rankings. +/// +public enum StudentEventRankingIssueType +{ + UnmatchedStudent, + UnmatchedEvent, + AmbiguousEvent, + DuplicateEvent, + DuplicateRank, + InvalidFormat +} diff --git a/Core/Parsers/StudentEventRankingParser.cs b/Core/Parsers/StudentEventRankingParser.cs index d4f4aae..0ffc85f 100644 --- a/Core/Parsers/StudentEventRankingParser.cs +++ b/Core/Parsers/StudentEventRankingParser.cs @@ -1,77 +1,241 @@ using Core.Entities; +using Core.Models; using FuzzySharp; namespace Core.Parsers; public class StudentEventRankingParser : CsvParserBase { + public const int StudentMatchThreshold = 90; + public const int EventMatchThreshold = 70; + public const int EventAmbiguityGap = 8; + + /// + /// Informal names that share too little text with the catalog for fuzzy matching. + /// Keyed by official . + /// + private static readonly Dictionary EventAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["Junior Solar Sprint"] = ["Solar Racer", "Solar Race"], + ["Challenging Technology Issues"] = ["Challenging Tech", "Challenging Technology"], + ["Digital Photography"] = ["Digital Photo"], + ["Forensic Technology"] = ["Forensics", "Forensic"], + ["Microcontroller Design"] = ["Micro Controller", "Micro Controller Design"], + ["Medical Technology"] = ["Med Tech"], + ["Inventions & Innovations"] = ["Innovations & Inventions", "Inventions and Innovations"], + ["System Control Technology"] = ["Systems Control Tech", "Systems Control Technology"], + ["Structural Engineering"] = ["Structural Eng"], + ["Drone Challenge (UAV)"] = ["Drone Challenge", "Drone"], + ["Drone Challenge"] = ["Drone Challenge (UAV)", "Drone"], + ["TSA Robotics"] = ["Robotics"], + ["Audio Podcasting"] = ["Audio Podcast", "Podcasting"] + }; + public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines) { } - public StudentEventRanking[] Parse(ICollection students, ICollection events) - { - var rankings = new List(); + public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines) + { + } + + public StudentEventRankingParseResult Parse(ICollection students, ICollection events) + { + var result = new StudentEventRankingParseResult(); CsvReader.Read(); CsvReader.ReadHeader(); - while (CsvReader.Read()) - { - var name = CsvReader.GetField("Student Name"); + if (CsvReader.HeaderRecord is null || + !CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase)) + { + result.Errors.Add("CSV must include a 'Student Name' column."); + return result; + } + + while (CsvReader.Read()) + { + var rowNumber = CsvReader.Context.Parser?.Row ?? 0; + var name = CsvReader.GetField("Student Name")?.Trim(); if (string.IsNullOrEmpty(name)) continue; - var student = students.FirstOrDefault(s => Fuzz.Ratio(s.FirstNameLastName, name) > 90); - if (student == null) - continue; - - - var competitiveEvents = new List(); - - for (var i = 1; i <= 6; i++) + var studentMatch = FindStudent(students, name); + if (studentMatch is null) { - var eventName = CsvReader.GetField(i.ToString()); - if (string.IsNullOrEmpty(eventName) || eventName == "") continue; - - eventName = eventName.Trim(); - - if (eventName == "I&I") - eventName = "Inventions & Innovations"; - if (eventName == "Med Tech") - eventName = "Medical Technology"; - if (eventName.StartsWith("Challenging Tech")) - eventName = "Challenging Technology Issues"; - - var matches = - (from e in events - let rat = Fuzz.Ratio(e.Name, eventName) - where rat > 90 - orderby rat descending - select e).ToList(); - - if (!matches.Any()) + result.Issues.Add(new StudentEventRankingIssue { - matches = - (from e in events - where e.Name.StartsWith(eventName) - select e).ToList(); - } - - var competitiveEvent = matches.FirstOrDefault(); - if (competitiveEvent == null) - { - - //todo: throw new ArgumentException($"Event named '{eventName}' not found"); - continue; - } - - rankings.Add(new StudentEventRanking{ - Student = student, EventDefinition = competitiveEvent, - Rank = i}); + RowNumber = rowNumber, + RawStudentName = name, + IssueType = StudentEventRankingIssueType.UnmatchedStudent, + Message = $"No student matched '{name}'." + }); + continue; } - } - return rankings.ToArray(); + var (student, studentScore) = studentMatch.Value; + var acceptedEventsForStudent = new HashSet(StringComparer.OrdinalIgnoreCase); + var acceptedRanksForStudent = new HashSet(); + + for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++) + { + var eventName = CsvReader.GetField(rank.ToString())?.Trim(); + if (string.IsNullOrEmpty(eventName)) + continue; + + var eventResolution = ResolveEvent(events, eventName); + if (eventResolution.Status == EventMatchStatus.Unmatched) + { + result.Issues.Add(new StudentEventRankingIssue + { + RowNumber = rowNumber, + Rank = rank, + RawStudentName = name, + RawEventName = eventName, + IssueType = StudentEventRankingIssueType.UnmatchedEvent, + Message = $"No event matched '{eventName}' for {student.FirstNameLastName} (rank {rank}).", + SuggestedEventName = eventResolution.BestEvent?.Name, + Score = eventResolution.BestScore + }); + continue; + } + + if (eventResolution.Status == EventMatchStatus.Ambiguous) + { + result.Issues.Add(new StudentEventRankingIssue + { + RowNumber = rowNumber, + Rank = rank, + RawStudentName = name, + RawEventName = eventName, + IssueType = StudentEventRankingIssueType.AmbiguousEvent, + Message = $"'{eventName}' is ambiguous between '{eventResolution.BestEvent?.Name}' and '{eventResolution.RunnerUpEvent?.Name}' for {student.FirstNameLastName} (rank {rank}).", + SuggestedEventName = eventResolution.BestEvent?.Name, + Score = eventResolution.BestScore + }); + continue; + } + + var matchedEvent = eventResolution.BestEvent!; + if (!acceptedEventsForStudent.Add($"{matchedEvent.Id}:{matchedEvent.Name}")) + { + result.Issues.Add(new StudentEventRankingIssue + { + RowNumber = rowNumber, + Rank = rank, + RawStudentName = name, + RawEventName = eventName, + IssueType = StudentEventRankingIssueType.DuplicateEvent, + Message = $"{student.FirstNameLastName} already has '{matchedEvent.Name}' in this file.", + SuggestedEventName = matchedEvent.Name, + Score = eventResolution.BestScore + }); + continue; + } + + if (!acceptedRanksForStudent.Add(rank)) + { + result.Issues.Add(new StudentEventRankingIssue + { + RowNumber = rowNumber, + Rank = rank, + RawStudentName = name, + RawEventName = eventName, + IssueType = StudentEventRankingIssueType.DuplicateRank, + Message = $"{student.FirstNameLastName} already has a rank {rank} assignment in this file.", + SuggestedEventName = matchedEvent.Name, + Score = eventResolution.BestScore + }); + continue; + } + + result.Matches.Add(new StudentEventRankingMatch + { + Ranking = new StudentEventRanking + { + Student = student, + EventDefinition = matchedEvent, + Rank = rank + }, + RawStudentName = name, + RawEventName = eventName, + StudentScore = studentScore, + EventScore = eventResolution.BestScore, + RowNumber = rowNumber + }); + } + } + + if (result.Matches.Count == 0 && result.Errors.Count == 0) + result.Warnings.Add("No rankings were accepted from the CSV."); + + return result; } -} \ No newline at end of file + + private static (Student Student, int Score)? FindStudent(ICollection students, string name) + { + var ranked = students + .Select(s => (Student: s, Score: ScoreStudent(s, name))) + .Where(x => x.Score >= StudentMatchThreshold) + .OrderByDescending(x => x.Score) + .ToList(); + + return ranked.Count == 0 ? null : ranked[0]; + } + + private static int ScoreStudent(Student student, string name) + { + var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName }; + return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name))); + } + + private static EventResolution ResolveEvent(ICollection events, string eventName) + { + var scored = events + .Select(e => (Event: e, Score: ScoreEvent(e, eventName))) + .OrderByDescending(x => x.Score) + .ToList(); + + if (scored.Count == 0) + return new EventResolution(EventMatchStatus.Unmatched, null, 0, null, 0); + + var best = scored[0]; + var runnerUp = scored.Count > 1 ? scored[1] : default; + + if (best.Score < EventMatchThreshold) + return new EventResolution(EventMatchStatus.Unmatched, best.Event, best.Score, runnerUp.Event, runnerUp.Score); + + if (runnerUp.Event is not null && best.Score - runnerUp.Score < EventAmbiguityGap) + return new EventResolution(EventMatchStatus.Ambiguous, best.Event, best.Score, runnerUp.Event, runnerUp.Score); + + return new EventResolution(EventMatchStatus.Matched, best.Event, best.Score, runnerUp.Event, runnerUp.Score); + } + + private static int ScoreEvent(EventDefinition evt, string eventName) + { + List names = [evt.Name]; + if (!string.IsNullOrWhiteSpace(evt.ShortName)) + names.Add(evt.ShortName); + if (EventAliases.TryGetValue(evt.Name, out var aliases)) + names.AddRange(aliases); + + return names + .Select(n => new[] { Fuzz.Ratio(n, eventName), Fuzz.TokenSetRatio(n, eventName), Fuzz.PartialRatio(n, eventName) }.Max()) + .DefaultIfEmpty(0) + .Max(); + } + + private enum EventMatchStatus + { + Matched, + Ambiguous, + Unmatched + } + + private readonly record struct EventResolution( + EventMatchStatus Status, + EventDefinition? BestEvent, + int BestScore, + EventDefinition? RunnerUpEvent, + int RunnerUpScore); +} diff --git a/Core/Services/IStudentEventRankingImportService.cs b/Core/Services/IStudentEventRankingImportService.cs new file mode 100644 index 0000000..baac780 --- /dev/null +++ b/Core/Services/IStudentEventRankingImportService.cs @@ -0,0 +1,15 @@ +using Core.Entities; +using Core.Models; + +namespace Core.Services; + +/// +/// Parses student event ranking CSV data against existing students and events. +/// +public interface IStudentEventRankingImportService +{ + /// + /// Parses ranking CSV from a stream. The stream is not disposed. + /// + StudentEventRankingParseResult Parse(Stream stream, ICollection students, ICollection events); +} diff --git a/Core/Services/StudentEventRankingImportService.cs b/Core/Services/StudentEventRankingImportService.cs new file mode 100644 index 0000000..1968d99 --- /dev/null +++ b/Core/Services/StudentEventRankingImportService.cs @@ -0,0 +1,19 @@ +using Core.Entities; +using Core.Models; +using Core.Parsers; + +namespace Core.Services; + +/// +/// Wraps for stream-based import. +/// +public class StudentEventRankingImportService : IStudentEventRankingImportService +{ + /// + public StudentEventRankingParseResult Parse(Stream stream, ICollection students, ICollection events) + { + var reader = new StreamReader(stream, leaveOpen: true); + using var parser = new StudentEventRankingParser(reader); + return parser.Parse(students, events); + } +} diff --git a/Tests/Parsers/StudentEventRankingParser_Tests.cs b/Tests/Parsers/StudentEventRankingParser_Tests.cs new file mode 100644 index 0000000..e988db1 --- /dev/null +++ b/Tests/Parsers/StudentEventRankingParser_Tests.cs @@ -0,0 +1,260 @@ +using System.Text; +using Core.Entities; +using Core.Models; +using Core.Parsers; +using Tests.Builders; + +namespace Tests.Parsers; + +[TestFixture] +public class StudentEventRankingParser_Tests +{ + private Student _aria = null!; + private EventDefinition _videoGame = null!; + private EventDefinition _coding = null!; + private EventDefinition _jss = null!; + private EventDefinition _digitalPhoto = null!; + private EventDefinition _biotech = null!; + private EventDefinition _inventions = null!; + private EventDefinition _techBowl = null!; + private EventDefinition _techDesign = null!; + private List _students = null!; + private List _events = null!; + + [SetUp] + public void SetUp() + { + BuilderExtensions.ResetAllBuilders(); + + _aria = StudentBuilder.Create("Aria", "Chittenden").Build(); + _videoGame = EventDefinitionBuilder.Team("Video Game Design", 2, 6).WithShortName("Video Game").Build(); + _coding = EventDefinitionBuilder.Team("Coding", 2, 2).WithShortName("Coding").Build(); + _jss = EventDefinitionBuilder.Team("Junior Solar Sprint", 2, 4).WithShortName("JSS").Build(); + _digitalPhoto = EventDefinitionBuilder.Individual("Digital Photography").WithShortName("Digital Photo").Build(); + _biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).WithShortName("Biotech").Build(); + _inventions = EventDefinitionBuilder.Team("Inventions & Innovations", 3, 6).WithShortName("I&I").Build(); + _techBowl = EventDefinitionBuilder.Team("Tech Bowl", 3, 3).WithShortName("Tech Bowl").Build(); + _techDesign = EventDefinitionBuilder.Team("Technical Design", 2, 2).WithShortName("Tech Design").Build(); + + _students = [_aria]; + _events = [_videoGame, _coding, _jss, _digitalPhoto, _biotech, _inventions, _techBowl, _techDesign]; + } + + [Test] + public void Parse_LastCommaFirst_MatchesStudent() + { + var result = ParseCsv(""" + Student Name,1 + "Chittenden, Aria",Coding + """, _students, _events); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria)); + Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_coding)); + Assert.That(result.Matches[0].Ranking.Rank, Is.EqualTo(1)); + } + + [Test] + public void Parse_FirstLast_MatchesStudent() + { + var result = ParseCsv(""" + Student Name,1 + Aria Chittenden,Coding + """, _students, _events); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria)); + } + + [Test] + public void Parse_ShortName_MatchesEvent() + { + var result = ParseCsv(""" + Student Name,1,2,3 + Aria Chittenden,Video Game,JSS,I&I + """, _students, _events); + + Assert.That(result.Issues, Is.Empty); + Assert.That(result.Matches, Has.Count.EqualTo(3)); + Assert.That(result.Matches.Select(m => m.Ranking.EventDefinition), Is.EqualTo(new[] { _videoGame, _jss, _inventions })); + } + + [Test] + public void Parse_NearMissFullName_MatchesEvent() + { + var result = ParseCsv(""" + Student Name,1 + Aria Chittenden,Digital Photo + """, _students, _events); + + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_digitalPhoto)); + Assert.That(result.Matches[0].EventScore, Is.GreaterThanOrEqualTo(StudentEventRankingParser.EventMatchThreshold)); + } + + [Test] + public void Parse_BiotechShortName_MatchesBiotechnology() + { + var result = ParseCsv(""" + Student Name,1 + Aria Chittenden,Biotech + """, _students, _events); + + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_biotech)); + } + + [Test] + public void Parse_UnmatchedStudent_IsReported() + { + var result = ParseCsv(""" + Student Name,1 + Nobody Here,Coding + """, _students, _events); + + Assert.That(result.Matches, Is.Empty); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedStudent)); + Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here")); + } + + [Test] + public void Parse_SolarRacer_MatchesJuniorSolarSprint() + { + var result = ParseCsv(""" + Student Name,1 + Aria Chittenden,Solar Racer + """, _students, _events); + + Assert.That(result.Issues, Is.Empty); + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_jss)); + } + + [TestCase("Solar Racer", "Junior Solar Sprint")] + [TestCase("Solar Race", "Junior Solar Sprint")] + [TestCase("Challenging Tech", "Challenging Technology Issues")] + [TestCase("Digital Photo", "Digital Photography")] + [TestCase("Forensics", "Forensic Technology")] + [TestCase("Micro Controller", "Microcontroller Design")] + [TestCase("Med Tech", "Medical Technology")] + [TestCase("Innovations & Inventions", "Inventions & Innovations")] + [TestCase("Inventions and Innovations", "Inventions & Innovations")] + [TestCase("Systems Control Tech", "System Control Technology")] + [TestCase("Systems Control Technology", "System Control Technology")] + [TestCase("Structural Eng", "Structural Engineering")] + [TestCase("Drone Challenge", "Drone Challenge (UAV)")] + [TestCase("Robotics", "TSA Robotics")] + [TestCase("Audio Podcast", "Audio Podcasting")] + public void Parse_KnownAlias_MatchesOfficialEvent(string alias, string officialName) + { + var events = TestEntityHandler.GetEvents().ToList(); + events.AddRange( + [ + EventDefinitionBuilder.Team("Drone Challenge (UAV)", 2, 6).WithShortName("Drone").Build(), + EventDefinitionBuilder.Team("TSA Robotics", 2, 6).WithShortName("Robotics").Build(), + EventDefinitionBuilder.Team("Audio Podcasting", 2, 6).WithShortName("Podcasting").Build() + ]); + + var result = ParseCsv($""" + Student Name,1 + Aria Chittenden,{alias} + """, _students, events); + + Assert.That(result.Issues, Is.Empty, $"Expected '{alias}' to match '{officialName}'"); + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Ranking.EventDefinition.Name, Is.EqualTo(officialName)); + } + + [Test] + public void Parse_UnmatchedEvent_IsReported() + { + var result = ParseCsv(""" + Student Name,1 + Aria Chittenden,Underwater Basket Weaving + """, _students, _events); + + Assert.That(result.Matches, Is.Empty); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedEvent)); + Assert.That(result.Issues[0].RawEventName, Is.EqualTo("Underwater Basket Weaving")); + } + + [Test] + public void Parse_AmbiguousEvent_IsReported() + { + var result = ParseCsv(""" + Student Name,1 + Aria Chittenden,Tech + """, _students, _events); + + Assert.That(result.Matches, Is.Empty); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.AmbiguousEvent)); + } + + [Test] + public void Parse_RankColumnsBeyondSix_AreRead() + { + var result = ParseCsv(""" + Student Name,1,2,3,4,5,6,7 + Aria Chittenden,Coding,,,,,,JSS + """, _students, _events); + + Assert.That(result.Matches, Has.Count.EqualTo(2)); + Assert.That(result.Matches.Select(m => m.Ranking.Rank), Is.EqualTo(new[] { 1, 7 })); + Assert.That(result.Matches[1].Ranking.EventDefinition, Is.SameAs(_jss)); + } + + [Test] + public void Parse_DuplicateEvent_IsReported() + { + var result = ParseCsv(""" + Student Name,1,2 + Aria Chittenden,Coding,Coding + """, _students, _events); + + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.DuplicateEvent)); + } + + [Test] + public void Parse_MissingStudentNameHeader_IsError() + { + var result = ParseCsv(""" + Name,1 + Aria Chittenden,Coding + """, _students, _events); + + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.Errors, Is.Not.Empty); + } + + [Test] + public void Parse_2024RankingsFile_MatchesKnownStudentsAndEvents() + { + var events = TestEntityHandler.GetEvents(); + var students = TestEntityHandler.GetStudents(events); + var rankings = TestEntityHandler.GetStudentEventRankings(students, events); + + Assert.That(students, Has.Length.EqualTo(29)); + Assert.That(rankings, Is.Not.Empty); + Assert.That(rankings.Select(r => r.Student.FirstNameLastName).Distinct().Count(), Is.EqualTo(29)); + Assert.That(rankings.All(r => r.EventDefinition is not null), Is.True); + Assert.That(rankings.All(r => r.Rank is >= 1 and <= 10), Is.True); + Assert.That(rankings.Count(r => r.Student.FirstName == "First26"), Is.EqualTo(7)); + } + + private static StudentEventRankingParseResult ParseCsv( + string csv, + ICollection students, + ICollection events) + { + using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv))); + using var parser = new StudentEventRankingParser(reader); + return parser.Parse(students, events); + } +} diff --git a/Tests/Parsers/TestEntityHandler.cs b/Tests/Parsers/TestEntityHandler.cs index d47f430..5831563 100644 --- a/Tests/Parsers/TestEntityHandler.cs +++ b/Tests/Parsers/TestEntityHandler.cs @@ -69,9 +69,9 @@ public static class TestEntityHandler public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events) { - var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2025 Student Event Rankings.csv"); + var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2024 Student Event Rankings.csv"); - var rankingParser = new StudentEventRankingParser(fileInfo); - return rankingParser.Parse(students, events); + using var rankingParser = new StudentEventRankingParser(fileInfo); + return [.. rankingParser.Parse(students, events).Rankings]; } } \ No newline at end of file diff --git a/WebApp/Components/Features/Students/EventRanking.razor b/WebApp/Components/Features/Students/EventRanking.razor index 090084a..a009511 100644 --- a/WebApp/Components/Features/Students/EventRanking.razor +++ b/WebApp/Components/Features/Students/EventRanking.razor @@ -11,6 +11,14 @@ Title="Student Event Ranks" Icon="@AppIcons.EventRank"> + + + Import + + diff --git a/WebApp/Components/Features/Students/EventRankingImport.razor b/WebApp/Components/Features/Students/EventRankingImport.razor new file mode 100644 index 0000000..3392a37 --- /dev/null +++ b/WebApp/Components/Features/Students/EventRankingImport.razor @@ -0,0 +1,345 @@ +@page "/students/event-ranking/import" +@attribute [Authorize] +@implements IAsyncDisposable +@using Core.Models +@using Core.Services +@using Microsoft.EntityFrameworkCore +@using WebApp.Models +@inject IStudentEventRankingImportService ImportService +@inject IStudentEventRankingSaveService SaveService +@inject AppDbContext Context +@inject NavigationManager NavigationManager +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject ILogger Logger +@rendermode InteractiveServer + + + + + + + Upload CSV + + + Required column: Student Name. Rank columns are 1 through 10. + Names can be Last, First or First Last. Event cells can be a full name, short name, or a close match. + + + @if (!string.IsNullOrEmpty(_fileName)) + { + @_fileName + } + + + Parse + + + Clear + + + + + + + + + Parsed Results + + @if (_isParsing) + { + + Parsing... + } + else if (_parseResult == null) + { + Upload and parse a CSV to see results here + } + else + { + + @foreach (var error in _parseResult.Errors) + { + @error + } + + @foreach (var warning in _parseResult.Warnings) + { + @warning + } + + @if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0) + { + + Matched @_parseResult.TotalParsed ranking(s) for @_parseResult.StudentsWithAcceptedRanks.Count student(s) + + } + + @if (_parseResult.Issues.Count > 0) + { + + + + + Row + Rank + Type + Student + Event text + Message + + + @context.RowNumber + @(context.Rank > 0 ? context.Rank.ToString() : "-") + + + @context.IssueType + + + @context.RawStudentName + @context.RawEventName + @context.Message + + + + + } + + @if (_parseResult.IsSuccess && _parseResult.Matches.Count > 0) + { + Matched rankings + + + Student + Rank + CSV text + Matched event + Score + + + @context.Ranking.Student.FirstNameLastName + @context.Ranking.Rank + @context.RawEventName + @context.Ranking.EventDefinition.Name + @context.EventScore + + + + + + Save to Database + + + Clear Results + + + } + + } + + + + +@code { + private byte[]? _fileBytes; + private string? _fileName; + private StudentEventRankingParseResult? _parseResult; + private bool _isParsing; + private bool _isSaving; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + private async Task HandleFileChanged(InputFileChangeEventArgs args) + { + if (_isDisposed) + return; + + try + { + await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024); + await using var memory = new MemoryStream(); + await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None); + _fileBytes = memory.ToArray(); + _fileName = args.File.Name; + _parseResult = null; + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error reading ranking CSV"); + if (!_isDisposed) + Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error); + } + } + + private async Task HandleParse() + { + if (_fileBytes is null) + { + Snackbar.Add("Please choose a CSV file first", Severity.Warning); + return; + } + + _isParsing = true; + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + var students = await Context.Students + .AsNoTracking() + .OrderBy(s => s.LastName) + .ThenBy(s => s.FirstName) + .ToListAsync(token); + var events = await Context.Events + .AsNoTracking() + .OrderBy(e => e.Name) + .ToListAsync(token); + + await using var stream = new MemoryStream(_fileBytes, writable: false); + _parseResult = ImportService.Parse(stream, students, events); + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error parsing ranking CSV"); + if (!_isDisposed) + { + Snackbar.Add($"Error parsing CSV: {ex.Message}", Severity.Error); + _parseResult = new StudentEventRankingParseResult + { + Errors = { $"Error: {ex.Message}" } + }; + } + } + finally + { + _isParsing = false; + } + } + + private void HandleClear() + { + _fileBytes = null; + _fileName = null; + _parseResult = null; + } + + private void HandleClearResults() + { + _parseResult = null; + } + + private async Task HandleSave() + { + if (_parseResult is null || !_parseResult.IsSuccess || _parseResult.TotalParsed == 0) + { + Snackbar.Add("No valid rankings to save", Severity.Warning); + return; + } + + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + var existing = await SaveService.GetStudentsWithExistingRankingsAsync(_parseResult, token); + if (existing.Count > 0) + { + var preview = string.Join(", ", existing.Take(8)); + var remaining = existing.Count > 8 ? $" and {existing.Count - 8} more" : string.Empty; + var confirmed = await DialogService.ShowMessageBox( + "Replace existing rankings?", + $"This will replace current rankings for {existing.Count} student(s): {preview}{remaining}. Continue?", + yesText: "Replace rankings", + cancelText: "Cancel"); + + if (confirmed != true || _isDisposed) + return; + } + + _isSaving = true; + var saveResult = await SaveService.SaveAsync(_parseResult, token); + if (_isDisposed) + return; + + Snackbar.Add( + $"Saved {saveResult.RankingsSaved} ranking(s) for {saveResult.StudentsUpdated} student(s)", + Severity.Success); + NavigationManager.NavigateTo("/students/event-ranking"); + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error saving imported rankings"); + if (!_isDisposed) + Snackbar.Add($"Error saving rankings: {ex.Message}", Severity.Error); + } + finally + { + _isSaving = false; + } + } + + private static Color GetIssueTypeColor(StudentEventRankingIssueType issueType) => + issueType switch + { + StudentEventRankingIssueType.UnmatchedStudent => Color.Error, + StudentEventRankingIssueType.UnmatchedEvent => Color.Warning, + StudentEventRankingIssueType.AmbiguousEvent => Color.Warning, + StudentEventRankingIssueType.DuplicateEvent => Color.Info, + StudentEventRankingIssueType.DuplicateRank => Color.Info, + StudentEventRankingIssueType.InvalidFormat => Color.Error, + _ => Color.Default + }; + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Program.cs b/WebApp/Program.cs index 531770b..52f3a55 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -204,6 +204,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure( builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName)); diff --git a/WebApp/Services/IStudentEventRankingSaveService.cs b/WebApp/Services/IStudentEventRankingSaveService.cs new file mode 100644 index 0000000..579f739 --- /dev/null +++ b/WebApp/Services/IStudentEventRankingSaveService.cs @@ -0,0 +1,34 @@ +using Core.Models; + +namespace WebApp.Services; + +/// +/// Persists parsed student event rankings. +/// +public interface IStudentEventRankingSaveService +{ + /// + /// Students in the parse result who already have rankings stored. + /// + Task> GetStudentsWithExistingRankingsAsync( + StudentEventRankingParseResult parseResult, + CancellationToken cancellationToken = default); + + /// + /// Replaces rankings for students who have at least one accepted rank in the parse result. + /// Other students are left unchanged. + /// + Task SaveAsync( + StudentEventRankingParseResult parseResult, + CancellationToken cancellationToken = default); +} + +/// +/// Outcome of saving imported event rankings. +/// +public class StudentEventRankingSaveResult +{ + public int StudentsUpdated { get; set; } + + public int RankingsSaved { get; set; } +} diff --git a/WebApp/Services/StudentEventRankingSaveService.cs b/WebApp/Services/StudentEventRankingSaveService.cs new file mode 100644 index 0000000..c16e343 --- /dev/null +++ b/WebApp/Services/StudentEventRankingSaveService.cs @@ -0,0 +1,111 @@ +using Core.Entities; +using Core.Models; +using Data; +using Microsoft.EntityFrameworkCore; + +namespace WebApp.Services; + +/// +/// Replaces event rankings for students present in a successful ranking import. +/// +public class StudentEventRankingSaveService : IStudentEventRankingSaveService +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public StudentEventRankingSaveService(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + public async Task> GetStudentsWithExistingRankingsAsync( + StudentEventRankingParseResult parseResult, + CancellationToken cancellationToken = default) + { + var studentIds = GetStudentIds(parseResult); + if (studentIds.Count == 0) + return []; + + return await _context.Students + .AsNoTracking() + .Where(s => studentIds.Contains(s.Id) && s.EventRankings.Any()) + .OrderBy(s => s.LastName) + .ThenBy(s => s.FirstName) + .Select(s => s.FirstName + " " + s.LastName) + .ToListAsync(cancellationToken); + } + + /// + public async Task SaveAsync( + StudentEventRankingParseResult parseResult, + CancellationToken cancellationToken = default) + { + var studentIds = GetStudentIds(parseResult); + if (studentIds.Count == 0) + return new StudentEventRankingSaveResult(); + + var students = await _context.Students + .Include(s => s.EventRankings) + .Where(s => studentIds.Contains(s.Id)) + .ToListAsync(cancellationToken); + + var eventIds = parseResult.Matches + .Select(m => m.Ranking.EventDefinition.Id) + .Where(id => id != 0) + .Distinct() + .ToList(); + + var events = await _context.Events + .Where(e => eventIds.Contains(e.Id)) + .ToDictionaryAsync(e => e.Id, cancellationToken); + + var matchesByStudent = parseResult.Matches + .Where(m => m.Ranking.Student.Id != 0) + .GroupBy(m => m.Ranking.Student.Id) + .ToDictionary(g => g.Key, g => g.ToList()); + + var rankingsSaved = 0; + foreach (var student in students) + { + if (!matchesByStudent.TryGetValue(student.Id, out var matches)) + continue; + + student.EventRankings.Clear(); + foreach (var match in matches.OrderBy(m => m.Ranking.Rank)) + { + if (!events.TryGetValue(match.Ranking.EventDefinition.Id, out var eventDefinition)) + { + _logger.LogWarning( + "Skipping ranking for student {StudentId}: event {EventId} was not found", + student.Id, + match.Ranking.EventDefinition.Id); + continue; + } + + student.EventRankings.Add(new StudentEventRanking + { + Student = student, + EventDefinition = eventDefinition, + Rank = match.Ranking.Rank + }); + rankingsSaved++; + } + } + + await _context.SaveChangesAsync(cancellationToken); + + return new StudentEventRankingSaveResult + { + StudentsUpdated = students.Count, + RankingsSaved = rankingsSaved + }; + } + + private static List GetStudentIds(StudentEventRankingParseResult parseResult) => + [.. parseResult.Matches + .Select(m => m.Ranking.Student.Id) + .Where(id => id != 0) + .Distinct()]; +} diff --git a/docs/instructions/event-ranking-import.md b/docs/instructions/event-ranking-import.md new file mode 100644 index 0000000..df96147 --- /dev/null +++ b/docs/instructions/event-ranking-import.md @@ -0,0 +1,32 @@ +# Import Student Event Rankings + +**Created:** 2026-08-28 +**Last updated:** 2026-08-28 +**Description:** How to import student event preference rankings from a CSV. + +## CSV format + +``` +Student Name,1,2,3,4,5,6 +"Last, First",Video Game Design,Coding,Flight +First Last,Video Game,Coding +``` + +- `Student Name` is required. `Last, First` and `First Last` both work. +- Rank columns are `1` through `10`. Empty cells are skipped. Extra columns are ignored. +- Event cells may be the official name, the catalog short name, or a close miss. Fuzzy matching scores both. +- Known nicknames that fuzzy cannot reach are also accepted, including `Solar Racer`, `Challenging Tech`, `Digital Photo`, `Forensics`, `Micro Controller`, `Med Tech`, `Innovations & Inventions`, `Systems Control Tech`, `Structural Eng`, `Drone Challenge`, `Robotics`, and `Audio Podcast`. + +## Steps + +1. Sign in and open **Student Event Ranks** (`/students/event-ranking`). +2. Click **Import**. +3. Choose the CSV and click **Parse**. +4. Review matched rows (raw text, chosen event, score) and any unmatched or ambiguous issues. +5. Click **Save to Database**. If any matched student already has rankings, confirm the replace dialog. + +Save replaces rankings only for students who have at least one accepted rank in the file. Other students are unchanged. + +## What is not imported + +Unmatched students, unmatched events, and ambiguous event names are reported in the preview and are not saved. Fix the CSV text or add the student/event in the app, then parse again. diff --git a/docs/instructions/year-rollover.md b/docs/instructions/year-rollover.md index 59583eb..9b43c89 100644 --- a/docs/instructions/year-rollover.md +++ b/docs/instructions/year-rollover.md @@ -1,7 +1,7 @@ # Year Rollover Runbook **Created:** 2026-08-14 -**Last updated:** 2026-08-14 +**Last updated:** 2026-08-28 **Description:** How to roll the chapter into a new competition year using the locked New Year wizard. ## Prerequisites @@ -40,7 +40,7 @@ - `/import` is add-only and skips existing first+last name matches, so re-importing a full roster is safe for returners. 11. **Import the new state schedule** from the calendar import page. 12. On **Meeting Schedule**, click **Reset** once. That page keeps team/student ids in browser localStorage; after a rollover those ids are stale. -13. Collect new event rankings and run team assignment as usual. +13. Collect new event rankings (CSV import at `/students/event-ranking/import`, or the ranking editor) and run team assignment as usual. ## What is deleted vs kept