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