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:
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user