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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 23:41:13 -04:00
co-authored by Cursor
parent 2acd83a841
commit 4c91db37c2
13 changed files with 1156 additions and 59 deletions
@@ -0,0 +1,107 @@
using Core.Entities;
namespace Core.Models;
/// <summary>
/// Result of parsing a student event ranking CSV.
/// </summary>
public class StudentEventRankingParseResult
{
/// <summary>
/// Accepted ranking matches, including the raw CSV text and fuzzy scores.
/// </summary>
public List<StudentEventRankingMatch> Matches { get; set; } = [];
/// <summary>
/// Unmatched students, unmatched or ambiguous events, and other row-level issues.
/// </summary>
public List<StudentEventRankingIssue> Issues { get; set; } = [];
/// <summary>
/// Critical errors that prevented parsing (for example a missing header).
/// </summary>
public List<string> Errors { get; set; } = [];
/// <summary>
/// Non-critical warnings about the file as a whole.
/// </summary>
public List<string> Warnings { get; set; } = [];
/// <summary>
/// Accepted rankings without match metadata.
/// </summary>
public IReadOnlyList<StudentEventRanking> Rankings => [.. Matches.Select(m => m.Ranking)];
/// <summary>
/// Number of accepted ranking rows.
/// </summary>
public int TotalParsed => Matches.Count;
/// <summary>
/// Distinct students who have at least one accepted rank.
/// Uses Id when assigned, otherwise first and last name, so unsaved parsed students stay distinct.
/// </summary>
public IReadOnlyList<Student> StudentsWithAcceptedRanks =>
[.. Matches
.Select(m => m.Ranking.Student)
.GroupBy(s => s.Id != 0 ? $"id:{s.Id}" : $"name:{s.FirstName}|{s.LastName}")
.Select(g => g.First())];
/// <summary>
/// True when no critical parse errors were recorded.
/// </summary>
public bool IsSuccess => Errors.Count == 0;
}
/// <summary>
/// A successfully matched ranking cell from the CSV.
/// </summary>
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; }
}
/// <summary>
/// A row-level problem encountered while parsing rankings.
/// </summary>
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; }
}
/// <summary>
/// Types of issues reported while parsing student event rankings.
/// </summary>
public enum StudentEventRankingIssueType
{
UnmatchedStudent,
UnmatchedEvent,
AmbiguousEvent,
DuplicateEvent,
DuplicateRank,
InvalidFormat
}
+218 -54
View File
@@ -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;
/// <summary>
/// Informal names that share too little text with the catalog for fuzzy matching.
/// Keyed by official <see cref="EventDefinition.Name"/>.
/// </summary>
private static readonly Dictionary<string, string[]> 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<Student> students, ICollection<EventDefinition> events)
{
var rankings = new List<StudentEventRanking>();
public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
public StudentEventRankingParseResult Parse(ICollection<Student> students, ICollection<EventDefinition> 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<EventDefinition>();
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<string>(StringComparer.OrdinalIgnoreCase);
var acceptedRanksForStudent = new HashSet<int>();
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;
}
}
private static (Student Student, int Score)? FindStudent(ICollection<Student> 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<EventDefinition> 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<string> 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);
}
@@ -0,0 +1,15 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student event ranking CSV data against existing students and events.
/// </summary>
public interface IStudentEventRankingImportService
{
/// <summary>
/// Parses ranking CSV from a stream. The stream is not disposed.
/// </summary>
StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events);
}
@@ -0,0 +1,19 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentEventRankingParser"/> for stream-based import.
/// </summary>
public class StudentEventRankingImportService : IStudentEventRankingImportService
{
/// <inheritdoc />
public StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentEventRankingParser(reader);
return parser.Parse(students, events);
}
}
@@ -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<Student> _students = null!;
private List<EventDefinition> _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<Student> students,
ICollection<EventDefinition> events)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new StudentEventRankingParser(reader);
return parser.Parse(students, events);
}
}
+3 -3
View File
@@ -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];
}
}
@@ -11,6 +11,14 @@
Title="Student Event Ranks"
Icon="@AppIcons.EventRank">
<ActionButtons>
<MudTooltip Text="Import rankings from CSV">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile"
Href="students/event-ranking/import"
Variant="Variant.Filled"
Color="Color.Primary">
Import
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Event Ranking" />
</ActionButtons>
</PageHeader>
@@ -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<EventRankingImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Rankings"
Description="Upload a CSV of student event preferences, preview matches, then save."
Icon="@AppIcons.EventRank"
ShowBackButton="true"
BackButtonUrl="/students/event-ranking" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required column: <code>Student Name</code>. Rank columns are <code>1</code> through <code>10</code>.
Names can be <code>Last, First</code> or <code>First Last</code>. Event cells can be a full name, short name, or a close match.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton
Variant="Variant.Text"
OnClick="HandleClear"
Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (_parseResult == null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
@foreach (var error in _parseResult.Errors)
{
<MudAlert Severity="Severity.Error" Dense="true">@error</MudAlert>
}
@foreach (var warning in _parseResult.Warnings)
{
<MudAlert Severity="Severity.Warning" Dense="true">@warning</MudAlert>
}
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
{
<MudAlert Severity="Severity.Success" Dense="true">
Matched @_parseResult.TotalParsed ranking(s) for @_parseResult.StudentsWithAcceptedRanks.Count student(s)
</MudAlert>
}
@if (_parseResult.Issues.Count > 0)
{
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@($"Issues ({_parseResult.Issues.Count})")"
Icon="@Icons.Material.Filled.Warning">
<MudTable Items="@_parseResult.Issues" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Row</MudTh>
<MudTh>Rank</MudTh>
<MudTh>Type</MudTh>
<MudTh>Student</MudTh>
<MudTh>Event text</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Row">@context.RowNumber</MudTd>
<MudTd DataLabel="Rank">@(context.Rank > 0 ? context.Rank.ToString() : "-")</MudTd>
<MudTd DataLabel="Type">
<MudChip T="string" Size="Size.Small" Color="@GetIssueTypeColor(context.IssueType)">
@context.IssueType
</MudChip>
</MudTd>
<MudTd DataLabel="Student">@context.RawStudentName</MudTd>
<MudTd DataLabel="Event text">@context.RawEventName</MudTd>
<MudTd DataLabel="Message">@context.Message</MudTd>
</RowTemplate>
</MudTable>
</MudExpansionPanel>
</MudExpansionPanels>
}
@if (_parseResult.IsSuccess && _parseResult.Matches.Count > 0)
{
<MudText Typo="Typo.h6">Matched rankings</MudText>
<MudTable Items="@_parseResult.Matches" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Student</MudTh>
<MudTh>Rank</MudTh>
<MudTh>CSV text</MudTh>
<MudTh>Matched event</MudTh>
<MudTh>Score</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Student">@context.Ranking.Student.FirstNameLastName</MudTd>
<MudTd DataLabel="Rank">@context.Ranking.Rank</MudTd>
<MudTd DataLabel="CSV text">@context.RawEventName</MudTd>
<MudTd DataLabel="Matched event">@context.Ranking.EventDefinition.Name</MudTd>
<MudTd DataLabel="Score">@context.EventScore</MudTd>
</RowTemplate>
</MudTable>
<MudStack Row="true" Spacing="2" Class="mt-2">
<MudButton
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@_isSaving">
Save to Database
</MudButton>
<MudButton
Variant="Variant.Text"
OnClick="HandleClearResults">
Clear Results
</MudButton>
</MudStack>
}
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@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;
}
}
+2
View File
@@ -204,6 +204,8 @@ builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.S
builder.Services.AddScoped<WebApp.Services.IChapterSettingsWriter, WebApp.Services.ChapterSettingsWriter>();
builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Services.DatabaseBackupService>();
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
@@ -0,0 +1,34 @@
using Core.Models;
namespace WebApp.Services;
/// <summary>
/// Persists parsed student event rankings.
/// </summary>
public interface IStudentEventRankingSaveService
{
/// <summary>
/// Students in the parse result who already have rankings stored.
/// </summary>
Task<IReadOnlyList<string>> GetStudentsWithExistingRankingsAsync(
StudentEventRankingParseResult parseResult,
CancellationToken cancellationToken = default);
/// <summary>
/// Replaces rankings for students who have at least one accepted rank in the parse result.
/// Other students are left unchanged.
/// </summary>
Task<StudentEventRankingSaveResult> SaveAsync(
StudentEventRankingParseResult parseResult,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Outcome of saving imported event rankings.
/// </summary>
public class StudentEventRankingSaveResult
{
public int StudentsUpdated { get; set; }
public int RankingsSaved { get; set; }
}
@@ -0,0 +1,111 @@
using Core.Entities;
using Core.Models;
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Replaces event rankings for students present in a successful ranking import.
/// </summary>
public class StudentEventRankingSaveService : IStudentEventRankingSaveService
{
private readonly AppDbContext _context;
private readonly ILogger<StudentEventRankingSaveService> _logger;
public StudentEventRankingSaveService(AppDbContext context, ILogger<StudentEventRankingSaveService> logger)
{
_context = context;
_logger = logger;
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> 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);
}
/// <inheritdoc />
public async Task<StudentEventRankingSaveResult> 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<int> GetStudentIds(StudentEventRankingParseResult parseResult) =>
[.. parseResult.Matches
.Select(m => m.Ranking.Student.Id)
.Where(id => id != 0)
.Distinct()];
}
+32
View File
@@ -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.
+2 -2
View File
@@ -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