feat: import leftover student fields into notes and show them on the roster
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>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using Core.Entities;
|
||||
using Core.Notes;
|
||||
|
||||
namespace Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a student notes field CSV.
|
||||
/// </summary>
|
||||
public class StudentNotesImportResult
|
||||
{
|
||||
public List<StudentNotesImportMatch> Matches { get; set; } = [];
|
||||
|
||||
public List<StudentNotesImportIssue> Issues { get; set; } = [];
|
||||
|
||||
public List<string> Errors { get; set; } = [];
|
||||
|
||||
public List<string> Warnings { get; set; } = [];
|
||||
|
||||
public IReadOnlyList<string> FieldNames { get; set; } = [];
|
||||
|
||||
public bool IsSuccess => Errors.Count == 0;
|
||||
|
||||
public int StudentsWithChanges => Matches.Count(m => m.Merge.Changed);
|
||||
}
|
||||
|
||||
public class StudentNotesImportMatch
|
||||
{
|
||||
public required Student Student { get; set; }
|
||||
|
||||
public string RawStudentName { get; set; } = string.Empty;
|
||||
|
||||
public int RowNumber { get; set; }
|
||||
|
||||
public int StudentScore { get; set; }
|
||||
|
||||
public List<ImportedField> IncomingFields { get; set; } = [];
|
||||
|
||||
public required ImportedFieldsMergeResult Merge { get; set; }
|
||||
}
|
||||
|
||||
public class StudentNotesImportIssue
|
||||
{
|
||||
public int RowNumber { get; set; }
|
||||
|
||||
public string RawStudentName { get; set; } = string.Empty;
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Core.Notes;
|
||||
|
||||
/// <summary>
|
||||
/// Parses and upserts a generic Field/Value markdown table under a stable heading.
|
||||
/// </summary>
|
||||
public static class ImportedFieldsTable
|
||||
{
|
||||
public const string Heading = "## Imported fields";
|
||||
|
||||
public static string NormalizeValue(string? raw)
|
||||
{
|
||||
var value = (raw ?? string.Empty).Trim();
|
||||
if (value.Equals("x", StringComparison.OrdinalIgnoreCase))
|
||||
return "Yes";
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads Field/Value rows from the Imported fields section.
|
||||
/// </summary>
|
||||
public static List<ImportedField> ParseFields(string? markdown)
|
||||
{
|
||||
var section = ExtractSection(markdown);
|
||||
if (string.IsNullOrWhiteSpace(section))
|
||||
return [];
|
||||
|
||||
List<ImportedField> fields = [];
|
||||
foreach (var rawLine in section.Split('\n'))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (!line.StartsWith('|') || line.Contains("---", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var cells = SplitTableCells(line);
|
||||
if (cells.Length < 2)
|
||||
continue;
|
||||
|
||||
var field = UnescapeCell(cells[0]);
|
||||
var value = UnescapeCell(cells[1]);
|
||||
if (field.Equals("Field", StringComparison.OrdinalIgnoreCase)
|
||||
&& value.Equals("Value", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
fields.Add(new ImportedField(field, value));
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts incoming fields into the Imported fields section. Incoming values win.
|
||||
/// Fields not in <paramref name="incoming"/> are kept. Identical values are not changes.
|
||||
/// </summary>
|
||||
public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming)
|
||||
{
|
||||
var existing = ParseFields(existingMarkdown);
|
||||
var merged = existing.ToList();
|
||||
List<ImportedFieldChange> changes = [];
|
||||
|
||||
foreach (var incomingField in incoming)
|
||||
{
|
||||
var name = incomingField.Name.Trim();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
var value = NormalizeValue(incomingField.Value);
|
||||
var index = merged.FindIndex(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (index < 0)
|
||||
{
|
||||
merged.Add(new ImportedField(name, value));
|
||||
changes.Add(new ImportedFieldChange
|
||||
{
|
||||
Field = name,
|
||||
PreviousValue = null,
|
||||
NewValue = value,
|
||||
IsNew = true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var current = merged[index];
|
||||
if (string.Equals(current.Value, value, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
merged[index] = new ImportedField(current.Name, value);
|
||||
changes.Add(new ImportedFieldChange
|
||||
{
|
||||
Field = current.Name,
|
||||
PreviousValue = current.Value,
|
||||
NewValue = value,
|
||||
IsNew = false
|
||||
});
|
||||
}
|
||||
|
||||
var section = FormatSection(merged);
|
||||
var markdown = ReplaceSection(existingMarkdown, section);
|
||||
|
||||
return new ImportedFieldsMergeResult
|
||||
{
|
||||
Markdown = markdown,
|
||||
Changed = changes.Count > 0,
|
||||
Changes = changes
|
||||
};
|
||||
}
|
||||
|
||||
public static string FormatSection(IReadOnlyList<ImportedField> fields)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(Heading);
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("| Field | Value |");
|
||||
builder.AppendLine("| --- | --- |");
|
||||
foreach (var field in fields)
|
||||
builder.AppendLine($"| {EscapeCell(field.Name)} | {EscapeCell(field.Value)} |");
|
||||
|
||||
return builder.ToString().TrimEnd() + Environment.NewLine;
|
||||
}
|
||||
|
||||
public static string? GetFieldValue(string? markdown, string fieldName)
|
||||
{
|
||||
var field = ParseFields(markdown)
|
||||
.FirstOrDefault(f => f.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase));
|
||||
return field?.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique imported field names across notes, first-seen casing, sorted A–Z.
|
||||
/// </summary>
|
||||
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
|
||||
{
|
||||
Dictionary<string, string> names = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var markdown in markdowns)
|
||||
{
|
||||
foreach (var field in ParseFields(markdown))
|
||||
{
|
||||
var name = field.Name.Trim();
|
||||
if (name.Length == 0)
|
||||
continue;
|
||||
names.TryAdd(name, name);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. names.Values.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)];
|
||||
}
|
||||
|
||||
private static string? ExtractSection(string? markdown)
|
||||
{
|
||||
if (string.IsNullOrEmpty(markdown))
|
||||
return null;
|
||||
|
||||
var start = IndexOfHeading(markdown);
|
||||
if (start < 0)
|
||||
return null;
|
||||
|
||||
var afterHeading = start + Heading.Length;
|
||||
var nextHeading = FindNextHeading(markdown, afterHeading);
|
||||
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
|
||||
}
|
||||
|
||||
private static string ReplaceSection(string? existingMarkdown, string section)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingMarkdown))
|
||||
return section.TrimEnd() + Environment.NewLine;
|
||||
|
||||
var start = IndexOfHeading(existingMarkdown);
|
||||
if (start < 0)
|
||||
{
|
||||
var prefix = existingMarkdown.TrimEnd();
|
||||
return string.IsNullOrEmpty(prefix)
|
||||
? section
|
||||
: prefix + Environment.NewLine + Environment.NewLine + section;
|
||||
}
|
||||
|
||||
var afterHeading = start + Heading.Length;
|
||||
var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
|
||||
var before = existingMarkdown[..start].TrimEnd();
|
||||
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
|
||||
|
||||
var builder = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(before))
|
||||
{
|
||||
builder.Append(before);
|
||||
builder.AppendLine();
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.Append(section.TrimEnd());
|
||||
builder.AppendLine();
|
||||
|
||||
if (!string.IsNullOrEmpty(after))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.Append(after.TrimEnd());
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static int IndexOfHeading(string markdown) =>
|
||||
markdown.IndexOf(Heading, StringComparison.Ordinal);
|
||||
|
||||
private static int FindNextHeading(string markdown, int startIndex)
|
||||
{
|
||||
var index = startIndex;
|
||||
while (index < markdown.Length)
|
||||
{
|
||||
var lineStart = markdown.IndexOf('\n', index);
|
||||
if (lineStart < 0)
|
||||
return -1;
|
||||
|
||||
lineStart++;
|
||||
if (lineStart < markdown.Length && markdown[lineStart] == '#' &&
|
||||
lineStart + 2 < markdown.Length && markdown[lineStart + 1] == '#' &&
|
||||
markdown[lineStart + 2] == ' ')
|
||||
return lineStart;
|
||||
|
||||
index = lineStart;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a markdown table row into cells. Leading/trailing pipes are ignored so a blank
|
||||
/// value (e.g. <c>| Teacher Rec 3 | |</c>) is kept instead of dropped.
|
||||
/// </summary>
|
||||
private static string[] SplitTableCells(string line)
|
||||
{
|
||||
var parts = line.Split('|', StringSplitOptions.TrimEntries);
|
||||
var start = 0;
|
||||
var length = parts.Length;
|
||||
if (length > 0 && parts[0].Length == 0)
|
||||
{
|
||||
start = 1;
|
||||
length--;
|
||||
}
|
||||
|
||||
if (length > 0 && parts[start + length - 1].Length == 0)
|
||||
length--;
|
||||
|
||||
return length <= 0 ? [] : parts[start..(start + length)];
|
||||
}
|
||||
|
||||
private static string EscapeCell(string value) => value.Replace("|", "\\|");
|
||||
|
||||
private static string UnescapeCell(string value) => value.Replace("\\|", "|");
|
||||
}
|
||||
|
||||
public record ImportedField(string Name, string Value);
|
||||
|
||||
public class ImportedFieldsMergeResult
|
||||
{
|
||||
public required string Markdown { get; init; }
|
||||
|
||||
public bool Changed { get; init; }
|
||||
|
||||
public List<ImportedFieldChange> Changes { get; init; } = [];
|
||||
}
|
||||
|
||||
public class ImportedFieldChange
|
||||
{
|
||||
public required string Field { get; init; }
|
||||
|
||||
public string? PreviousValue { get; init; }
|
||||
|
||||
public required string NewValue { get; init; }
|
||||
|
||||
public bool IsNew { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Core.Notes;
|
||||
|
||||
/// <summary>
|
||||
/// Default imported-field names used for Students index columns and the CSV template.
|
||||
/// </summary>
|
||||
public static class StudentNoteFieldDefaults
|
||||
{
|
||||
public static readonly string[] IndexColumns =
|
||||
[
|
||||
"Interview Time",
|
||||
"Application",
|
||||
"Club Permission Slip"
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Core.Entities;
|
||||
using FuzzySharp;
|
||||
|
||||
namespace Core.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Fuzzy-matches a CSV or pasted name to existing students.
|
||||
/// </summary>
|
||||
public static class FuzzyStudentMatcher
|
||||
{
|
||||
public const int MatchThreshold = 90;
|
||||
|
||||
public static (Student Student, int Score)? Find(ICollection<Student> students, string name)
|
||||
{
|
||||
var ranked = students
|
||||
.Select(s => (Student: s, Score: Score(s, name)))
|
||||
.Where(x => x.Score >= MatchThreshold)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
|
||||
return ranked.Count == 0 ? null : ranked[0];
|
||||
}
|
||||
|
||||
public static int Score(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)));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace Core.Parsers;
|
||||
|
||||
public class StudentEventRankingParser : CsvParserBase
|
||||
{
|
||||
public const int StudentMatchThreshold = 90;
|
||||
public const int EventMatchThreshold = 70;
|
||||
public const int EventAmbiguityGap = 8;
|
||||
|
||||
@@ -60,7 +59,7 @@ public class StudentEventRankingParser : CsvParserBase
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
var studentMatch = FindStudent(students, name);
|
||||
var studentMatch = FuzzyStudentMatcher.Find(students, name);
|
||||
if (studentMatch is null)
|
||||
{
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
@@ -172,23 +171,6 @@ public class StudentEventRankingParser : CsvParserBase
|
||||
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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Core.Notes;
|
||||
|
||||
namespace Core.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a starter CSV for <c>/students/import</c> with roster columns plus leftover note fields.
|
||||
/// </summary>
|
||||
public static class StudentImportCsvTemplate
|
||||
{
|
||||
public static readonly string[] RosterHeaders =
|
||||
[
|
||||
"Student Name",
|
||||
"Grade",
|
||||
"TSA year",
|
||||
"State ID",
|
||||
"Regional ID",
|
||||
"National ID"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Returns a CSV with a header row and one example data row.
|
||||
/// </summary>
|
||||
public static string Build(IEnumerable<string>? leftoverFieldNames = null)
|
||||
{
|
||||
List<string> leftovers = leftoverFieldNames?
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !StudentNotesFieldParser.IsReservedHeader(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList() ?? [];
|
||||
|
||||
if (leftovers.Count == 0)
|
||||
leftovers = [.. StudentNoteFieldDefaults.IndexColumns];
|
||||
|
||||
var headers = RosterHeaders.Concat(leftovers).ToArray();
|
||||
var values = headers.Select(ExampleValue).ToArray();
|
||||
return $"{ToCsvRow(headers)}{Environment.NewLine}{ToCsvRow(values)}{Environment.NewLine}";
|
||||
}
|
||||
|
||||
private static string ExampleValue(string header) => header switch
|
||||
{
|
||||
"Student Name" => "Last, First",
|
||||
"Grade" => "9",
|
||||
"TSA year" => "1st",
|
||||
"Interview Time" => "3:20-3:35",
|
||||
_ when ContainsIgnoreCase(header, "Application")
|
||||
|| ContainsIgnoreCase(header, "Permission") => "x",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
private static bool ContainsIgnoreCase(string value, string part) =>
|
||||
value.Contains(part, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string ToCsvRow(IEnumerable<string> cells) =>
|
||||
string.Join(",", cells.Select(EscapeCsv));
|
||||
|
||||
private static string EscapeCsv(string value)
|
||||
{
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using Core.Notes;
|
||||
|
||||
namespace Core.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses leftover CSV columns (not roster or ranking) into student note field merges.
|
||||
/// </summary>
|
||||
public class StudentNotesFieldParser : CsvParserBase
|
||||
{
|
||||
private static readonly HashSet<string> ReservedHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Student Name",
|
||||
"Grade",
|
||||
"TSA year",
|
||||
"State ID",
|
||||
"Regional ID",
|
||||
"National ID",
|
||||
"Officer",
|
||||
"TOTAL # OF EVENTS"
|
||||
};
|
||||
|
||||
public StudentNotesFieldParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
|
||||
{
|
||||
}
|
||||
|
||||
public StudentNotesFieldParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roster and ranking columns that must not become imported note fields.
|
||||
/// </summary>
|
||||
public static bool IsReservedHeader(string? header)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header))
|
||||
return true;
|
||||
|
||||
var trimmed = header.Trim();
|
||||
if (ReservedHeaders.Contains(trimmed))
|
||||
return true;
|
||||
|
||||
return int.TryParse(trimmed, out var rank)
|
||||
&& rank >= 1
|
||||
&& rank <= StudentEventRanking.MaxRank;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leftover field names from a header row after reserved columns are removed.
|
||||
/// </summary>
|
||||
public static List<string> GetLeftoverFieldNames(IEnumerable<string?> headers) =>
|
||||
headers
|
||||
.Where(h => !IsReservedHeader(h))
|
||||
.Select(h => h!.Trim())
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Reads the header row and returns leftover field names without processing data rows.
|
||||
/// </summary>
|
||||
public List<string> PeekLeftoverFieldNames()
|
||||
{
|
||||
CsvReader.Read();
|
||||
CsvReader.ReadHeader();
|
||||
return GetLeftoverFieldNames(CsvReader.HeaderRecord ?? []);
|
||||
}
|
||||
|
||||
public StudentNotesImportResult Parse(
|
||||
ICollection<Student> students,
|
||||
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
|
||||
{
|
||||
var result = new StudentNotesImportResult();
|
||||
|
||||
CsvReader.Read();
|
||||
CsvReader.ReadHeader();
|
||||
|
||||
if (CsvReader.HeaderRecord is null ||
|
||||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Errors.Add("CSV must include a 'Student Name' column.");
|
||||
return result;
|
||||
}
|
||||
|
||||
var fieldNames = GetLeftoverFieldNames(CsvReader.HeaderRecord);
|
||||
result.FieldNames = fieldNames;
|
||||
|
||||
if (fieldNames.Count == 0)
|
||||
result.Warnings.Add("No leftover field columns were found besides roster and ranking columns.");
|
||||
|
||||
Dictionary<int, PendingStudentFields> pendingByStudentId = [];
|
||||
|
||||
while (CsvReader.Read())
|
||||
{
|
||||
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
|
||||
var name = CsvReader.GetField("Student Name")?.Trim();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
var studentMatch = FuzzyStudentMatcher.Find(students, name);
|
||||
if (studentMatch is null)
|
||||
{
|
||||
result.Issues.Add(new StudentNotesImportIssue
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
RawStudentName = name,
|
||||
Message = $"No student matched '{name}'."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (student, score) = studentMatch.Value;
|
||||
if (!pendingByStudentId.TryGetValue(student.Id, out var pending))
|
||||
{
|
||||
pending = new PendingStudentFields(student);
|
||||
pendingByStudentId[student.Id] = pending;
|
||||
}
|
||||
|
||||
pending.RawStudentName = name;
|
||||
pending.RowNumber = rowNumber;
|
||||
pending.StudentScore = score;
|
||||
|
||||
foreach (var fieldName in fieldNames)
|
||||
{
|
||||
var raw = CsvReader.GetField(fieldName);
|
||||
pending.Fields[fieldName.Trim()] = raw ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pending in pendingByStudentId.Values)
|
||||
{
|
||||
List<ImportedField> incoming = [.. pending.Fields.Select(pair => new ImportedField(pair.Key, pair.Value))];
|
||||
existingNotesByStudentId.TryGetValue(pending.Student.Id, out var existingMarkdown);
|
||||
var merge = ImportedFieldsTable.Merge(existingMarkdown, incoming);
|
||||
|
||||
result.Matches.Add(new StudentNotesImportMatch
|
||||
{
|
||||
Student = pending.Student,
|
||||
RawStudentName = pending.RawStudentName,
|
||||
RowNumber = pending.RowNumber,
|
||||
StudentScore = pending.StudentScore,
|
||||
IncomingFields = incoming,
|
||||
Merge = merge
|
||||
});
|
||||
}
|
||||
|
||||
if (result.Matches.Count == 0 && result.Errors.Count == 0)
|
||||
result.Warnings.Add("No students were matched from the CSV.");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class PendingStudentFields(Student student)
|
||||
{
|
||||
public Student Student { get; } = student;
|
||||
|
||||
public string RawStudentName { get; set; } = string.Empty;
|
||||
|
||||
public int RowNumber { get; set; }
|
||||
|
||||
public int StudentScore { get; set; }
|
||||
|
||||
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,19 @@ public interface INoteNamingService
|
||||
/// <param name="noteTitle">The note title to check</param>
|
||||
/// <returns>True if the note is a meeting note, false otherwise</returns>
|
||||
bool IsMeetingNote(string noteTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the title for a student note. Format: "#Student:{id}"
|
||||
/// </summary>
|
||||
string GetStudentNoteTitle(int studentId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a note title is a student note.
|
||||
/// </summary>
|
||||
bool IsStudentNote(string? noteTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the student id from a student note title.
|
||||
/// </summary>
|
||||
bool TryParseStudentNoteId(string? noteTitle, out int studentId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
|
||||
namespace Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parses student field CSVs and merges them into markdown notes.
|
||||
/// </summary>
|
||||
public interface IStudentNotesImportService
|
||||
{
|
||||
StudentNotesImportResult Parse(
|
||||
Stream stream,
|
||||
ICollection<Student> students,
|
||||
IReadOnlyDictionary<int, string?> existingNotesByStudentId);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public class NoteNamingService : INoteNamingService
|
||||
{
|
||||
private const string PageNotePrefix = "#";
|
||||
private const string MeetingNotePrefix = "#Meeting Notes";
|
||||
private const string StudentNotePrefix = "#Student:";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string GetMeetingNoteTitle(DateTime meetingDate)
|
||||
@@ -47,4 +48,26 @@ public class NoteNamingService : INoteNamingService
|
||||
|
||||
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string GetStudentNoteTitle(int studentId) => $"{StudentNotePrefix}{studentId}";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsStudentNote(string? noteTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(noteTitle))
|
||||
return false;
|
||||
|
||||
return noteTitle.StartsWith(StudentNotePrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryParseStudentNoteId(string? noteTitle, out int studentId)
|
||||
{
|
||||
studentId = 0;
|
||||
if (!IsStudentNote(noteTitle))
|
||||
return false;
|
||||
|
||||
return int.TryParse(noteTitle.AsSpan(StudentNotePrefix.Length), out studentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Core.Models;
|
||||
|
||||
namespace Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Decides which parsed student notes should be created or updated.
|
||||
/// Unchanged merges are omitted so a re-import does not write history.
|
||||
/// </summary>
|
||||
public static class StudentNotesImportPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds persist actions from a parse result. Only matches with field changes are included.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<StudentNotePersistAction> Create(
|
||||
StudentNotesImportResult parseResult,
|
||||
IReadOnlySet<int> studentIdsWithExistingNotes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parseResult);
|
||||
ArgumentNullException.ThrowIfNull(studentIdsWithExistingNotes);
|
||||
|
||||
List<StudentNotePersistAction> actions = [];
|
||||
foreach (var match in parseResult.Matches)
|
||||
{
|
||||
if (!match.Merge.Changed)
|
||||
continue;
|
||||
|
||||
actions.Add(new StudentNotePersistAction
|
||||
{
|
||||
StudentId = match.Student.Id,
|
||||
Markdown = match.Merge.Markdown,
|
||||
Kind = studentIdsWithExistingNotes.Contains(match.Student.Id)
|
||||
? StudentNotePersistKind.Update
|
||||
: StudentNotePersistKind.Create
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
public enum StudentNotePersistKind
|
||||
{
|
||||
Create,
|
||||
Update
|
||||
}
|
||||
|
||||
public class StudentNotePersistAction
|
||||
{
|
||||
public required int StudentId { get; init; }
|
||||
|
||||
public required string Markdown { get; init; }
|
||||
|
||||
public required StudentNotePersistKind Kind { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using Core.Parsers;
|
||||
|
||||
namespace Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <see cref="StudentNotesFieldParser"/> for stream-based import.
|
||||
/// </summary>
|
||||
public class StudentNotesImportService : IStudentNotesImportService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public StudentNotesImportResult Parse(
|
||||
Stream stream,
|
||||
ICollection<Student> students,
|
||||
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
|
||||
{
|
||||
var reader = new StreamReader(stream, leaveOpen: true);
|
||||
using var parser = new StudentNotesFieldParser(reader);
|
||||
return parser.Parse(students, existingNotesByStudentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Core.Notes;
|
||||
|
||||
namespace Tests.Notes;
|
||||
|
||||
[TestFixture]
|
||||
public class ImportedFieldsTable_Tests
|
||||
{
|
||||
[Test]
|
||||
public void NormalizeValue_X_BecomesYes()
|
||||
{
|
||||
Assert.That(ImportedFieldsTable.NormalizeValue("x"), Is.EqualTo("Yes"));
|
||||
Assert.That(ImportedFieldsTable.NormalizeValue("X"), Is.EqualTo("Yes"));
|
||||
Assert.That(ImportedFieldsTable.NormalizeValue(" x "), Is.EqualTo("Yes"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NormalizeValue_Blank_StaysEmpty()
|
||||
{
|
||||
Assert.That(ImportedFieldsTable.NormalizeValue(null), Is.EqualTo(string.Empty));
|
||||
Assert.That(ImportedFieldsTable.NormalizeValue(" "), Is.EqualTo(string.Empty));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Merge_IncomingWins_AndAppendsNewFields()
|
||||
{
|
||||
var existing = """
|
||||
Freeform note
|
||||
|
||||
## Imported fields
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Interview Time | 1:00-1:15 |
|
||||
| Application | Yes |
|
||||
""";
|
||||
|
||||
var result = ImportedFieldsTable.Merge(existing,
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35"),
|
||||
new ImportedField("Teacher Rec 1", "Fuqua")
|
||||
]);
|
||||
|
||||
Assert.That(result.Changed, Is.True);
|
||||
Assert.That(result.Changes, Has.Count.EqualTo(2));
|
||||
var fields = ImportedFieldsTable.ParseFields(result.Markdown);
|
||||
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
|
||||
Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
|
||||
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
|
||||
Assert.That(result.Markdown, Does.Contain("Freeform note"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Merge_SameDataTwice_IsNoOp()
|
||||
{
|
||||
var first = ImportedFieldsTable.Merge(null,
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35"),
|
||||
new ImportedField("Application", "x")
|
||||
]);
|
||||
|
||||
Assert.That(first.Changed, Is.True);
|
||||
Assert.That(ImportedFieldsTable.ParseFields(first.Markdown).Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
|
||||
|
||||
var second = ImportedFieldsTable.Merge(first.Markdown,
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35"),
|
||||
new ImportedField("Application", "x")
|
||||
]);
|
||||
|
||||
Assert.That(second.Changed, Is.False);
|
||||
Assert.That(second.Changes, Is.Empty);
|
||||
Assert.That(ImportedFieldsTable.ParseFields(second.Markdown), Is.EqualTo(ImportedFieldsTable.ParseFields(first.Markdown)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetFieldValue_ReadsImportedTable()
|
||||
{
|
||||
var markdown = ImportedFieldsTable.FormatSection(
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35")
|
||||
]);
|
||||
|
||||
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Interview Time"), Is.EqualTo("3:20-3:35"));
|
||||
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Merge_PreservesTextAfterSection()
|
||||
{
|
||||
var existing = """
|
||||
## Imported fields
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Application | Yes |
|
||||
|
||||
## Advisor comments
|
||||
|
||||
Great interview.
|
||||
""";
|
||||
|
||||
var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Application", "Yes")]);
|
||||
Assert.That(result.Changed, Is.False);
|
||||
Assert.That(result.Markdown, Does.Contain("## Advisor comments"));
|
||||
Assert.That(result.Markdown, Does.Contain("Great interview."));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseFields_KeepsBlankValues()
|
||||
{
|
||||
var markdown = ImportedFieldsTable.FormatSection(
|
||||
[
|
||||
new ImportedField("Application", "Yes"),
|
||||
new ImportedField("Teacher Rec 3", "")
|
||||
]);
|
||||
|
||||
var fields = ImportedFieldsTable.ParseFields(markdown);
|
||||
Assert.That(fields.Single(f => f.Name == "Teacher Rec 3").Value, Is.EqualTo(string.Empty));
|
||||
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Merge_BlankFields_SameDataTwice_IsNoOp()
|
||||
{
|
||||
var first = ImportedFieldsTable.Merge(null,
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35"),
|
||||
new ImportedField("Teacher Rec 3", "")
|
||||
]);
|
||||
|
||||
Assert.That(first.Changed, Is.True);
|
||||
Assert.That(ImportedFieldsTable.ParseFields(first.Markdown), Has.Count.EqualTo(2));
|
||||
|
||||
var second = ImportedFieldsTable.Merge(first.Markdown,
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35"),
|
||||
new ImportedField("Teacher Rec 3", "")
|
||||
]);
|
||||
|
||||
Assert.That(second.Changed, Is.False);
|
||||
Assert.That(second.Changes, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Merge_IncomingBlank_ClearsExistingValue()
|
||||
{
|
||||
var existing = ImportedFieldsTable.Merge(null, [new ImportedField("Teacher Rec 1", "Fuqua")]).Markdown;
|
||||
var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Teacher Rec 1", "")]);
|
||||
|
||||
Assert.That(result.Changed, Is.True);
|
||||
Assert.That(ImportedFieldsTable.GetFieldValue(result.Markdown, "Teacher Rec 1"), Is.EqualTo(string.Empty));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetFieldValue_IsCaseInsensitive()
|
||||
{
|
||||
var markdown = ImportedFieldsTable.FormatSection([new ImportedField("Interview Time", "3:20-3:35")]);
|
||||
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "interview time"), Is.EqualTo("3:20-3:35"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DistinctFieldNames_UnionsNotes_AndKeepsFirstCasing()
|
||||
{
|
||||
var first = ImportedFieldsTable.FormatSection(
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35"),
|
||||
new ImportedField("Application", "Yes")
|
||||
]);
|
||||
var second = ImportedFieldsTable.FormatSection(
|
||||
[
|
||||
new ImportedField("application", "Yes"),
|
||||
new ImportedField("Teacher Rec 1", "Fuqua")
|
||||
]);
|
||||
|
||||
var names = ImportedFieldsTable.DistinctFieldNames([first, second, null, ""]);
|
||||
|
||||
Assert.That(names, Is.EqualTo(new[] { "Application", "Interview Time", "Teacher Rec 1" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Core.Parsers;
|
||||
|
||||
namespace Tests.Parsers;
|
||||
|
||||
[TestFixture]
|
||||
public class StudentImportCsvTemplate_Tests
|
||||
{
|
||||
[Test]
|
||||
public void Build_IncludesRosterAndDefaultLeftoverColumns()
|
||||
{
|
||||
var csv = StudentImportCsvTemplate.Build();
|
||||
var header = csv.Split('\n')[0].TrimEnd('\r');
|
||||
|
||||
Assert.That(header, Does.StartWith("Student Name,Grade,TSA year,State ID,Regional ID,National ID"));
|
||||
Assert.That(header, Does.Contain("Interview Time"));
|
||||
Assert.That(header, Does.Contain("Application"));
|
||||
Assert.That(csv, Does.Contain("\"Last, First\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_UsesConfiguredLeftoverFields_AndSkipsReserved()
|
||||
{
|
||||
var csv = StudentImportCsvTemplate.Build(["Teacher Rec 1", "Grade", " Application "]);
|
||||
var header = csv.Split('\n')[0].TrimEnd('\r');
|
||||
|
||||
Assert.That(header, Does.Contain("Teacher Rec 1"));
|
||||
Assert.That(header, Does.Contain("Application"));
|
||||
Assert.That(header.Split(',').Count(c => c.Equals("Grade", StringComparison.OrdinalIgnoreCase)), Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Text;
|
||||
using Core.Notes;
|
||||
using Core.Parsers;
|
||||
using Tests.Builders;
|
||||
|
||||
namespace Tests.Parsers;
|
||||
|
||||
[TestFixture]
|
||||
public class StudentNotesFieldParser_Tests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
BuilderExtensions.ResetAllBuilders();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_MatchesStudentAndReportsUnmatched()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
const string csv = """
|
||||
Student Name,Interview Time,Application
|
||||
"Chittenden, Aria",3:20-3:35,x
|
||||
Nobody Here,1:00-1:15,x
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria]);
|
||||
|
||||
Assert.That(result.IsSuccess, Is.True);
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Student, Is.SameAs(aria));
|
||||
Assert.That(result.Matches[0].Merge.Changed, Is.True);
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_SameExistingNote_HasNoChanges()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
var existing = ImportedFieldsTable.Merge(null,
|
||||
[
|
||||
new ImportedField("Interview Time", "3:20-3:35"),
|
||||
new ImportedField("Application", "Yes")
|
||||
]).Markdown;
|
||||
|
||||
const string csv = """
|
||||
Student Name,Interview Time,Application
|
||||
Aria Chittenden,3:20-3:35,x
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = existing });
|
||||
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Merge.Changed, Is.False);
|
||||
Assert.That(result.StudentsWithChanges, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_MissingStudentNameColumn_IsError()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
const string csv = """
|
||||
Name,Interview Time
|
||||
Aria Chittenden,3:20-3:35
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria]);
|
||||
|
||||
Assert.That(result.IsSuccess, Is.False);
|
||||
Assert.That(result.Errors, Has.Some.Contains("Student Name"));
|
||||
Assert.That(result.Matches, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_PreservesFieldsNotInCsv()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
var existing = ImportedFieldsTable.Merge(null,
|
||||
[
|
||||
new ImportedField("Interview Time", "1:00-1:15"),
|
||||
new ImportedField("Teacher Rec 1", "Fuqua")
|
||||
]).Markdown;
|
||||
|
||||
const string csv = """
|
||||
Student Name,Interview Time
|
||||
Aria Chittenden,3:20-3:35
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = existing });
|
||||
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
|
||||
|
||||
Assert.That(result.Matches[0].Merge.Changed, Is.True);
|
||||
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
|
||||
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_X_BecomesYes_AndBlankFieldsRoundTrip()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
const string csv = """
|
||||
Student Name,Application,Teacher Rec 3
|
||||
Aria Chittenden,x,
|
||||
""";
|
||||
|
||||
var first = Parse(csv, [aria]);
|
||||
var markdown = first.Matches[0].Merge.Markdown;
|
||||
|
||||
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.EqualTo("Yes"));
|
||||
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
|
||||
|
||||
var second = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = markdown });
|
||||
Assert.That(second.Matches[0].Merge.Changed, Is.False);
|
||||
Assert.That(second.StudentsWithChanges, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_DuplicateStudentRows_LastValueWins_OneMatch()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
const string csv = """
|
||||
Student Name,Interview Time,Teacher Rec 1
|
||||
Aria Chittenden,1:00-1:15,Fuqua
|
||||
"Chittenden, Aria",3:20-3:35,Young
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria]);
|
||||
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
|
||||
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
|
||||
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Young"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_NameOnlyCsv_WarnsAndMatchesWithoutFieldChanges()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
const string csv = """
|
||||
Student Name
|
||||
Aria Chittenden
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria]);
|
||||
|
||||
Assert.That(result.IsSuccess, Is.True);
|
||||
Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Merge.Changed, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_ExcludesRosterAndRankingColumns()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
const string csv = """
|
||||
Student Name,Grade,TSA year,State ID,Regional ID,National ID,Officer,1,2,TOTAL # OF EVENTS,Interview Time,Application
|
||||
Aria Chittenden,6,1st,,,,,Coding,JSS,2,3:20-3:35,x
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria]);
|
||||
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
|
||||
|
||||
Assert.That(result.FieldNames, Is.EquivalentTo(["Interview Time", "Application"]));
|
||||
Assert.That(fields.Select(f => f.Name), Is.EquivalentTo(["Interview Time", "Application"]));
|
||||
Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
|
||||
Assert.That(fields.Any(f => f.Name is "Grade" or "1" or "Officer"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_RosterOnlyCsv_HasNoLeftoverFields()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
const string csv = """
|
||||
Student Name,Grade,TSA year,State ID,Regional ID,National ID
|
||||
Aria Chittenden,6,1st,,,
|
||||
""";
|
||||
|
||||
var result = Parse(csv, [aria]);
|
||||
|
||||
Assert.That(result.FieldNames, Is.Empty);
|
||||
Assert.That(result.Matches[0].Merge.Changed, Is.False);
|
||||
Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
|
||||
}
|
||||
|
||||
private static Core.Models.StudentNotesImportResult Parse(
|
||||
string csv,
|
||||
ICollection<Core.Entities.Student> students,
|
||||
IReadOnlyDictionary<int, string?>? notes = null)
|
||||
{
|
||||
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
|
||||
using var parser = new StudentNotesFieldParser(reader);
|
||||
return parser.Parse(students, notes ?? new Dictionary<int, string?>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Core.Services;
|
||||
|
||||
namespace Tests.Services;
|
||||
|
||||
[TestFixture]
|
||||
public class NoteNamingService_Tests
|
||||
{
|
||||
private readonly NoteNamingService _service = new();
|
||||
|
||||
[Test]
|
||||
public void GetStudentNoteTitle_UsesStablePrefix()
|
||||
{
|
||||
Assert.That(_service.GetStudentNoteTitle(42), Is.EqualTo("#Student:42"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsStudentNote_AndParseId()
|
||||
{
|
||||
Assert.That(_service.IsStudentNote("#Student:12"), Is.True);
|
||||
Assert.That(_service.IsStudentNote("#Students"), Is.False);
|
||||
Assert.That(_service.TryParseStudentNoteId("#Student:12", out var id), Is.True);
|
||||
Assert.That(id, Is.EqualTo(12));
|
||||
Assert.That(_service.TryParseStudentNoteId("#Event Ranking", out _), Is.False);
|
||||
Assert.That(_service.IsStudentNote(null), Is.False);
|
||||
Assert.That(_service.TryParseStudentNoteId("#Student:", out _), Is.False);
|
||||
Assert.That(_service.IsPageNote("#Student:12"), Is.True);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Core.Models;
|
||||
using Core.Notes;
|
||||
using Core.Services;
|
||||
using Tests.Builders;
|
||||
|
||||
namespace Tests.Services;
|
||||
|
||||
[TestFixture]
|
||||
public class StudentNotesImportPlan_Tests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
BuilderExtensions.ResetAllBuilders();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_SkipsUnchanged_CreatesAndUpdatesChanged()
|
||||
{
|
||||
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
var blake = StudentBuilder.Create("Blake", "Nguyen").Build();
|
||||
var casey = StudentBuilder.Create("Casey", "Ortiz").Build();
|
||||
|
||||
var firstWrite = ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]);
|
||||
var unchanged = ImportedFieldsTable.Merge(firstWrite.Markdown, [new ImportedField("Application", "Yes")]);
|
||||
var created = ImportedFieldsTable.Merge(null, [new ImportedField("Interview Time", "3:20-3:35")]);
|
||||
var updated = ImportedFieldsTable.Merge(
|
||||
ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]).Markdown,
|
||||
[new ImportedField("Application", "")]);
|
||||
|
||||
var parseResult = new StudentNotesImportResult
|
||||
{
|
||||
Matches =
|
||||
[
|
||||
Match(aria, unchanged),
|
||||
Match(blake, created),
|
||||
Match(casey, updated)
|
||||
]
|
||||
};
|
||||
|
||||
HashSet<int> existingNoteIds = [casey.Id];
|
||||
var actions = StudentNotesImportPlan.Create(parseResult, existingNoteIds);
|
||||
|
||||
Assert.That(actions, Has.Count.EqualTo(2));
|
||||
Assert.That(actions.Any(a => a.StudentId == aria.Id), Is.False);
|
||||
Assert.That(actions.Single(a => a.StudentId == blake.Id).Kind, Is.EqualTo(StudentNotePersistKind.Create));
|
||||
Assert.That(actions.Single(a => a.StudentId == casey.Id).Kind, Is.EqualTo(StudentNotePersistKind.Update));
|
||||
Assert.That(actions.Single(a => a.StudentId == casey.Id).Markdown, Is.EqualTo(updated.Markdown));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_EmptyMatches_ReturnsNoActions()
|
||||
{
|
||||
var actions = StudentNotesImportPlan.Create(new StudentNotesImportResult(), new HashSet<int>());
|
||||
Assert.That(actions, Is.Empty);
|
||||
}
|
||||
|
||||
private static StudentNotesImportMatch Match(Core.Entities.Student student, ImportedFieldsMergeResult merge) =>
|
||||
new()
|
||||
{
|
||||
Student = student,
|
||||
Merge = merge
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||
<script src="js/markdownTablePaste.js"></script>
|
||||
<script src="js/downloadFile.js"></script>
|
||||
<script src="js/login.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
@page "/events/import"
|
||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||
@implements IAsyncDisposable
|
||||
@using Core.Parsers
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Authentication
|
||||
@using WebApp.Models
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<EventCatalogImport> Logger
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageHeader
|
||||
Title="Import Event Catalog"
|
||||
Description="Add new event definitions from CSV. Existing event names are skipped."
|
||||
Icon="@AppIcons.Events"
|
||||
ShowBackButton="true"
|
||||
BackButtonUrl="/events" />
|
||||
|
||||
<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>Event</code>, <code>Team Size</code>, <code>State Count</code>.
|
||||
Optional: <code>Short Name</code>, <code>EventFormat</code>, <code>Level of Effort</code>,
|
||||
<code>Eligibility</code>, <code>Description</code>, <code>Theme</code>,
|
||||
<code>Documentation</code>, <code>State Presubmission</code>,
|
||||
<code>Semifinalist Activity</code>, <code>Regional Notes</code>.
|
||||
</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 (_events 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">
|
||||
@_events.Length event(s) parsed.
|
||||
@_newEventCount new, @_existingEventCount already in the database.
|
||||
</MudAlert>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Success"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="HandleSave"
|
||||
Disabled="@(_isSaving || _newEventCount == 0)">
|
||||
Save to Database
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private byte[]? _fileBytes;
|
||||
private string? _fileName;
|
||||
private EventDefinition[]? _events;
|
||||
private int _newEventCount;
|
||||
private int _existingEventCount;
|
||||
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 event catalog 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));
|
||||
_events = new EventDefinitionParser(reader).Parse();
|
||||
|
||||
var existingNames = await Context.Events
|
||||
.AsNoTracking()
|
||||
.Select(e => e.Name)
|
||||
.ToListAsync(token);
|
||||
var existingSet = existingNames.ToHashSet();
|
||||
|
||||
_existingEventCount = _events.Count(e => existingSet.Contains(e.Name));
|
||||
_newEventCount = _events.Length - _existingEventCount;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error parsing event catalog CSV");
|
||||
_events = null;
|
||||
_parseError = $"Error parsing CSV: {ex.Message}";
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add(_parseError, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isParsing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (_events 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 evt in _events)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var exists = await Context.Events.FirstOrDefaultAsync(e => e.Name == evt.Name, token);
|
||||
if (exists != null)
|
||||
continue;
|
||||
await Context.Events.AddAsync(evt, token);
|
||||
added++;
|
||||
}
|
||||
|
||||
await Context.SaveChangesAsync(token);
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
Snackbar.Add($"Added {added} event(s).", Severity.Success);
|
||||
NavigationManager.NavigateTo("/events");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error saving imported events");
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add($"Error saving events: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleClear()
|
||||
{
|
||||
_fileBytes = null;
|
||||
_fileName = null;
|
||||
ResetParse();
|
||||
}
|
||||
|
||||
private void ResetParse()
|
||||
{
|
||||
_events = null;
|
||||
_newEventCount = 0;
|
||||
_existingEventCount = 0;
|
||||
_parseError = null;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using WebApp.Authentication
|
||||
@inject AppDbContext Context
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -13,6 +14,11 @@
|
||||
<MudTooltip Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
</MudTooltip>
|
||||
<AuthorizeView Roles="@AuthRoles.Administrator">
|
||||
<MudTooltip Text="Add new catalog events from CSV. Existing names are skipped.">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/events/import" Variant="Variant.Outlined">Import</MudButton>
|
||||
</MudTooltip>
|
||||
</AuthorizeView>
|
||||
<MudTooltip Text="Printable Descriptions">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
||||
</MudTooltip>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
@using Core.Services
|
||||
@using PSC.Blazor.Components.MarkdownEditor
|
||||
@inject INotesService NotesService
|
||||
@inject INoteNamingService NoteNamingService
|
||||
@inject MarkdownTablePasteService MarkdownTablePasteService
|
||||
@inject ISnackbar Snackbar
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Notes</MudText>
|
||||
@if (_isLoading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (ReadOnly)
|
||||
{
|
||||
@if (string.IsNullOrWhiteSpace(_content))
|
||||
{
|
||||
<MudText Class="mud-text-secondary">No notes yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@((MarkupString)MarkdownHelper.ToHtml(_content))
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown is supported. Imported fields appear in a table and can be edited.</MudText>
|
||||
<MarkdownEditor Value="@_content"
|
||||
ValueChanged="@((string? value) => _content = value ?? string.Empty)"
|
||||
Placeholder="Student notes..."
|
||||
AutoSaveEnabled="false"
|
||||
NativeSpellChecker="false" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int StudentId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
private string _content = string.Empty;
|
||||
private int? _noteId;
|
||||
private int _loadedStudentId;
|
||||
private bool _isLoading = true;
|
||||
private bool _pasteInitialized;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (StudentId <= 0 || _loadedStudentId == StudentId)
|
||||
return;
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var note = await NotesService.GetStudentNoteAsync(StudentId);
|
||||
_noteId = note?.Id;
|
||||
_content = note?.Content ?? string.Empty;
|
||||
_loadedStudentId = StudentId;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (ReadOnly || _pasteInitialized)
|
||||
return;
|
||||
|
||||
await Task.Delay(150);
|
||||
await MarkdownTablePasteService.InitializeAsync();
|
||||
_pasteInitialized = true;
|
||||
}
|
||||
|
||||
public async Task SaveAsync()
|
||||
{
|
||||
if (StudentId <= 0)
|
||||
return;
|
||||
|
||||
if (_noteId is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_content))
|
||||
return;
|
||||
|
||||
var created = await NotesService.CreateNoteAsync(new Note
|
||||
{
|
||||
Title = NoteNamingService.GetStudentNoteTitle(StudentId),
|
||||
Content = _content
|
||||
});
|
||||
_noteId = created.Id;
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await NotesService.GetNoteAsync(_noteId.Value);
|
||||
if (existing is null)
|
||||
return;
|
||||
|
||||
if (string.Equals(existing.Content ?? string.Empty, _content, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
existing.Content = _content;
|
||||
await NotesService.UpdateNoteAsync(existing);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,10 @@
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||||
<StudentNotePanel StudentId="student.Id" ReadOnly="true" />
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private Student? student;
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
</MudSelect>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="5">
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
<StudentNotePanel @ref="_notePanel" StudentId="Student.Id" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</EditForm>
|
||||
|
||||
@@ -73,6 +78,7 @@
|
||||
private FormChangeTracker? _formChangeTracker;
|
||||
private EditContext? _editContext;
|
||||
private List<string> _validationErrors = new();
|
||||
private StudentNotePanel? _notePanel;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -120,6 +126,8 @@
|
||||
try
|
||||
{
|
||||
await Context.SaveChangesAsync();
|
||||
if (_notePanel is not null)
|
||||
await _notePanel.SaveAsync();
|
||||
Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
|
||||
_formChangeTracker?.AllowNavigation();
|
||||
NavigationManager.NavigateTo(ReturnUrl ?? "/students");
|
||||
|
||||
@@ -2,17 +2,40 @@
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Authentication
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@inject AppDbContext Context
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject INotesService NotesService
|
||||
@inject IConfiguration Configuration
|
||||
@inject IJSRuntime JSRuntime
|
||||
@using Core.Notes
|
||||
@using Core.Parsers
|
||||
@using WebApp.Services
|
||||
|
||||
<PageHeader Title="Students">
|
||||
<ActionButtons>
|
||||
<MudTooltip Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
</MudTooltip>
|
||||
<AuthorizeView Roles="@AuthRoles.Administrator">
|
||||
<MudButtonGroup Variant="Variant.Outlined">
|
||||
<MudTooltip Text="Add new students from CSV. Existing names are skipped; leftover columns merge into student notes.">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/students/import">Import</MudButton>
|
||||
</MudTooltip>
|
||||
<MudMenu Icon="@Icons.Material.Filled.ArrowDropDown"
|
||||
AriaLabel="More import actions"
|
||||
AnchorOrigin="Origin.BottomRight"
|
||||
TransformOrigin="Origin.TopRight">
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Download"
|
||||
OnClick="DownloadStudentImportTemplate">
|
||||
Download CSV template
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
</MudButtonGroup>
|
||||
</AuthorizeView>
|
||||
<MudTooltip Text="Event Rankings">
|
||||
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
|
||||
</MudTooltip>
|
||||
@@ -24,6 +47,7 @@
|
||||
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
<MudDataGrid T="Student"
|
||||
@key="NoteFieldColumnsKey"
|
||||
ServerData="ServerReload"
|
||||
@ref="_dataGrid"
|
||||
Filterable="true"
|
||||
@@ -65,6 +89,15 @@
|
||||
<span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear)
|
||||
</CellTemplate>
|
||||
</PropertyColumn>
|
||||
@foreach (var field in _noteFieldColumns)
|
||||
{
|
||||
var fieldName = field;
|
||||
<TemplateColumn Title="@fieldName" Sortable="false" Filterable="false">
|
||||
<CellTemplate>
|
||||
@GetNoteField(context.Item.Id, fieldName)
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
}
|
||||
</Columns>
|
||||
<PagerContent>
|
||||
<MudDataGridPager T="Student"></MudDataGridPager>
|
||||
@@ -77,12 +110,20 @@
|
||||
private bool _isLoading = true;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
private List<string> _noteFieldColumns = [];
|
||||
private Dictionary<int, string?> _noteContentByStudentId = [];
|
||||
private string NoteFieldColumnsKey => string.Join('\u001f', _noteFieldColumns);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_noteFieldColumns = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
|
||||
}
|
||||
|
||||
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
@@ -104,6 +145,9 @@
|
||||
var totalItems = await query.CountAsync(cancellationToken);
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
|
||||
|
||||
var notes = await NotesService.GetStudentNotesAsync(pagedData.Select(s => s.Id));
|
||||
_noteContentByStudentId = notes.ToDictionary(k => k.Key, v => v.Value.Content);
|
||||
|
||||
return new GridData<Student>
|
||||
{
|
||||
TotalItems = totalItems,
|
||||
@@ -160,6 +204,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken);
|
||||
Context.Students.Remove(studentToDelete);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -203,4 +248,42 @@
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task DownloadStudentImportTemplate()
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
var fromNotes = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
|
||||
var leftover = _noteFieldColumns
|
||||
.Concat(fromNotes)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
var csv = StudentImportCsvTemplate.Build(leftover);
|
||||
byte[] bytes = [..System.Text.Encoding.UTF8.GetPreamble(), ..System.Text.Encoding.UTF8.GetBytes(csv)];
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
await JSRuntime.InvokeVoidAsync("tsaDownload.fromBase64", "student-import-template.csv", "text/csv;charset=utf-8", base64);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add($"Could not download template: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetNoteField(int studentId, string fieldName)
|
||||
{
|
||||
if (!_noteContentByStudentId.TryGetValue(studentId, out var content))
|
||||
return string.Empty;
|
||||
|
||||
return ImportedFieldsTable.GetFieldValue(content, fieldName) ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/settings/chapter"
|
||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||
@implements IAsyncDisposable
|
||||
@using WebApp.Authentication
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -7,12 +8,13 @@
|
||||
@using Core.Models
|
||||
@inject IConfiguration Configuration
|
||||
@inject IChapterSettingsWriter ChapterSettingsWriter
|
||||
@inject INotesService NotesService
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageHeader
|
||||
Title="Chapter Settings"
|
||||
Description="Configure chapter information. Changes take effect on next application restart." />
|
||||
Description="Configure chapter information. Student index columns apply the next time you open Students. Printouts that cache chapter name or year may still need a restart." />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
|
||||
@@ -90,6 +92,41 @@
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Field names from each student's imported notes table to show as extra Students index columns. Click a field found in notes to add or remove it, or type names (one per line).
|
||||
</MudText>
|
||||
@if (_availableFields.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Fields in student notes</MudText>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" Class="mb-4">
|
||||
@foreach (var field in _availableFields)
|
||||
{
|
||||
var selected = IsFieldSelected(field);
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@(selected ? Color.Primary : Color.Default)"
|
||||
Variant="@(selected ? Variant.Filled : Variant.Outlined)"
|
||||
OnClick="() => ToggleField(field)">
|
||||
@field
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-3">
|
||||
No imported fields found in student notes yet.
|
||||
</MudText>
|
||||
}
|
||||
<MudTextField @bind-Value="_noteFieldsText"
|
||||
Label="Imported field columns"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="5"
|
||||
HelperText="Example: Interview Time" />
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-6">
|
||||
<MudGrid>
|
||||
<MudItem xs="12">
|
||||
@@ -125,14 +162,52 @@
|
||||
|
||||
@code {
|
||||
private Models.ChapterSettings? _settings;
|
||||
private string _noteFieldsText = string.Empty;
|
||||
private IReadOnlyList<string> _availableFields = [];
|
||||
private bool _isSaving;
|
||||
private string? _statusMessage;
|
||||
private Severity _statusSeverity = Severity.Success;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
||||
?? new Models.ChapterSettings();
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_settings = Models.ChapterSettings.FromConfiguration(Configuration);
|
||||
_noteFieldsText = string.Join(Environment.NewLine, _settings.StudentIndexNoteFields);
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
_availableFields = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> SelectedFields() =>
|
||||
_noteFieldsText
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
|
||||
private bool IsFieldSelected(string field) =>
|
||||
SelectedFields().Any(selected => selected.Equals(field, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void ToggleField(string field)
|
||||
{
|
||||
var selected = SelectedFields().ToList();
|
||||
var index = selected.FindIndex(name => name.Equals(field, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0)
|
||||
selected.RemoveAt(index);
|
||||
else
|
||||
selected.Add(field);
|
||||
|
||||
_noteFieldsText = string.Join(Environment.NewLine, selected);
|
||||
}
|
||||
|
||||
private async Task SaveSettings()
|
||||
@@ -144,18 +219,41 @@
|
||||
|
||||
try
|
||||
{
|
||||
await ChapterSettingsWriter.WriteAsync(_settings);
|
||||
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
||||
_settings.StudentIndexNoteFields = [.. SelectedFields()];
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
await ChapterSettingsWriter.WriteAsync(_settings, cancellationToken);
|
||||
if (_isDisposed)
|
||||
return;
|
||||
_statusMessage = "Settings saved. Open Students again to see the updated index columns.";
|
||||
_statusSeverity = Severity.Success;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
_statusMessage = $"Error saving settings: {ex.Message}";
|
||||
_statusSeverity = Severity.Error;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_isDisposed)
|
||||
_isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
@page "/import"
|
||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||
@using Core.Parsers
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Authentication
|
||||
@inject AppDbContext Context
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Import Data</PageTitle>
|
||||
|
||||
<h1>Import Data</h1>
|
||||
|
||||
<h3>Events</h3>
|
||||
<InputFile OnChange="UploadEvents"></InputFile>
|
||||
<text>@_events?.Length Events</text>
|
||||
<button class="btn btn-primary" @onclick="SaveEvents">Save to Database</button>
|
||||
<br/>
|
||||
|
||||
<h3>Students</h3>
|
||||
<InputFile OnChange="UploadStudents"></InputFile>
|
||||
<text>@_students?.Length Students</text>
|
||||
<button class="btn btn-primary" @onclick="SaveStudents">Save to Database</button>
|
||||
|
||||
@code {
|
||||
private EventDefinition[]? _events;
|
||||
private Student[]? _students;
|
||||
|
||||
async Task UploadEvents(InputFileChangeEventArgs arg)
|
||||
{
|
||||
await GetStreamReaderFromInputFile(arg, reader =>
|
||||
{
|
||||
var eventDefinitionParser = new EventDefinitionParser(reader);
|
||||
_events = eventDefinitionParser.Parse();
|
||||
});
|
||||
}
|
||||
|
||||
async Task SaveEvents()
|
||||
{
|
||||
if (_events == null)
|
||||
return;
|
||||
|
||||
foreach (var evt in _events)
|
||||
{
|
||||
// check if it already exists
|
||||
var exists
|
||||
= await Context.Events
|
||||
.FirstOrDefaultAsync(e => e.Name == evt.Name);
|
||||
if (exists != null)
|
||||
continue;
|
||||
await Context.Events.AddAsync(evt);
|
||||
}
|
||||
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
async Task UploadStudents(InputFileChangeEventArgs arg)
|
||||
{
|
||||
await GetStreamReaderFromInputFile(arg, reader =>
|
||||
{
|
||||
var studentParser = new StudentParser(reader);
|
||||
_students = studentParser.Parse();
|
||||
});
|
||||
}
|
||||
|
||||
async Task SaveStudents()
|
||||
{
|
||||
if (_students == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var student in _students)
|
||||
{
|
||||
// check if it already exists
|
||||
var exists
|
||||
= await Context.Students
|
||||
.FirstOrDefaultAsync(e
|
||||
=> e.FirstName == student.FirstName
|
||||
&& e.LastName == student.LastName);
|
||||
if (exists != null)
|
||||
continue;
|
||||
await Context.Students.AddAsync(student);
|
||||
}
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task GetStreamReaderFromInputFile(InputFileChangeEventArgs arg, Action<StreamReader> f)
|
||||
{
|
||||
StreamReader? streamReader = null;
|
||||
try
|
||||
{
|
||||
var browserFile = arg.File;
|
||||
|
||||
await using var fs = browserFile.OpenReadStream();
|
||||
await using var ms = new MemoryStream();
|
||||
|
||||
await fs.CopyToAsync(ms);
|
||||
ms.Seek(0,0);
|
||||
streamReader = new StreamReader(ms);
|
||||
f(streamReader);
|
||||
}
|
||||
catch
|
||||
{
|
||||
streamReader?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Core.Models;
|
||||
using Core.Notes;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace WebApp.Models;
|
||||
|
||||
@@ -51,4 +53,26 @@ public class ChapterSettings
|
||||
/// School level for the chapter (null = import both MS and HS events)
|
||||
/// </summary>
|
||||
public SchoolLevel? SchoolLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Field names from student note <c>## Imported fields</c> tables to show as Students index columns.
|
||||
/// </summary>
|
||||
public List<string> StudentIndexNoteFields { get; set; } = [.. StudentNoteFieldDefaults.IndexColumns];
|
||||
|
||||
public static ChapterSettings FromConfiguration(IConfiguration configuration)
|
||||
{
|
||||
var settings = configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
|
||||
?? new ChapterSettings();
|
||||
settings.StudentIndexNoteFields = ReadIndexNoteFields(configuration);
|
||||
return settings;
|
||||
}
|
||||
|
||||
public static List<string> ReadIndexNoteFields(IConfiguration configuration)
|
||||
{
|
||||
var fields = configuration.GetSection("ChapterSettings:StudentIndexNoteFields").Get<List<string>>();
|
||||
if (fields is null)
|
||||
return [.. StudentNoteFieldDefaults.IndexColumns];
|
||||
|
||||
return [.. fields.Where(f => !string.IsNullOrWhiteSpace(f)).Select(f => f.Trim())];
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
using Serilog;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Core.Notes;
|
||||
using VisNetwork.Blazor;
|
||||
using WebApp;
|
||||
using WebApp.Authentication;
|
||||
@@ -34,21 +36,24 @@ if (!File.Exists(dataAppSettingsPath))
|
||||
var baseConfig = File.ReadAllText(baseAppSettingsPath);
|
||||
var baseDoc = JsonDocument.Parse(baseConfig);
|
||||
|
||||
var templateSettings = new Dictionary<string, object?>();
|
||||
JsonObject templateSettings = [];
|
||||
|
||||
if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings))
|
||||
{
|
||||
templateSettings["ChapterSettings"] = JsonSerializer.Deserialize<object>(chapterSettings.GetRawText());
|
||||
var chapterObj = JsonNode.Parse(chapterSettings.GetRawText()) as JsonObject ?? [];
|
||||
if (chapterObj["StudentIndexNoteFields"] is null)
|
||||
chapterObj["StudentIndexNoteFields"] = JsonSerializer.SerializeToNode(StudentNoteFieldDefaults.IndexColumns);
|
||||
templateSettings["ChapterSettings"] = chapterObj;
|
||||
}
|
||||
|
||||
if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings))
|
||||
{
|
||||
templateSettings["ValidationSettings"] = JsonSerializer.Deserialize<object>(validationSettings.GetRawText());
|
||||
templateSettings["ValidationSettings"] = JsonNode.Parse(validationSettings.GetRawText());
|
||||
}
|
||||
|
||||
if (templateSettings.Any())
|
||||
if (templateSettings.Count > 0)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(templateSettings, new JsonSerializerOptions
|
||||
var json = templateSettings.ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
@@ -206,6 +211,8 @@ builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Servic
|
||||
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.AddScoped<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>();
|
||||
builder.Services.AddScoped<WebApp.Services.IStudentNotesImportSaveService, WebApp.Services.StudentNotesImportSaveService>();
|
||||
|
||||
builder.Services.Configure<StateScheduleHandoutOptions>(
|
||||
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using WebApp.Models;
|
||||
|
||||
namespace WebApp.Services;
|
||||
@@ -8,6 +9,11 @@ namespace WebApp.Services;
|
||||
/// </summary>
|
||||
public class ChapterSettingsWriter : IChapterSettingsWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ChapterSettingsWriter> _logger;
|
||||
@@ -29,35 +35,41 @@ public class ChapterSettingsWriter : IChapterSettingsWriter
|
||||
var appSettingsPath = GetAppSettingsPath();
|
||||
var dataDir = Path.GetDirectoryName(appSettingsPath);
|
||||
if (dataDir != null && !Directory.Exists(dataDir))
|
||||
{
|
||||
Directory.CreateDirectory(dataDir);
|
||||
}
|
||||
|
||||
Dictionary<string, object?> root;
|
||||
JsonObject root;
|
||||
if (File.Exists(appSettingsPath))
|
||||
{
|
||||
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
|
||||
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
|
||||
?? [];
|
||||
root = JsonNode.Parse(existingJson) as JsonObject ?? [];
|
||||
}
|
||||
else
|
||||
{
|
||||
root = [];
|
||||
}
|
||||
|
||||
root["ChapterSettings"] = settings;
|
||||
var incoming = JsonSerializer.SerializeToNode(settings, JsonOptions) as JsonObject ?? [];
|
||||
if (root["ChapterSettings"] is JsonObject existing)
|
||||
{
|
||||
foreach (var property in incoming)
|
||||
existing[property.Key] = property.Value?.DeepClone();
|
||||
}
|
||||
else
|
||||
{
|
||||
root["ChapterSettings"] = incoming;
|
||||
}
|
||||
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
var json = JsonSerializer.Serialize(root, options);
|
||||
await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken);
|
||||
await File.WriteAllTextAsync(appSettingsPath, root.ToJsonString(JsonOptions), cancellationToken);
|
||||
|
||||
if (_configuration is IConfigurationRoot configurationRoot)
|
||||
configurationRoot.Reload();
|
||||
|
||||
_logger.LogInformation("Chapter settings written to {Path}", appSettingsPath);
|
||||
}
|
||||
|
||||
public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = _configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
|
||||
?? new ChapterSettings();
|
||||
var settings = ChapterSettings.FromConfiguration(_configuration);
|
||||
settings.CompetitionYear = competitionYear;
|
||||
await WriteAsync(settings, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,26 @@ public interface INotesService
|
||||
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
|
||||
Task<Note?> GetPageNoteAsync(string pageIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the system note for a student, or null if none exists.
|
||||
/// </summary>
|
||||
Task<Note?> GetStudentNoteAsync(int studentId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets system notes for the given student ids, keyed by student id.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds);
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes system notes for the given student ids.
|
||||
/// </summary>
|
||||
Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct Field names from student note <c>## Imported fields</c> tables.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all history entries for a note.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Core.Models;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists merged student field notes.
|
||||
/// </summary>
|
||||
public interface IStudentNotesImportSaveService
|
||||
{
|
||||
Task<StudentNotesImportSaveResult> SaveAsync(
|
||||
StudentNotesImportResult parseResult,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class StudentNotesImportSaveResult
|
||||
{
|
||||
public int NotesCreated { get; set; }
|
||||
|
||||
public int NotesUpdated { get; set; }
|
||||
|
||||
public int StudentsUnchanged { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Core.Entities;
|
||||
using Core.Notes;
|
||||
using Core.Services;
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -46,8 +47,9 @@ public class NotesService : INotesService
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
||||
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
||||
.Where(n => n.Title == null || !n.Title.StartsWith("#Student:"))
|
||||
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
|
||||
.ThenByDescending(n => n.UpdatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -67,6 +69,84 @@ public class NotesService : INotesService
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Note?> GetStudentNoteAsync(int studentId)
|
||||
{
|
||||
var title = _noteNamingService.GetStudentNoteTitle(studentId);
|
||||
return await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => n.Title == title && !n.IsDeleted)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds)
|
||||
{
|
||||
var ids = studentIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return new Dictionary<int, Note>();
|
||||
|
||||
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
|
||||
var notes = await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<int, Note> byStudentId = [];
|
||||
foreach (var note in notes)
|
||||
{
|
||||
if (_noteNamingService.TryParseStudentNoteId(note.Title, out var studentId))
|
||||
byStudentId[studentId] = note;
|
||||
}
|
||||
|
||||
return byStudentId;
|
||||
}
|
||||
|
||||
public async Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = studentIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return;
|
||||
|
||||
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
|
||||
var notes = await _context.Notes
|
||||
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (notes.Count == 0)
|
||||
return;
|
||||
|
||||
var userEmail = GetCurrentUserEmail();
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var note in notes)
|
||||
{
|
||||
_context.NoteHistories.Add(new NoteHistory
|
||||
{
|
||||
NoteId = note.Id,
|
||||
Title = note.Title,
|
||||
Content = note.Content,
|
||||
ModifiedBy = userEmail,
|
||||
ModifiedAt = now,
|
||||
ChangeType = "Soft Deleted"
|
||||
});
|
||||
note.IsDeleted = true;
|
||||
note.UpdatedAt = now;
|
||||
note.LastModifiedBy = userEmail;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("Soft-deleted {Count} student note(s)", notes.Count);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contents = await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => !n.IsDeleted && n.Title != null && n.Title.StartsWith("#Student:"))
|
||||
.Select(n => n.Content)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return ImportedFieldsTable.DistinctFieldNames(contents);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
|
||||
{
|
||||
return await _context.NoteHistories
|
||||
@@ -253,9 +333,9 @@ public class NotesService : INotesService
|
||||
{
|
||||
return await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => n.IsDeleted)
|
||||
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
||||
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
||||
.Where(n => n.IsDeleted && (n.Title == null || !n.Title.StartsWith("#Student:")))
|
||||
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
|
||||
.ThenByDescending(n => n.UpdatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Core.Models;
|
||||
using Core.Services;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates #Student:{id} notes when imported fields actually change.
|
||||
/// </summary>
|
||||
public class StudentNotesImportSaveService : IStudentNotesImportSaveService
|
||||
{
|
||||
private readonly INotesService _notesService;
|
||||
private readonly INoteNamingService _noteNamingService;
|
||||
private readonly ILogger<StudentNotesImportSaveService> _logger;
|
||||
|
||||
public StudentNotesImportSaveService(
|
||||
INotesService notesService,
|
||||
INoteNamingService noteNamingService,
|
||||
ILogger<StudentNotesImportSaveService> logger)
|
||||
{
|
||||
_notesService = notesService;
|
||||
_noteNamingService = noteNamingService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StudentNotesImportSaveResult> SaveAsync(
|
||||
StudentNotesImportResult parseResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new StudentNotesImportSaveResult();
|
||||
var existingNotes = await _notesService.GetStudentNotesAsync(
|
||||
parseResult.Matches.Select(m => m.Student.Id));
|
||||
var actions = StudentNotesImportPlan.Create(parseResult, existingNotes.Keys.ToHashSet());
|
||||
result.StudentsUnchanged = parseResult.Matches.Count - actions.Count;
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (action.Kind == StudentNotePersistKind.Update
|
||||
&& existingNotes.TryGetValue(action.StudentId, out var existing))
|
||||
{
|
||||
existing.Content = action.Markdown;
|
||||
await _notesService.UpdateNoteAsync(existing);
|
||||
result.NotesUpdated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await _notesService.CreateNoteAsync(new Core.Entities.Note
|
||||
{
|
||||
Title = _noteNamingService.GetStudentNoteTitle(action.StudentId),
|
||||
Content = action.Markdown
|
||||
});
|
||||
result.NotesCreated++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Student notes import saved. Created={Created}, Updated={Updated}, Unchanged={Unchanged}",
|
||||
result.NotesCreated,
|
||||
result.NotesUpdated,
|
||||
result.StudentsUnchanged);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,20 @@ public class YearRolloverService : IYearRolloverService
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IDatabaseBackupService _backupService;
|
||||
private readonly IChapterSettingsWriter _chapterSettingsWriter;
|
||||
private readonly INotesService _notesService;
|
||||
private readonly ILogger<YearRolloverService> _logger;
|
||||
|
||||
public YearRolloverService(
|
||||
AppDbContext context,
|
||||
IDatabaseBackupService backupService,
|
||||
IChapterSettingsWriter chapterSettingsWriter,
|
||||
INotesService notesService,
|
||||
ILogger<YearRolloverService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_backupService = backupService;
|
||||
_chapterSettingsWriter = chapterSettingsWriter;
|
||||
_notesService = notesService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -107,6 +110,8 @@ public class YearRolloverService : IYearRolloverService
|
||||
$"Roster changed since the wizard loaded ({unexpected.Count} unexpected student(s): {names}). Reload the page and try again.");
|
||||
}
|
||||
|
||||
await _notesService.SoftDeleteStudentNotesAsync(removalIds, cancellationToken);
|
||||
|
||||
_context.Students.RemoveRange(toRemove);
|
||||
|
||||
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
window.tsaDownload = {
|
||||
/**
|
||||
* Triggers a browser file download from a base64 payload.
|
||||
*/
|
||||
fromBase64: function (filename, contentType, base64) {
|
||||
var binary = atob(base64);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++)
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
|
||||
var blob = new Blob([bytes], { type: contentType });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
# Import Event Catalog
|
||||
|
||||
**Created:** 2026-08-29
|
||||
**Last updated:** 2026-08-29
|
||||
**Description:** How `/events/import` adds event definitions from CSV.
|
||||
|
||||
## Where this is
|
||||
|
||||
This is the **event catalog** (names, team size, format). It is not student event rankings (`/students/event-ranking/import`) and not the calendar schedule (`/calendar/event-occurrences/import`).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Sign in as an Administrator.
|
||||
2. Open **Events** and click **Import**, or go to `/events/import`.
|
||||
3. Upload a CSV, **Parse**, review new vs existing counts, then **Save to Database**.
|
||||
|
||||
Existing event names are skipped. Re-importing the same catalog is safe.
|
||||
|
||||
## Required columns
|
||||
|
||||
`Event`, `Team Size`, `State Count`
|
||||
|
||||
Optional columns the parser already reads include `Short Name`, `EventFormat`, `Level of Effort`, `Eligibility`, `Description`, `Theme`, `Documentation`, `State Presubmission`, `Semifinalist Activity`, and `Regional Notes`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Import Students and Note Fields
|
||||
|
||||
**Created:** 2026-08-29
|
||||
**Last updated:** 2026-08-29
|
||||
|
||||
**Description:** How `/students/import` creates students and merges leftover CSV columns into each student's markdown notes.
|
||||
|
||||
## Where notes live
|
||||
|
||||
Each student has a system note titled `#Student:{id}`. It is edited on the student edit page and shown on student details. Those notes are hidden from the main Notes list.
|
||||
|
||||
Imported leftover values go in a table under `## Imported fields`. Other markdown above or below that heading is left alone.
|
||||
|
||||
## CSV format
|
||||
|
||||
```
|
||||
Student Name,Grade,TSA year,State ID,Regional ID,National ID,Interview Time,Application,Club Permission Slip,Teacher Rec 1,Teacher Rec 2,Teacher Rec 3
|
||||
"Last, First",6,1st,,,,3:20-3:35,x,x,Fuqua,Young,
|
||||
```
|
||||
|
||||
- Roster (required): `Student Name` (`Last, First` or `First Last`), `Grade`, `TSA year`.
|
||||
- Roster (optional): `State ID`, `Regional ID`, `National ID`.
|
||||
- Every other column becomes a Field/Value row. Later files can add columns without a code change.
|
||||
- A cell that is only `x` / `X` is stored as `Yes`. Blank stays blank.
|
||||
- Rank columns `1`–`10`, `Officer`, and `TOTAL # OF EVENTS` are ignored (use `/students/event-ranking/import` for rankings).
|
||||
|
||||
A notes-only file without `Grade` will not import. Put roster and leftover columns in the same CSV.
|
||||
|
||||
## Merge rules
|
||||
|
||||
- New students are inserted. Existing first+last name matches are skipped for the roster (add-only).
|
||||
- After students are saved, leftover columns are fuzzy-matched by name and merged into notes. Existing students still receive note updates.
|
||||
- Incoming values win when a field already exists, including a blank cell that clears a previous value.
|
||||
- New fields are appended. Fields not in this CSV stay. Blank cells are stored and re-imported as a no-op.
|
||||
- Duplicate rows for the same student collapse into one note match; the last row wins for overlapping field names.
|
||||
- Importing the same file twice adds no students and writes no note history.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Sign in as an Administrator.
|
||||
2. Open **Students** and click **Import**, or go to `/students/import`.
|
||||
The arrow next to **Import** downloads a CSV template (roster columns, Students index columns, and any other imported fields already in notes).
|
||||
`/import` still opens this same student page.
|
||||
3. Upload the student CSV.
|
||||
4. Review new vs existing counts and leftover field names.
|
||||
5. **Save to Database**.
|
||||
|
||||
## Index columns
|
||||
|
||||
Chapter Settings → **Student Index Columns** lists which imported field names appear as extra columns on the Students index. The page also shows field names already present in student notes; click a chip to add or remove it from the list. Open Students again after saving; a restart is not required.
|
||||
@@ -1,7 +1,8 @@
|
||||
# Year Rollover Runbook
|
||||
|
||||
**Created:** 2026-08-14
|
||||
**Last updated:** 2026-08-28
|
||||
**Last updated:** 2026-08-29
|
||||
|
||||
**Description:** How to roll the chapter into a new competition year using the locked New Year wizard.
|
||||
|
||||
## Prerequisites
|
||||
@@ -36,9 +37,10 @@
|
||||
- Type the target competition year exactly to enable **Apply rollover**.
|
||||
- Confirm the destructive dialog. The wizard creates `Data/backups/pre-rollover-yyyyMMdd-HHmmss.db` first; that file is the only undo.
|
||||
9. **Restart the application** so the home page and printouts show the new competition year.
|
||||
10. **Add new students** via `/students/create` or `/import`.
|
||||
- `/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.
|
||||
10. **Add new students** via `/students/create` or `/students/import`.
|
||||
- Student import is add-only for roster rows and skips existing first+last name matches, so re-importing a full roster is safe for returners.
|
||||
- Leftover CSV columns (interview time, application, and so on) are merged into each student's notes. Rank columns stay on `/students/event-ranking/import`. See `docs/instructions/student-notes-import.md`.
|
||||
11. **Import the new state schedule** from the calendar import page. If the chapter event file changed, refresh the catalog at `/events/import` (add-only by event name).
|
||||
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 (CSV import at `/students/event-ranking/import`, or the ranking editor) and run team assignment as usual.
|
||||
|
||||
@@ -48,7 +50,7 @@
|
||||
|---------|------|
|
||||
| Non-returning students | Returning students (promoted) |
|
||||
| All teams | Event definitions (national catalog) |
|
||||
| All event rankings | Notes (including meeting notes by title) |
|
||||
| All event rankings | Notes (including meeting notes by title; notes for removed students are soft-deleted) |
|
||||
| All meeting history attendance snapshots | Database backup under `Data/backups/` |
|
||||
| Event occurrences (when checked) | |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user