Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings. Co-authored-by: Cursor <cursoragent@cursor.com>
302 lines
10 KiB
Plaintext
302 lines
10 KiB
Plaintext
@page "/students/import"
|
|
@page "/import"
|
|
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
|
@implements IAsyncDisposable
|
|
@using Core.Parsers
|
|
@using Core.Services
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using WebApp.Authentication
|
|
@inject AppDbContext Context
|
|
@inject IStudentNotesImportService NotesImportService
|
|
@inject IStudentNotesImportSaveService NotesSaveService
|
|
@inject INotesService NotesService
|
|
@inject NavigationManager NavigationManager
|
|
@inject ISnackbar Snackbar
|
|
@inject ILogger<StudentImport> Logger
|
|
@rendermode InteractiveServer
|
|
|
|
<PageHeader
|
|
Title="Import Students"
|
|
Description="Add new students from CSV. Existing first+last names are skipped; leftover columns merge into student notes."
|
|
ShowBackButton="true"
|
|
BackButtonUrl="/students" />
|
|
|
|
<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 columns: <code>Student Name</code>, <code>Grade</code>, <code>TSA year</code>.
|
|
Optional IDs are saved on new students. Every other column is merged into that student's notes.
|
|
</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 (!string.IsNullOrEmpty(_parseError))
|
|
{
|
|
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
|
|
}
|
|
else if (_students is null)
|
|
{
|
|
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
|
|
}
|
|
else
|
|
{
|
|
<MudStack Spacing="3">
|
|
<MudAlert Severity="Severity.Success" Dense="true">
|
|
@_students.Length student(s) parsed.
|
|
@_newStudentCount new, @_existingStudentCount already in the database.
|
|
</MudAlert>
|
|
@if (_leftoverFieldNames.Count > 0)
|
|
{
|
|
<MudText Typo="Typo.body2">Leftover note fields: @string.Join(", ", _leftoverFieldNames)</MudText>
|
|
}
|
|
else
|
|
{
|
|
<MudText Typo="Typo.body2" Class="mud-text-secondary">No leftover note fields in this file.</MudText>
|
|
}
|
|
|
|
<MudButton Variant="Variant.Filled" Color="Color.Success"
|
|
StartIcon="@Icons.Material.Filled.Save"
|
|
OnClick="HandleSave"
|
|
Disabled="@(_isSaving || _students.Length == 0)">
|
|
Save to Database
|
|
</MudButton>
|
|
</MudStack>
|
|
}
|
|
</MudPaper>
|
|
</MudItem>
|
|
</MudGrid>
|
|
|
|
@code {
|
|
private byte[]? _fileBytes;
|
|
private string? _fileName;
|
|
private Student[]? _students;
|
|
private List<string> _leftoverFieldNames = [];
|
|
private int _newStudentCount;
|
|
private int _existingStudentCount;
|
|
private string? _parseError;
|
|
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;
|
|
ResetParse();
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error reading student 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;
|
|
_parseError = null;
|
|
try
|
|
{
|
|
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
using var reader = new StreamReader(new MemoryStream(_fileBytes));
|
|
_students = new StudentParser(reader).Parse();
|
|
_leftoverFieldNames = PeekLeftoverFieldNames(_fileBytes);
|
|
|
|
var existingNames = await Context.Students
|
|
.AsNoTracking()
|
|
.Select(s => new { s.FirstName, s.LastName })
|
|
.ToListAsync(token);
|
|
var existingSet = existingNames
|
|
.Select(s => (s.FirstName, s.LastName))
|
|
.ToHashSet();
|
|
|
|
_existingStudentCount = _students.Count(s => existingSet.Contains((s.FirstName, s.LastName)));
|
|
_newStudentCount = _students.Length - _existingStudentCount;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error parsing student CSV");
|
|
_students = null;
|
|
_leftoverFieldNames = [];
|
|
_parseError = $"Error parsing CSV: {ex.Message}";
|
|
if (!_isDisposed)
|
|
Snackbar.Add(_parseError, Severity.Error);
|
|
}
|
|
finally
|
|
{
|
|
_isParsing = false;
|
|
}
|
|
}
|
|
|
|
private async Task HandleSave()
|
|
{
|
|
if (_students is null || _fileBytes is null)
|
|
{
|
|
Snackbar.Add("Parse a CSV first", Severity.Warning);
|
|
return;
|
|
}
|
|
|
|
_isSaving = true;
|
|
try
|
|
{
|
|
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
var added = 0;
|
|
foreach (var student in _students)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var exists = await Context.Students
|
|
.FirstOrDefaultAsync(e => e.FirstName == student.FirstName && e.LastName == student.LastName, token);
|
|
if (exists != null)
|
|
continue;
|
|
await Context.Students.AddAsync(student, token);
|
|
added++;
|
|
}
|
|
|
|
await Context.SaveChangesAsync(token);
|
|
|
|
var notesCreated = 0;
|
|
var notesUpdated = 0;
|
|
if (_leftoverFieldNames.Count > 0)
|
|
{
|
|
var students = await Context.Students
|
|
.AsNoTracking()
|
|
.OrderBy(s => s.LastName)
|
|
.ThenBy(s => s.FirstName)
|
|
.ToListAsync(token);
|
|
var notes = await NotesService.GetStudentNotesAsync(students.Select(s => s.Id));
|
|
var existing = notes.ToDictionary(k => k.Key, v => v.Value.Content);
|
|
|
|
await using var csvStream = new MemoryStream(_fileBytes, writable: false);
|
|
var parseResult = NotesImportService.Parse(csvStream, students, existing);
|
|
if (parseResult.IsSuccess && parseResult.StudentsWithChanges > 0)
|
|
{
|
|
var saveResult = await NotesSaveService.SaveAsync(parseResult, token);
|
|
notesCreated = saveResult.NotesCreated;
|
|
notesUpdated = saveResult.NotesUpdated;
|
|
}
|
|
}
|
|
|
|
if (_isDisposed)
|
|
return;
|
|
|
|
Snackbar.Add(
|
|
$"Added {added} student(s). Notes created {notesCreated}, updated {notesUpdated}.",
|
|
Severity.Success);
|
|
NavigationManager.NavigateTo("/students");
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error saving imported students");
|
|
if (!_isDisposed)
|
|
Snackbar.Add($"Error saving students: {ex.Message}", Severity.Error);
|
|
}
|
|
finally
|
|
{
|
|
_isSaving = false;
|
|
}
|
|
}
|
|
|
|
private void HandleClear()
|
|
{
|
|
_fileBytes = null;
|
|
_fileName = null;
|
|
ResetParse();
|
|
}
|
|
|
|
private void ResetParse()
|
|
{
|
|
_students = null;
|
|
_leftoverFieldNames = [];
|
|
_newStudentCount = 0;
|
|
_existingStudentCount = 0;
|
|
_parseError = null;
|
|
}
|
|
|
|
private static List<string> PeekLeftoverFieldNames(byte[] csvBytes)
|
|
{
|
|
using var reader = new StreamReader(new MemoryStream(csvBytes));
|
|
using var parser = new StudentNotesFieldParser(reader);
|
|
return parser.PeekLeftoverFieldNames();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
_isDisposed = true;
|
|
_cancellationTokenSource?.Cancel();
|
|
_cancellationTokenSource?.Dispose();
|
|
_cancellationTokenSource = null;
|
|
}
|
|
|
|
await ValueTask.CompletedTask;
|
|
}
|
|
}
|