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