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
}