Compare commits
6
Commits
432caa0fe8
...
4c91db37c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c91db37c2 | ||
|
|
2acd83a841 | ||
|
|
74fc542630 | ||
|
|
980a7213a4 | ||
|
|
afdabd179a | ||
|
|
c5c0f95f60 |
@@ -0,0 +1,107 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a student event ranking CSV.
|
||||
/// </summary>
|
||||
public class StudentEventRankingParseResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepted ranking matches, including the raw CSV text and fuzzy scores.
|
||||
/// </summary>
|
||||
public List<StudentEventRankingMatch> Matches { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Unmatched students, unmatched or ambiguous events, and other row-level issues.
|
||||
/// </summary>
|
||||
public List<StudentEventRankingIssue> Issues { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Critical errors that prevented parsing (for example a missing header).
|
||||
/// </summary>
|
||||
public List<string> Errors { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Non-critical warnings about the file as a whole.
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Accepted rankings without match metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StudentEventRanking> Rankings => [.. Matches.Select(m => m.Ranking)];
|
||||
|
||||
/// <summary>
|
||||
/// Number of accepted ranking rows.
|
||||
/// </summary>
|
||||
public int TotalParsed => Matches.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Distinct students who have at least one accepted rank.
|
||||
/// Uses Id when assigned, otherwise first and last name, so unsaved parsed students stay distinct.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Student> StudentsWithAcceptedRanks =>
|
||||
[.. Matches
|
||||
.Select(m => m.Ranking.Student)
|
||||
.GroupBy(s => s.Id != 0 ? $"id:{s.Id}" : $"name:{s.FirstName}|{s.LastName}")
|
||||
.Select(g => g.First())];
|
||||
|
||||
/// <summary>
|
||||
/// True when no critical parse errors were recorded.
|
||||
/// </summary>
|
||||
public bool IsSuccess => Errors.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A successfully matched ranking cell from the CSV.
|
||||
/// </summary>
|
||||
public class StudentEventRankingMatch
|
||||
{
|
||||
public required StudentEventRanking Ranking { get; set; }
|
||||
|
||||
public string RawStudentName { get; set; } = string.Empty;
|
||||
|
||||
public string RawEventName { get; set; } = string.Empty;
|
||||
|
||||
public int StudentScore { get; set; }
|
||||
|
||||
public int EventScore { get; set; }
|
||||
|
||||
public int RowNumber { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A row-level problem encountered while parsing rankings.
|
||||
/// </summary>
|
||||
public class StudentEventRankingIssue
|
||||
{
|
||||
public int RowNumber { get; set; }
|
||||
|
||||
public int Rank { get; set; }
|
||||
|
||||
public string RawStudentName { get; set; } = string.Empty;
|
||||
|
||||
public string RawEventName { get; set; } = string.Empty;
|
||||
|
||||
public StudentEventRankingIssueType IssueType { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public string? SuggestedEventName { get; set; }
|
||||
|
||||
public int? Score { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of issues reported while parsing student event rankings.
|
||||
/// </summary>
|
||||
public enum StudentEventRankingIssueType
|
||||
{
|
||||
UnmatchedStudent,
|
||||
UnmatchedEvent,
|
||||
AmbiguousEvent,
|
||||
DuplicateEvent,
|
||||
DuplicateRank,
|
||||
InvalidFormat
|
||||
}
|
||||
@@ -1,77 +1,241 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using FuzzySharp;
|
||||
|
||||
namespace Core.Parsers;
|
||||
|
||||
public class StudentEventRankingParser : CsvParserBase
|
||||
{
|
||||
public const int StudentMatchThreshold = 90;
|
||||
public const int EventMatchThreshold = 70;
|
||||
public const int EventAmbiguityGap = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Informal names that share too little text with the catalog for fuzzy matching.
|
||||
/// Keyed by official <see cref="EventDefinition.Name"/>.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string[]> EventAliases = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Junior Solar Sprint"] = ["Solar Racer", "Solar Race"],
|
||||
["Challenging Technology Issues"] = ["Challenging Tech", "Challenging Technology"],
|
||||
["Digital Photography"] = ["Digital Photo"],
|
||||
["Forensic Technology"] = ["Forensics", "Forensic"],
|
||||
["Microcontroller Design"] = ["Micro Controller", "Micro Controller Design"],
|
||||
["Medical Technology"] = ["Med Tech"],
|
||||
["Inventions & Innovations"] = ["Innovations & Inventions", "Inventions and Innovations"],
|
||||
["System Control Technology"] = ["Systems Control Tech", "Systems Control Technology"],
|
||||
["Structural Engineering"] = ["Structural Eng"],
|
||||
["Drone Challenge (UAV)"] = ["Drone Challenge", "Drone"],
|
||||
["Drone Challenge"] = ["Drone Challenge (UAV)", "Drone"],
|
||||
["TSA Robotics"] = ["Robotics"],
|
||||
["Audio Podcasting"] = ["Audio Podcast", "Podcasting"]
|
||||
};
|
||||
|
||||
public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
|
||||
{
|
||||
}
|
||||
|
||||
public StudentEventRanking[] Parse(ICollection<Student> students, ICollection<EventDefinition> events)
|
||||
{
|
||||
var rankings = new List<StudentEventRanking>();
|
||||
public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
|
||||
{
|
||||
}
|
||||
|
||||
public StudentEventRankingParseResult Parse(ICollection<Student> students, ICollection<EventDefinition> events)
|
||||
{
|
||||
var result = new StudentEventRankingParseResult();
|
||||
|
||||
CsvReader.Read();
|
||||
CsvReader.ReadHeader();
|
||||
|
||||
while (CsvReader.Read())
|
||||
{
|
||||
var name = CsvReader.GetField("Student Name");
|
||||
if (CsvReader.HeaderRecord is null ||
|
||||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Errors.Add("CSV must include a 'Student Name' column.");
|
||||
return result;
|
||||
}
|
||||
|
||||
while (CsvReader.Read())
|
||||
{
|
||||
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
|
||||
var name = CsvReader.GetField("Student Name")?.Trim();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
var student = students.FirstOrDefault(s => Fuzz.Ratio(s.FirstNameLastName, name) > 90);
|
||||
if (student == null)
|
||||
continue;
|
||||
|
||||
|
||||
var competitiveEvents = new List<EventDefinition>();
|
||||
|
||||
for (var i = 1; i <= 6; i++)
|
||||
var studentMatch = FindStudent(students, name);
|
||||
if (studentMatch is null)
|
||||
{
|
||||
var eventName = CsvReader.GetField(i.ToString());
|
||||
if (string.IsNullOrEmpty(eventName) || eventName == "") continue;
|
||||
|
||||
eventName = eventName.Trim();
|
||||
|
||||
if (eventName == "I&I")
|
||||
eventName = "Inventions & Innovations";
|
||||
if (eventName == "Med Tech")
|
||||
eventName = "Medical Technology";
|
||||
if (eventName.StartsWith("Challenging Tech"))
|
||||
eventName = "Challenging Technology Issues";
|
||||
|
||||
var matches =
|
||||
(from e in events
|
||||
let rat = Fuzz.Ratio(e.Name, eventName)
|
||||
where rat > 90
|
||||
orderby rat descending
|
||||
select e).ToList();
|
||||
|
||||
if (!matches.Any())
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
{
|
||||
matches =
|
||||
(from e in events
|
||||
where e.Name.StartsWith(eventName)
|
||||
select e).ToList();
|
||||
}
|
||||
|
||||
var competitiveEvent = matches.FirstOrDefault();
|
||||
if (competitiveEvent == null)
|
||||
{
|
||||
|
||||
//todo: throw new ArgumentException($"Event named '{eventName}' not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
rankings.Add(new StudentEventRanking{
|
||||
Student = student, EventDefinition = competitiveEvent,
|
||||
Rank = i});
|
||||
RowNumber = rowNumber,
|
||||
RawStudentName = name,
|
||||
IssueType = StudentEventRankingIssueType.UnmatchedStudent,
|
||||
Message = $"No student matched '{name}'."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return rankings.ToArray();
|
||||
var (student, studentScore) = studentMatch.Value;
|
||||
var acceptedEventsForStudent = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var acceptedRanksForStudent = new HashSet<int>();
|
||||
|
||||
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
|
||||
{
|
||||
var eventName = CsvReader.GetField(rank.ToString())?.Trim();
|
||||
if (string.IsNullOrEmpty(eventName))
|
||||
continue;
|
||||
|
||||
var eventResolution = ResolveEvent(events, eventName);
|
||||
if (eventResolution.Status == EventMatchStatus.Unmatched)
|
||||
{
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Rank = rank,
|
||||
RawStudentName = name,
|
||||
RawEventName = eventName,
|
||||
IssueType = StudentEventRankingIssueType.UnmatchedEvent,
|
||||
Message = $"No event matched '{eventName}' for {student.FirstNameLastName} (rank {rank}).",
|
||||
SuggestedEventName = eventResolution.BestEvent?.Name,
|
||||
Score = eventResolution.BestScore
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventResolution.Status == EventMatchStatus.Ambiguous)
|
||||
{
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Rank = rank,
|
||||
RawStudentName = name,
|
||||
RawEventName = eventName,
|
||||
IssueType = StudentEventRankingIssueType.AmbiguousEvent,
|
||||
Message = $"'{eventName}' is ambiguous between '{eventResolution.BestEvent?.Name}' and '{eventResolution.RunnerUpEvent?.Name}' for {student.FirstNameLastName} (rank {rank}).",
|
||||
SuggestedEventName = eventResolution.BestEvent?.Name,
|
||||
Score = eventResolution.BestScore
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var matchedEvent = eventResolution.BestEvent!;
|
||||
if (!acceptedEventsForStudent.Add($"{matchedEvent.Id}:{matchedEvent.Name}"))
|
||||
{
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Rank = rank,
|
||||
RawStudentName = name,
|
||||
RawEventName = eventName,
|
||||
IssueType = StudentEventRankingIssueType.DuplicateEvent,
|
||||
Message = $"{student.FirstNameLastName} already has '{matchedEvent.Name}' in this file.",
|
||||
SuggestedEventName = matchedEvent.Name,
|
||||
Score = eventResolution.BestScore
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!acceptedRanksForStudent.Add(rank))
|
||||
{
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Rank = rank,
|
||||
RawStudentName = name,
|
||||
RawEventName = eventName,
|
||||
IssueType = StudentEventRankingIssueType.DuplicateRank,
|
||||
Message = $"{student.FirstNameLastName} already has a rank {rank} assignment in this file.",
|
||||
SuggestedEventName = matchedEvent.Name,
|
||||
Score = eventResolution.BestScore
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Matches.Add(new StudentEventRankingMatch
|
||||
{
|
||||
Ranking = new StudentEventRanking
|
||||
{
|
||||
Student = student,
|
||||
EventDefinition = matchedEvent,
|
||||
Rank = rank
|
||||
},
|
||||
RawStudentName = name,
|
||||
RawEventName = eventName,
|
||||
StudentScore = studentScore,
|
||||
EventScore = eventResolution.BestScore,
|
||||
RowNumber = rowNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Matches.Count == 0 && result.Errors.Count == 0)
|
||||
result.Warnings.Add("No rankings were accepted from the CSV.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static (Student Student, int Score)? FindStudent(ICollection<Student> students, string name)
|
||||
{
|
||||
var ranked = students
|
||||
.Select(s => (Student: s, Score: ScoreStudent(s, name)))
|
||||
.Where(x => x.Score >= StudentMatchThreshold)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
|
||||
return ranked.Count == 0 ? null : ranked[0];
|
||||
}
|
||||
|
||||
private static int ScoreStudent(Student student, string name)
|
||||
{
|
||||
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
|
||||
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
|
||||
}
|
||||
|
||||
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
|
||||
{
|
||||
var scored = events
|
||||
.Select(e => (Event: e, Score: ScoreEvent(e, eventName)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
|
||||
if (scored.Count == 0)
|
||||
return new EventResolution(EventMatchStatus.Unmatched, null, 0, null, 0);
|
||||
|
||||
var best = scored[0];
|
||||
var runnerUp = scored.Count > 1 ? scored[1] : default;
|
||||
|
||||
if (best.Score < EventMatchThreshold)
|
||||
return new EventResolution(EventMatchStatus.Unmatched, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
|
||||
|
||||
if (runnerUp.Event is not null && best.Score - runnerUp.Score < EventAmbiguityGap)
|
||||
return new EventResolution(EventMatchStatus.Ambiguous, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
|
||||
|
||||
return new EventResolution(EventMatchStatus.Matched, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
|
||||
}
|
||||
|
||||
private static int ScoreEvent(EventDefinition evt, string eventName)
|
||||
{
|
||||
List<string> names = [evt.Name];
|
||||
if (!string.IsNullOrWhiteSpace(evt.ShortName))
|
||||
names.Add(evt.ShortName);
|
||||
if (EventAliases.TryGetValue(evt.Name, out var aliases))
|
||||
names.AddRange(aliases);
|
||||
|
||||
return names
|
||||
.Select(n => new[] { Fuzz.Ratio(n, eventName), Fuzz.TokenSetRatio(n, eventName), Fuzz.PartialRatio(n, eventName) }.Max())
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
}
|
||||
|
||||
private enum EventMatchStatus
|
||||
{
|
||||
Matched,
|
||||
Ambiguous,
|
||||
Unmatched
|
||||
}
|
||||
|
||||
private readonly record struct EventResolution(
|
||||
EventMatchStatus Status,
|
||||
EventDefinition? BestEvent,
|
||||
int BestScore,
|
||||
EventDefinition? RunnerUpEvent,
|
||||
int RunnerUpScore);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
|
||||
namespace Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parses student event ranking CSV data against existing students and events.
|
||||
/// </summary>
|
||||
public interface IStudentEventRankingImportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses ranking CSV from a stream. The stream is not disposed.
|
||||
/// </summary>
|
||||
StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using Core.Parsers;
|
||||
|
||||
namespace Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <see cref="StudentEventRankingParser"/> for stream-based import.
|
||||
/// </summary>
|
||||
public class StudentEventRankingImportService : IStudentEventRankingImportService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events)
|
||||
{
|
||||
var reader = new StreamReader(stream, leaveOpen: true);
|
||||
using var parser = new StudentEventRankingParser(reader);
|
||||
return parser.Parse(students, events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Core.Models;
|
||||
|
||||
namespace Core.YearTransition;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the graduating grade from chapter school level configuration.
|
||||
/// </summary>
|
||||
public static class GraduatingGradeResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Middle school students graduate after grade 8; high school after grade 12.
|
||||
/// Returns null when school level is unset (both / unspecified).
|
||||
/// </summary>
|
||||
public static int? FromSchoolLevel(SchoolLevel? schoolLevel) => schoolLevel switch
|
||||
{
|
||||
SchoolLevel.MiddleSchool => 8,
|
||||
SchoolLevel.HighSchool => 12,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label for wizard display.
|
||||
/// </summary>
|
||||
public static string Describe(SchoolLevel schoolLevel, int graduatingGrade) => schoolLevel switch
|
||||
{
|
||||
SchoolLevel.MiddleSchool =>
|
||||
$"Middle school chapter — students graduate after grade {graduatingGrade}",
|
||||
SchoolLevel.HighSchool =>
|
||||
$"High school chapter — students graduate after grade {graduatingGrade}",
|
||||
_ => $"Students graduate after grade {graduatingGrade}"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.YearTransition;
|
||||
|
||||
/// <summary>
|
||||
/// Inputs for building a year-transition plan.
|
||||
/// </summary>
|
||||
public sealed class YearTransitionRequest
|
||||
{
|
||||
public required IReadOnlyList<Student> Students { get; init; }
|
||||
public required IReadOnlySet<int> ReturningStudentIds { get; init; }
|
||||
public IReadOnlyDictionary<OfficerRole, int?> OfficerAssignments { get; init; }
|
||||
= new Dictionary<OfficerRole, int?>();
|
||||
public required int GraduatingGrade { get; init; }
|
||||
public required string TargetCompetitionYear { get; init; }
|
||||
public IReadOnlyList<string> PastedNames { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview of a year transition before it is applied.
|
||||
/// </summary>
|
||||
public sealed class YearTransitionPlan
|
||||
{
|
||||
public required string TargetCompetitionYear { get; init; }
|
||||
public required int GraduatingGrade { get; init; }
|
||||
public required IReadOnlyList<StudentPromotion> Promotions { get; init; }
|
||||
public required IReadOnlyList<Student> StudentsToRemove { get; init; }
|
||||
public required IReadOnlyList<OfficerAssignmentChange> OfficerChanges { get; init; }
|
||||
public required IReadOnlyList<string> UnmatchedPastedNames { get; init; }
|
||||
public required IReadOnlyList<string> AmbiguousPastedNames { get; init; }
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
|
||||
public int ReturningCount => Promotions.Count;
|
||||
public int RemovalCount => StudentsToRemove.Count;
|
||||
}
|
||||
|
||||
public sealed class StudentPromotion
|
||||
{
|
||||
public required Student Student { get; init; }
|
||||
public required int PreviousGrade { get; init; }
|
||||
public required int NewGrade { get; init; }
|
||||
public required int PreviousTsaYear { get; init; }
|
||||
public required int NewTsaYear { get; init; }
|
||||
public OfficerRole? PreviousOfficerRole { get; init; }
|
||||
public OfficerRole? NewOfficerRole { get; init; }
|
||||
}
|
||||
|
||||
public sealed class OfficerAssignmentChange
|
||||
{
|
||||
public required OfficerRole Role { get; init; }
|
||||
public Student? NewOfficer { get; init; }
|
||||
public Student? PreviousOfficer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure planner for year-end student promotion, graduation, and officer assignment.
|
||||
/// </summary>
|
||||
public static class YearTransitionPlanner
|
||||
{
|
||||
private const int AbsoluteMaxGrade = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Students at or above the graduating grade are suggested as non-returning.
|
||||
/// </summary>
|
||||
public static bool SuggestReturning(Student student, int graduatingGrade) =>
|
||||
student.Grade < graduatingGrade;
|
||||
|
||||
/// <summary>
|
||||
/// Parses pasted name lines and matches them to students.
|
||||
/// </summary>
|
||||
public static NameMatchResult MatchPastedNames(
|
||||
IReadOnlyList<Student> students,
|
||||
IEnumerable<string> pastedLines)
|
||||
{
|
||||
var matchedIds = new HashSet<int>();
|
||||
var unmatched = new List<string>();
|
||||
var ambiguous = new List<string>();
|
||||
|
||||
foreach (var rawLine in pastedLines)
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
var matches = FindNameMatches(students, line).ToList();
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
unmatched.Add(line);
|
||||
}
|
||||
else if (matches.Count > 1)
|
||||
{
|
||||
ambiguous.Add(line);
|
||||
foreach (var match in matches)
|
||||
matchedIds.Add(match.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
matchedIds.Add(matches[0].Id);
|
||||
}
|
||||
}
|
||||
|
||||
return new NameMatchResult(matchedIds, unmatched, ambiguous);
|
||||
}
|
||||
|
||||
public static YearTransitionPlan Build(YearTransitionRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.GraduatingGrade is < 5 or > AbsoluteMaxGrade)
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Graduating grade must be between 5 and 12.");
|
||||
|
||||
var studentsById = request.Students.ToDictionary(s => s.Id);
|
||||
var warnings = new List<string>();
|
||||
var promotions = new List<StudentPromotion>();
|
||||
var toRemove = new List<Student>();
|
||||
|
||||
var nameMatch = MatchPastedNames(request.Students, request.PastedNames);
|
||||
warnings.AddRange(nameMatch.AmbiguousNames.Select(n =>
|
||||
$"Pasted name '{n}' matched more than one student."));
|
||||
|
||||
// Officer role -> student id from request (null = vacant)
|
||||
var newOfficerByRole = Enum.GetValues<OfficerRole>()
|
||||
.ToDictionary(
|
||||
role => role,
|
||||
role => request.OfficerAssignments.TryGetValue(role, out var id) ? id : null);
|
||||
|
||||
// Detect same student assigned to multiple offices
|
||||
var assignedStudentIds = newOfficerByRole.Values
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)
|
||||
.ToList();
|
||||
var duplicateOfficerStudents = assignedStudentIds
|
||||
.GroupBy(id => id)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var studentId in duplicateOfficerStudents)
|
||||
{
|
||||
if (studentsById.TryGetValue(studentId, out var student))
|
||||
{
|
||||
warnings.Add($"{student.LastNameFirstName} is assigned to more than one officer role.");
|
||||
}
|
||||
}
|
||||
|
||||
// Build new officer role lookup for returning students
|
||||
var newRoleByStudentId = new Dictionary<int, OfficerRole>();
|
||||
foreach (var (role, studentId) in newOfficerByRole)
|
||||
{
|
||||
if (!studentId.HasValue)
|
||||
continue;
|
||||
|
||||
if (!request.ReturningStudentIds.Contains(studentId.Value))
|
||||
{
|
||||
var name = studentsById.TryGetValue(studentId.Value, out var s)
|
||||
? s.LastNameFirstName
|
||||
: $"Id {studentId.Value}";
|
||||
warnings.Add($"{role} is assigned to {name}, who is not marked returning.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (duplicateOfficerStudents.Contains(studentId.Value))
|
||||
continue;
|
||||
|
||||
newRoleByStudentId[studentId.Value] = role;
|
||||
}
|
||||
|
||||
foreach (var student in request.Students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
{
|
||||
if (request.ReturningStudentIds.Contains(student.Id))
|
||||
{
|
||||
var newGrade = Math.Min(AbsoluteMaxGrade,
|
||||
Math.Min(request.GraduatingGrade, student.Grade + 1));
|
||||
var newTsaYear = student.TsaYear + 1;
|
||||
|
||||
if (student.Grade >= request.GraduatingGrade)
|
||||
{
|
||||
warnings.Add(
|
||||
$"{student.LastNameFirstName} is at or above graduating grade {request.GraduatingGrade} but marked returning; grade stays at {newGrade}.");
|
||||
}
|
||||
|
||||
OfficerRole? assignedRole = newRoleByStudentId.TryGetValue(student.Id, out var role)
|
||||
? role
|
||||
: null;
|
||||
|
||||
promotions.Add(new StudentPromotion
|
||||
{
|
||||
Student = student,
|
||||
PreviousGrade = student.Grade,
|
||||
NewGrade = newGrade,
|
||||
PreviousTsaYear = student.TsaYear,
|
||||
NewTsaYear = newTsaYear,
|
||||
PreviousOfficerRole = student.OfficerRole,
|
||||
NewOfficerRole = assignedRole
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
toRemove.Add(student);
|
||||
}
|
||||
}
|
||||
|
||||
var officerChanges = Enum.GetValues<OfficerRole>()
|
||||
.Select(role =>
|
||||
{
|
||||
var previous = request.Students.FirstOrDefault(s => s.OfficerRole == role);
|
||||
Student? next = null;
|
||||
if (newOfficerByRole.TryGetValue(role, out var nextId) &&
|
||||
nextId.HasValue &&
|
||||
studentsById.TryGetValue(nextId.Value, out var nextStudent) &&
|
||||
request.ReturningStudentIds.Contains(nextId.Value) &&
|
||||
!duplicateOfficerStudents.Contains(nextId.Value))
|
||||
{
|
||||
next = nextStudent;
|
||||
}
|
||||
|
||||
return new OfficerAssignmentChange
|
||||
{
|
||||
Role = role,
|
||||
PreviousOfficer = previous,
|
||||
NewOfficer = next
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new YearTransitionPlan
|
||||
{
|
||||
TargetCompetitionYear = request.TargetCompetitionYear,
|
||||
GraduatingGrade = request.GraduatingGrade,
|
||||
Promotions = promotions,
|
||||
StudentsToRemove = toRemove,
|
||||
OfficerChanges = officerChanges,
|
||||
UnmatchedPastedNames = nameMatch.UnmatchedNames,
|
||||
AmbiguousPastedNames = nameMatch.AmbiguousNames,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<Student> FindNameMatches(IReadOnlyList<Student> students, string line)
|
||||
{
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
// Exact full-name matches first
|
||||
var fullMatches = students.Where(s =>
|
||||
comparer.Equals(s.FirstNameLastName, line) ||
|
||||
comparer.Equals(s.LastNameFirstName, line)).ToList();
|
||||
if (fullMatches.Count > 0)
|
||||
return fullMatches;
|
||||
|
||||
var (first, last) = ParseName(line);
|
||||
if (string.IsNullOrWhiteSpace(first) && string.IsNullOrWhiteSpace(last))
|
||||
return [];
|
||||
|
||||
return students.Where(s =>
|
||||
comparer.Equals(s.FirstName.Trim(), first) &&
|
||||
comparer.Equals(s.LastName.Trim(), last));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a name line into (first, last), using <see cref="Student.ParseNameParts"/> for
|
||||
/// "Last, First" and a last-space split for "First Last".
|
||||
/// </summary>
|
||||
private static (string First, string Last) ParseName(string fullName)
|
||||
{
|
||||
var trimmed = fullName.Trim();
|
||||
if (trimmed.Contains(','))
|
||||
{
|
||||
var parts = Student.ParseNameParts(trimmed);
|
||||
return (parts.Item1.Trim(), parts.Item2.Trim());
|
||||
}
|
||||
|
||||
var lastSpace = trimmed.LastIndexOf(' ');
|
||||
if (lastSpace <= 0)
|
||||
return (trimmed, string.Empty);
|
||||
|
||||
return (trimmed[..lastSpace].Trim(), trimmed[(lastSpace + 1)..].Trim());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NameMatchResult
|
||||
{
|
||||
public NameMatchResult(
|
||||
IReadOnlySet<int> matchedStudentIds,
|
||||
IReadOnlyList<string> unmatchedNames,
|
||||
IReadOnlyList<string> ambiguousNames)
|
||||
{
|
||||
MatchedStudentIds = matchedStudentIds;
|
||||
UnmatchedNames = unmatchedNames;
|
||||
AmbiguousNames = ambiguousNames;
|
||||
}
|
||||
|
||||
public IReadOnlySet<int> MatchedStudentIds { get; }
|
||||
public IReadOnlyList<string> UnmatchedNames { get; }
|
||||
public IReadOnlyList<string> AmbiguousNames { get; }
|
||||
}
|
||||
+28
-2
@@ -172,6 +172,31 @@ docker-compose logs -f webapp
|
||||
|
||||
---
|
||||
|
||||
## Data Persistence (SQLite + Backups)
|
||||
|
||||
The app stores its SQLite database and runtime config under **`Data/`** (capital D) relative to the content root:
|
||||
|
||||
| Path in container | Purpose |
|
||||
|-------------------|---------|
|
||||
| `/app/Data/app.db` | Live SQLite database |
|
||||
| `/app/Data/appsettings.json` | Chapter settings overrides (e.g. CompetitionYear) |
|
||||
| `/app/Data/backups/pre-rollover-*.db` | Automatic backups created by New Year Rollover |
|
||||
|
||||
**Docker volume mount must use capital `Data`:**
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/Data
|
||||
```
|
||||
|
||||
On Linux, `./data:/app/data` is a **different** path and will not persist the database or rollover backups. After a correct mount, host files appear under `./data/` (for example `./data/app.db` and `./data/backups/`).
|
||||
|
||||
Ensure the container user can write to the mounted directory (the image runs as a non-root `APP_UID`). If directory creation for `backups/` fails, year rollover aborts before changing data.
|
||||
|
||||
For the rollover workflow itself, see `docs/instructions/year-rollover.md`.
|
||||
|
||||
---
|
||||
|
||||
## Managing Users
|
||||
|
||||
### Adding a New User
|
||||
@@ -310,10 +335,11 @@ curl http://localhost:8080
|
||||
- [ ] Set file permissions to 600
|
||||
- [ ] Configured HTTPS/SSL certificates
|
||||
- [ ] Updated `ASPNETCORE_URLS` for production domain
|
||||
- [ ] Configured volume for database persistence
|
||||
- [ ] Configured volume for database persistence as `./data:/app/Data` (capital D)
|
||||
- [ ] Verified `app.db` appears on the host under `./data/` after first run
|
||||
- [ ] Removed development endpoints (already done in code)
|
||||
- [ ] Set up log monitoring
|
||||
- [ ] Configured automatic backups
|
||||
- [ ] Confirmed year-rollover backups write to `./data/backups/` (see `docs/instructions/year-rollover.md`)
|
||||
- [ ] Tested login with all user roles
|
||||
- [ ] Tested rate limiting (5 failed attempts)
|
||||
- [ ] Documented admin password securely
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Text;
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using Core.Parsers;
|
||||
using Tests.Builders;
|
||||
|
||||
namespace Tests.Parsers;
|
||||
|
||||
[TestFixture]
|
||||
public class StudentEventRankingParser_Tests
|
||||
{
|
||||
private Student _aria = null!;
|
||||
private EventDefinition _videoGame = null!;
|
||||
private EventDefinition _coding = null!;
|
||||
private EventDefinition _jss = null!;
|
||||
private EventDefinition _digitalPhoto = null!;
|
||||
private EventDefinition _biotech = null!;
|
||||
private EventDefinition _inventions = null!;
|
||||
private EventDefinition _techBowl = null!;
|
||||
private EventDefinition _techDesign = null!;
|
||||
private List<Student> _students = null!;
|
||||
private List<EventDefinition> _events = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
BuilderExtensions.ResetAllBuilders();
|
||||
|
||||
_aria = StudentBuilder.Create("Aria", "Chittenden").Build();
|
||||
_videoGame = EventDefinitionBuilder.Team("Video Game Design", 2, 6).WithShortName("Video Game").Build();
|
||||
_coding = EventDefinitionBuilder.Team("Coding", 2, 2).WithShortName("Coding").Build();
|
||||
_jss = EventDefinitionBuilder.Team("Junior Solar Sprint", 2, 4).WithShortName("JSS").Build();
|
||||
_digitalPhoto = EventDefinitionBuilder.Individual("Digital Photography").WithShortName("Digital Photo").Build();
|
||||
_biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).WithShortName("Biotech").Build();
|
||||
_inventions = EventDefinitionBuilder.Team("Inventions & Innovations", 3, 6).WithShortName("I&I").Build();
|
||||
_techBowl = EventDefinitionBuilder.Team("Tech Bowl", 3, 3).WithShortName("Tech Bowl").Build();
|
||||
_techDesign = EventDefinitionBuilder.Team("Technical Design", 2, 2).WithShortName("Tech Design").Build();
|
||||
|
||||
_students = [_aria];
|
||||
_events = [_videoGame, _coding, _jss, _digitalPhoto, _biotech, _inventions, _techBowl, _techDesign];
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_LastCommaFirst_MatchesStudent()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
"Chittenden, Aria",Coding
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.IsSuccess, Is.True);
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
|
||||
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_coding));
|
||||
Assert.That(result.Matches[0].Ranking.Rank, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_FirstLast_MatchesStudent()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
Aria Chittenden,Coding
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.IsSuccess, Is.True);
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_ShortName_MatchesEvent()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1,2,3
|
||||
Aria Chittenden,Video Game,JSS,I&I
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Issues, Is.Empty);
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(3));
|
||||
Assert.That(result.Matches.Select(m => m.Ranking.EventDefinition), Is.EqualTo(new[] { _videoGame, _jss, _inventions }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_NearMissFullName_MatchesEvent()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
Aria Chittenden,Digital Photo
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_digitalPhoto));
|
||||
Assert.That(result.Matches[0].EventScore, Is.GreaterThanOrEqualTo(StudentEventRankingParser.EventMatchThreshold));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_BiotechShortName_MatchesBiotechnology()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
Aria Chittenden,Biotech
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_biotech));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_UnmatchedStudent_IsReported()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
Nobody Here,Coding
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Matches, Is.Empty);
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedStudent));
|
||||
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_SolarRacer_MatchesJuniorSolarSprint()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
Aria Chittenden,Solar Racer
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Issues, Is.Empty);
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_jss));
|
||||
}
|
||||
|
||||
[TestCase("Solar Racer", "Junior Solar Sprint")]
|
||||
[TestCase("Solar Race", "Junior Solar Sprint")]
|
||||
[TestCase("Challenging Tech", "Challenging Technology Issues")]
|
||||
[TestCase("Digital Photo", "Digital Photography")]
|
||||
[TestCase("Forensics", "Forensic Technology")]
|
||||
[TestCase("Micro Controller", "Microcontroller Design")]
|
||||
[TestCase("Med Tech", "Medical Technology")]
|
||||
[TestCase("Innovations & Inventions", "Inventions & Innovations")]
|
||||
[TestCase("Inventions and Innovations", "Inventions & Innovations")]
|
||||
[TestCase("Systems Control Tech", "System Control Technology")]
|
||||
[TestCase("Systems Control Technology", "System Control Technology")]
|
||||
[TestCase("Structural Eng", "Structural Engineering")]
|
||||
[TestCase("Drone Challenge", "Drone Challenge (UAV)")]
|
||||
[TestCase("Robotics", "TSA Robotics")]
|
||||
[TestCase("Audio Podcast", "Audio Podcasting")]
|
||||
public void Parse_KnownAlias_MatchesOfficialEvent(string alias, string officialName)
|
||||
{
|
||||
var events = TestEntityHandler.GetEvents().ToList();
|
||||
events.AddRange(
|
||||
[
|
||||
EventDefinitionBuilder.Team("Drone Challenge (UAV)", 2, 6).WithShortName("Drone").Build(),
|
||||
EventDefinitionBuilder.Team("TSA Robotics", 2, 6).WithShortName("Robotics").Build(),
|
||||
EventDefinitionBuilder.Team("Audio Podcasting", 2, 6).WithShortName("Podcasting").Build()
|
||||
]);
|
||||
|
||||
var result = ParseCsv($"""
|
||||
Student Name,1
|
||||
Aria Chittenden,{alias}
|
||||
""", _students, events);
|
||||
|
||||
Assert.That(result.Issues, Is.Empty, $"Expected '{alias}' to match '{officialName}'");
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Matches[0].Ranking.EventDefinition.Name, Is.EqualTo(officialName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_UnmatchedEvent_IsReported()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
Aria Chittenden,Underwater Basket Weaving
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Matches, Is.Empty);
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedEvent));
|
||||
Assert.That(result.Issues[0].RawEventName, Is.EqualTo("Underwater Basket Weaving"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_AmbiguousEvent_IsReported()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1
|
||||
Aria Chittenden,Tech
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Matches, Is.Empty);
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.AmbiguousEvent));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_RankColumnsBeyondSix_AreRead()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1,2,3,4,5,6,7
|
||||
Aria Chittenden,Coding,,,,,,JSS
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(2));
|
||||
Assert.That(result.Matches.Select(m => m.Ranking.Rank), Is.EqualTo(new[] { 1, 7 }));
|
||||
Assert.That(result.Matches[1].Ranking.EventDefinition, Is.SameAs(_jss));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_DuplicateEvent_IsReported()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Student Name,1,2
|
||||
Aria Chittenden,Coding,Coding
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.Matches, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(1));
|
||||
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.DuplicateEvent));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_MissingStudentNameHeader_IsError()
|
||||
{
|
||||
var result = ParseCsv("""
|
||||
Name,1
|
||||
Aria Chittenden,Coding
|
||||
""", _students, _events);
|
||||
|
||||
Assert.That(result.IsSuccess, Is.False);
|
||||
Assert.That(result.Errors, Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_2024RankingsFile_MatchesKnownStudentsAndEvents()
|
||||
{
|
||||
var events = TestEntityHandler.GetEvents();
|
||||
var students = TestEntityHandler.GetStudents(events);
|
||||
var rankings = TestEntityHandler.GetStudentEventRankings(students, events);
|
||||
|
||||
Assert.That(students, Has.Length.EqualTo(29));
|
||||
Assert.That(rankings, Is.Not.Empty);
|
||||
Assert.That(rankings.Select(r => r.Student.FirstNameLastName).Distinct().Count(), Is.EqualTo(29));
|
||||
Assert.That(rankings.All(r => r.EventDefinition is not null), Is.True);
|
||||
Assert.That(rankings.All(r => r.Rank is >= 1 and <= 10), Is.True);
|
||||
Assert.That(rankings.Count(r => r.Student.FirstName == "First26"), Is.EqualTo(7));
|
||||
}
|
||||
|
||||
private static StudentEventRankingParseResult ParseCsv(
|
||||
string csv,
|
||||
ICollection<Student> students,
|
||||
ICollection<EventDefinition> events)
|
||||
{
|
||||
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
|
||||
using var parser = new StudentEventRankingParser(reader);
|
||||
return parser.Parse(students, events);
|
||||
}
|
||||
}
|
||||
@@ -69,9 +69,9 @@ public static class TestEntityHandler
|
||||
|
||||
public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events)
|
||||
{
|
||||
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2025 Student Event Rankings.csv");
|
||||
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2024 Student Event Rankings.csv");
|
||||
|
||||
var rankingParser = new StudentEventRankingParser(fileInfo);
|
||||
return rankingParser.Parse(students, events);
|
||||
using var rankingParser = new StudentEventRankingParser(fileInfo);
|
||||
return [.. rankingParser.Parse(students, events).Rankings];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
General Session
|
||||
Registration April 9 4:00 p.m. - 6:00 p.m. Banquet Room E
|
||||
TECHSPO April 9 4:00 p.m. - 6:00 p.m. Main Hallway
|
||||
Tennessee TSA Store April 9 4:00 p.m. - 8:00 p.m. Meeting Room 1
|
||||
Curfew April 9 11:00 p.m. All Conference Locations
|
||||
Static Event Turn-In April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Help Desk April 10 10:00 a.m. - 5:00 p.m. CCC Lobby
|
||||
Mandatory Advisor Meeting April 10 10:00 a.m. - 11:00 a.m. Banquet Room E
|
||||
TECHSPO April 10 10:00 a.m. - 4:00 p.m. Main Hallway
|
||||
Tennessee TSA Store April 10 10:00 a.m. - 11:30 a.m. Meeting Room 1
|
||||
Branching Out Workshop April 10 11:30 a.m. - 12:30 p.m. Banquet Room E
|
||||
Finding Your Roots Workshop April 10 12:30 p.m. - 1:30 p.m. Banquet Room E
|
||||
Tennessee TSA Store April 10 12:30 p.m. - 4:00 p.m. Meeting Room 1
|
||||
Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
|
||||
Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
|
||||
Chapter Officer Meeting April 10 2:30 p.m. - 3:00 p.m. Banquet Room E
|
||||
Board Game Night April 10 8:00 p.m. - 10:00 p.m. Meeting Room 14
|
||||
Dance April 10 8:00 p.m. - 10:00 p.m. Exhibit Hall D
|
||||
Senior Social April 10 8:00 p.m. - 8:30 p.m. Banquet Room E
|
||||
Curfew April 10 11:00 p.m. All Conference Locations
|
||||
Voting Delegate Meeting April 11 8:00 a.m. - 9:00 a.m. Banquet Room E
|
||||
Help Desk April 11 9:00 a.m. - 5:00 p.m. CCC Lobby
|
||||
Tennessee TSA Store April 11 9:00 a.m. - 11:30 a.m. Meeting Room 1
|
||||
Tennessee TSA Store April 11 12:30 p.m. - 4:00 p.m. Meeting Room 1
|
||||
Business Meeting April 11 5:30 p.m. - 6:30 p.m. Exhibit Hall A
|
||||
Creating Community through Communication Workshop April 11 11:00 p.m. - 12:00 p.m. Banquet Room E
|
||||
Curfew April 11 11:00 p.m. All Conference Locations
|
||||
Awards Ceremony April 12 8:30 a.m. - 12:00 p.m. Exhibit Hall A
|
||||
|
||||
Audio Podcasting – MS
|
||||
Prompt Pick-Up April 9 6:00 p.m. Online
|
||||
Semifinalist Submissions Due April 11 9:00 a.m. Online
|
||||
|
||||
Biotechnology – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations/Interviews April 11 9:30 a.m. - 11:30 a.m. Exhibit Hall C
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
CAD Foundations – MS
|
||||
Setup, Event, & Interviews April 10 10:30 a.m. - 2:00 p.m. Meeting Room 6
|
||||
|
||||
Career Prep – MS
|
||||
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Interviews April 10 1:00 p.m. - 3:00 p.m. Meeting Room 10
|
||||
|
||||
Challenging Technology Issues – MS
|
||||
*NOTE: Preliminary Round Time Sign-ups listed as April 8 in Yapp — verify with event coordinator
|
||||
Preliminary Round Time Sign-ups April 8 6:00 p.m. - 7:00 p.m. Online
|
||||
Prelims Presentation – Holding Room April 10 10:30 a.m. - 1:00 p.m. Meeting Room 7
|
||||
Prelims Presentation – Presentation April 10 10:30 a.m. - 1:00 p.m. Meeting Room 8
|
||||
Semifinalist Round Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentation – Holding Room April 11 9:30 a.m. - 11:00 a.m. Meeting Room 9
|
||||
Semifinalist Presentation – Presentation April 11 9:30 a.m. - 11:00 a.m. Meeting Room 10
|
||||
|
||||
Children's Stories – MS
|
||||
Submit Entry April 9 6:00 p.m. - 7:00 p.m. Banquet Hall G
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Meeting Room 13
|
||||
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Reading/Interviews April 11 1:00 p.m. - 4:00 p.m. Meeting Room 18
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Coding – MS
|
||||
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
|
||||
On-Site Event April 11 12:30 p.m. - 3:00 p.m. Meeting Room 19
|
||||
|
||||
Community Service Video – MS
|
||||
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations April 10 3:00 p.m. - 4:00 p.m. Meeting Room 17
|
||||
|
||||
Construction Challenge – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations/Interviews April 11 10:30 a.m. - 12:30 p.m. Exhibit Hall C
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Cybersecurity – MS
|
||||
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
|
||||
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations April 11 3:30 p.m. - 4:30 p.m. Meeting Room 19
|
||||
|
||||
Data Science and Analytics – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Semifinalist Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Preparation April 11 3:00 p.m. - 4:30 p.m. Meeting Room 4
|
||||
Semifinalist Presentations April 11 3:00 p.m. - 4:30 p.m. Meeting Room 5
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Digital Photography – MS
|
||||
Semifinalist Setup, Onsite Problem April 10 10:00 a.m. - 1:00 p.m. Meeting Room 19
|
||||
Semifinalist Time Sign-Up April 10 10:00 a.m. - 1:00 p.m. Meeting Room 19
|
||||
Semifinalist Interviews April 10 4:00 p.m. - 5:00 p.m. Meeting Room 16
|
||||
|
||||
Dragster – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall B
|
||||
Time Trials April 10 10:00 a.m. - 11:00 a.m. Exhibit Hall B
|
||||
Semifinalist Interview Sign-ups April 10 12:00 p.m. - 12:15 p.m. Exhibit Hall B
|
||||
Semifinalist Interviews April 10 12:30 p.m. - 1:30 p.m. Exhibit Hall B
|
||||
Semifinalist Races April 10 2:30 p.m. - 3:00 p.m. Exhibit Hall B
|
||||
Project Pick-up April 10 5:00 p.m. - 5:30 p.m. Exhibit Hall B
|
||||
|
||||
Flight – MS
|
||||
Submit Entry & Sign Up April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Preliminary Round Testing April 10 10:00 a.m. - 11:30 a.m. Exhibit Hall C
|
||||
Semifinalist Construction and Flights April 10 1:00 p.m. - 3:30 p.m. Exhibit Hall C
|
||||
All Event Materials Picked Up April 10 4:00 p.m. - 4:30 p.m. Exhibit Hall C
|
||||
|
||||
Forensic Technology – MS
|
||||
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
|
||||
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations April 11 1:30 p.m. - 4:30 p.m. Meeting Room 9
|
||||
|
||||
Inventions and Innovations – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations/Interviews April 11 11:30 a.m. - 1:30 p.m. Exhibit Hall C
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Leadership Strategies – MS
|
||||
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Preliminary Presentation – Delivery April 10 1:30 p.m. - 4:00 p.m. Meeting Room 8
|
||||
Preliminary Presentation – Holding Room April 10 1:30 p.m. - 4:00 p.m. Meeting Room 7
|
||||
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinals Presentation – Delivery April 11 11:30 a.m. - 1:00 p.m. Meeting Room 10
|
||||
Semifinals Presentation – Holding Room April 11 11:30 a.m. - 1:00 p.m. Meeting Room 9
|
||||
|
||||
Mass Production – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations/Interviews April 11 12:30 p.m. - 2:30 p.m. Exhibit Hall C
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Mechanical Engineering – MS
|
||||
Submit Entry & Sign Up April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Design Trial April 10 3:00 p.m. - 4:00 p.m. Exhibit Hall C
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Medical Technology – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations/Interviews April 11 1:30 p.m. - 3:30 p.m. Exhibit Hall C
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Microcontroller Design – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Presentation Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Presentations/Interviews April 11 2:00 p.m. - 4:30 p.m. Exhibit Hall C
|
||||
|
||||
Off the Grid – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
|
||||
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
|
||||
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations/Interviews April 11 2:30 p.m. - 4:30 p.m. Exhibit Hall C
|
||||
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
|
||||
Prepared Speech – MS
|
||||
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Preliminary Presentations April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10
|
||||
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations April 11 1:30 p.m. - 3:00 p.m. Meeting Room 10
|
||||
|
||||
Problem Solving – MS
|
||||
Peer Kit Check April 11 12:00 p.m. - 12:30 p.m. Exhibit Hall C
|
||||
Onsite Problem April 11 12:30 p.m. - 3:00 p.m. Exhibit Hall C
|
||||
|
||||
Promotional Marketing – MS
|
||||
Semifinalist Setup, Onsite Problem April 11 9:30 a.m. - 11:00 a.m. Meeting Room 6
|
||||
|
||||
TSA Robotics – MS
|
||||
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Preliminary Round Review April 10 2:00 p.m. - 3:30 p.m. Exhibit Hall B
|
||||
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Interviews April 11 10:30 a.m. - 11:30 a.m. Exhibit Hall B
|
||||
|
||||
STEM Animation – MS
|
||||
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Presentations April 11 9:30 a.m. - 11:00 a.m. Meeting Room 16
|
||||
|
||||
Structural Engineering – MS
|
||||
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall B
|
||||
Semifinalist Build April 11 9:00 a.m. - 12:00 p.m. Exhibit Hall B
|
||||
Semifinalist Testing April 11 3:30 p.m. - 4:00 p.m. Exhibit Hall B
|
||||
Project Pick-up April 11 4:30 p.m. - 5:00 p.m. Exhibit Hall B
|
||||
|
||||
System Control Technology – MS
|
||||
Set-up, Performance, and Judging April 10 10:00 a.m. - 2:00 p.m. Meeting Room 3
|
||||
|
||||
Tech Bowl – MS
|
||||
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
|
||||
Bracket Released April 10 6:00 p.m. Online
|
||||
Semifinalist Competition April 11 9:00 a.m. - 1:00 p.m. Banquet Hall G
|
||||
Semifinalist Holding April 11 9:00 a.m. - 1:00 p.m. Banquet Hall H
|
||||
|
||||
Technical Design – MS
|
||||
Prompt Release April 10 10:00 a.m. Online
|
||||
Solution Submit April 11 9:00 a.m. - 10:00 a.m. Online
|
||||
Judging April 11 10:00 a.m. - 2:00 p.m. CRC
|
||||
|
||||
Video Game Design – MS
|
||||
Semifinalist Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 16
|
||||
|
||||
Website Design – MS
|
||||
Semifinalist Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
|
||||
Semifinalist Interviews April 10 10:00 a.m. - 12:00 p.m. Meeting Room 16
|
||||
@@ -0,0 +1,230 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using Core.YearTransition;
|
||||
using NUnit.Framework;
|
||||
using Tests.Builders;
|
||||
|
||||
namespace Tests.YearTransition;
|
||||
|
||||
[TestFixture]
|
||||
public class GraduatingGradeResolver_Tests
|
||||
{
|
||||
[Test]
|
||||
public void FromSchoolLevel_MiddleSchool_Returns8()
|
||||
{
|
||||
Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.MiddleSchool), Is.EqualTo(8));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FromSchoolLevel_HighSchool_Returns12()
|
||||
{
|
||||
Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.HighSchool), Is.EqualTo(12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FromSchoolLevel_Null_ReturnsNull()
|
||||
{
|
||||
Assert.That(GraduatingGradeResolver.FromSchoolLevel(null), Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public class YearTransitionPlanner_Tests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
StudentBuilder.ResetIdCounter();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SuggestReturning_BelowGraduatingGrade_IsTrue()
|
||||
{
|
||||
var student = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
|
||||
Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SuggestReturning_AtGraduatingGrade_IsFalse()
|
||||
{
|
||||
var student = StudentBuilder.Create("Ann", "Lee").WithGrade(8).Build();
|
||||
Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_PromotesReturningStudents_MiddleSchool()
|
||||
{
|
||||
var returning = StudentBuilder.Create("Ann", "Lee").WithGrade(6).WithTsaYear(1).Build();
|
||||
var graduating = StudentBuilder.Create("Bob", "Smith").WithGrade(8).WithTsaYear(3).Build();
|
||||
|
||||
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = [returning, graduating],
|
||||
ReturningStudentIds = new HashSet<int> { returning.Id },
|
||||
GraduatingGrade = 8,
|
||||
TargetCompetitionYear = "2027"
|
||||
});
|
||||
|
||||
Assert.That(plan.ReturningCount, Is.EqualTo(1));
|
||||
Assert.That(plan.RemovalCount, Is.EqualTo(1));
|
||||
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(7));
|
||||
Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(2));
|
||||
Assert.That(plan.StudentsToRemove[0].Id, Is.EqualTo(graduating.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_CapsGradeAtGraduatingGrade_HighSchool()
|
||||
{
|
||||
var senior = StudentBuilder.Create("Chris", "Young").WithGrade(12).WithTsaYear(4).Build();
|
||||
|
||||
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = [senior],
|
||||
ReturningStudentIds = new HashSet<int> { senior.Id },
|
||||
GraduatingGrade = 12,
|
||||
TargetCompetitionYear = "2027"
|
||||
});
|
||||
|
||||
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(12));
|
||||
Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(5));
|
||||
Assert.That(plan.Warnings, Has.Some.Contain("graduating grade"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_CapsGradeAtEight_WhenMiddleSchoolReturnerAtGraduatingGrade()
|
||||
{
|
||||
var eighth = StudentBuilder.Create("Dana", "Nguyen").WithGrade(8).WithTsaYear(2).Build();
|
||||
|
||||
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = [eighth],
|
||||
ReturningStudentIds = new HashSet<int> { eighth.Id },
|
||||
GraduatingGrade = 8,
|
||||
TargetCompetitionYear = "2027"
|
||||
});
|
||||
|
||||
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(8));
|
||||
Assert.That(plan.Warnings, Has.Some.Contain("graduating grade"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_AssignsOfficersAndClearsOthers()
|
||||
{
|
||||
var president = StudentBuilder.Create("Eve", "Adams").WithGrade(7).AsPresident().Build();
|
||||
var vp = StudentBuilder.Create("Frank", "Baker").WithGrade(6).Build();
|
||||
|
||||
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = [president, vp],
|
||||
ReturningStudentIds = new HashSet<int> { president.Id, vp.Id },
|
||||
GraduatingGrade = 8,
|
||||
TargetCompetitionYear = "2027",
|
||||
OfficerAssignments = new Dictionary<OfficerRole, int?>
|
||||
{
|
||||
[OfficerRole.President] = vp.Id,
|
||||
[OfficerRole.VicePresident] = president.Id
|
||||
}
|
||||
});
|
||||
|
||||
var eve = plan.Promotions.Single(p => p.Student.Id == president.Id);
|
||||
var frank = plan.Promotions.Single(p => p.Student.Id == vp.Id);
|
||||
|
||||
Assert.That(eve.PreviousOfficerRole, Is.EqualTo(OfficerRole.President));
|
||||
Assert.That(eve.NewOfficerRole, Is.EqualTo(OfficerRole.VicePresident));
|
||||
Assert.That(frank.NewOfficerRole, Is.EqualTo(OfficerRole.President));
|
||||
|
||||
var presidentChange = plan.OfficerChanges.Single(c => c.Role == OfficerRole.President);
|
||||
Assert.That(presidentChange.PreviousOfficer!.Id, Is.EqualTo(president.Id));
|
||||
Assert.That(presidentChange.NewOfficer!.Id, Is.EqualTo(vp.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_WarnsWhenOfficerAssignedToNonReturningStudent()
|
||||
{
|
||||
var returning = StudentBuilder.Create("Gina", "Cole").WithGrade(6).Build();
|
||||
var leaving = StudentBuilder.Create("Hank", "Diaz").WithGrade(8).Build();
|
||||
|
||||
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = [returning, leaving],
|
||||
ReturningStudentIds = new HashSet<int> { returning.Id },
|
||||
GraduatingGrade = 8,
|
||||
TargetCompetitionYear = "2027",
|
||||
OfficerAssignments = new Dictionary<OfficerRole, int?>
|
||||
{
|
||||
[OfficerRole.President] = leaving.Id
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(plan.Warnings, Has.Some.Contain("not marked returning"));
|
||||
Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_WarnsWhenSameStudentHasTwoOffices()
|
||||
{
|
||||
var student = StudentBuilder.Create("Ivy", "Evans").WithGrade(7).Build();
|
||||
|
||||
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = [student],
|
||||
ReturningStudentIds = new HashSet<int> { student.Id },
|
||||
GraduatingGrade = 8,
|
||||
TargetCompetitionYear = "2027",
|
||||
OfficerAssignments = new Dictionary<OfficerRole, int?>
|
||||
{
|
||||
[OfficerRole.President] = student.Id,
|
||||
[OfficerRole.Treasurer] = student.Id
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(plan.Warnings, Has.Some.Contain("more than one officer role"));
|
||||
Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MatchPastedNames_MatchesLastCommaFirstAndFirstLast()
|
||||
{
|
||||
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
|
||||
var b = StudentBuilder.Create("Bob", "Smith").WithGrade(7).Build();
|
||||
|
||||
var result = YearTransitionPlanner.MatchPastedNames(
|
||||
[a, b],
|
||||
["Lee, Ann", "Bob Smith", "Nobody Here"]);
|
||||
|
||||
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id }));
|
||||
Assert.That(result.UnmatchedNames, Is.EquivalentTo(new[] { "Nobody Here" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MatchPastedNames_AmbiguousWhenDuplicateNames()
|
||||
{
|
||||
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
|
||||
var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
|
||||
|
||||
var result = YearTransitionPlanner.MatchPastedNames([a, b], ["Ann Lee"]);
|
||||
|
||||
Assert.That(result.AmbiguousNames, Is.EquivalentTo(new[] { "Ann Lee" }));
|
||||
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_IncludesUnmatchedAndAmbiguousFromPaste()
|
||||
{
|
||||
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
|
||||
var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
|
||||
|
||||
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = [a, b],
|
||||
ReturningStudentIds = new HashSet<int> { a.Id },
|
||||
GraduatingGrade = 8,
|
||||
TargetCompetitionYear = "2027",
|
||||
PastedNames = ["Ann Lee", "Zed Zulu"]
|
||||
});
|
||||
|
||||
Assert.That(plan.AmbiguousPastedNames, Has.Member("Ann Lee"));
|
||||
Assert.That(plan.UnmatchedPastedNames, Has.Member("Zed Zulu"));
|
||||
Assert.That(plan.Warnings, Has.Some.Contain("matched more than one"));
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<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/login.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
// Blur the active element to ensure MudTextField bindings update
|
||||
await JS.InvokeVoidAsync("eval", "document.activeElement.blur()");
|
||||
await JS.InvokeVoidAsync("tsaLogin.blurActiveElement");
|
||||
|
||||
// Small delay to allow bindings to process
|
||||
await Task.Delay(50);
|
||||
@@ -146,14 +146,11 @@
|
||||
|
||||
private async Task HandleFormSubmit()
|
||||
{
|
||||
// Update hidden inputs with current model values, then submit the form
|
||||
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
|
||||
await JS.InvokeVoidAsync("eval", $@"
|
||||
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
||||
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
||||
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
||||
document.getElementById('returnUrlInput').value = {returnUrlValue};
|
||||
document.getElementById('loginForm').submit();
|
||||
");
|
||||
await JS.InvokeVoidAsync(
|
||||
"tsaLogin.submitForm",
|
||||
_loginModel.Email ?? string.Empty,
|
||||
_loginModel.Password ?? string.Empty,
|
||||
_loginModel.RememberMe,
|
||||
_returnUrl ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<PageHeader
|
||||
Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")"
|
||||
Description="Yearly theme: Unity Through Community" />
|
||||
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
|
||||
|
||||
@if (_events == null)
|
||||
{
|
||||
@@ -66,7 +66,7 @@ else
|
||||
{
|
||||
<MudItem xs="3">
|
||||
<MudText Class="d-flex py-1">
|
||||
<i>Theme for 2025-26:</i>
|
||||
<i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="8">
|
||||
@@ -91,83 +91,6 @@ else
|
||||
<MudDivider />
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
<MudContainer>
|
||||
@foreach (var evt in _events)
|
||||
{
|
||||
<MudContainer Class="mt-3 mb-1 nobrk">
|
||||
<MudGrid>
|
||||
<MudItem xs="4">
|
||||
<MudStack>
|
||||
<MudItem>
|
||||
<MudText Class="d-flex py-1" Typo="Typo.h5">@evt.Name</MudText>
|
||||
</MudItem>
|
||||
@if (evt.RegionalEvent)
|
||||
{
|
||||
<MudItem>
|
||||
<MudText Class="d-flex" Typo="Typo.caption"><i>Regional Event</i></MudText>
|
||||
</MudItem>
|
||||
}
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="2">
|
||||
|
||||
<MudText>
|
||||
@if (evt.EventFormat is EventFormat.Team)
|
||||
{
|
||||
<strong>@evt.EventFormat</strong>
|
||||
<br />
|
||||
<p>Size: <strong>@evt.TeamSize</strong></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<strong>@evt.EventFormat</strong>
|
||||
}
|
||||
</MudText>
|
||||
|
||||
</MudItem>
|
||||
<MudItem xs="3">
|
||||
Eligibility: @evt.Eligibility
|
||||
</MudItem>
|
||||
<MudItem xs="1">
|
||||
<strong> Effort</strong>: @evt.LevelOfEffort
|
||||
</MudItem>
|
||||
<MudItem xs="2">
|
||||
<strong>Activity</strong>: @evt.SemifinalistActivity
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Description</MudText>
|
||||
</MudItem>
|
||||
@if (!string.IsNullOrEmpty(evt.Theme))
|
||||
{
|
||||
<MudItem xs="3">
|
||||
<MudText Class="d-flex py-1">
|
||||
<i>Theme for 2025-26:</i>
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="8">
|
||||
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Theme</MudText>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(evt.Documentation))
|
||||
{
|
||||
<MudItem xs="3">
|
||||
<MudText Class="d-flex py-1">
|
||||
<i>Materials:</i>
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="8">
|
||||
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Documentation</MudText>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
<MudDivider />
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
@code {
|
||||
private EventDefinition[]? _events;
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
Title="Student Event Ranks"
|
||||
Icon="@AppIcons.EventRank">
|
||||
<ActionButtons>
|
||||
<MudTooltip Text="Import rankings from CSV">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.UploadFile"
|
||||
Href="students/event-ranking/import"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary">
|
||||
Import
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
<PageNoteButton PageIdentifier="Event Ranking" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
@page "/students/event-ranking-edit/{StudentId:int}"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using BlazorSortableList
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -33,7 +31,6 @@ else
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@* https://github.com/AlexNek/BlazorSortableList *@
|
||||
<MudGrid Class="mt-2">
|
||||
<MudItem xs="12" md="6" xl="4">
|
||||
<MudPaper Class="pa-3 mb-3" Elevation="2">
|
||||
@@ -41,29 +38,52 @@ else
|
||||
<MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" Class="mr-2" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Ranked Events</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Drag events here in order of preference</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Use the arrows to set rank. Use the X to remove an event. Up to @StudentEventRanking.MaxRank events.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
<SortableList
|
||||
Group="GroupId" Id="ListId1" Context="item"
|
||||
Items="_rankedEvents" OnRemove="RankedEventsRemove" OnUpdate="Update">
|
||||
<SortableItemTemplate>
|
||||
<MudCard Outlined="true" Class="mb-2">
|
||||
<MudCardContent Class="pa-2">
|
||||
<div class="d-flex align-center">
|
||||
<MudBadge Content="@(_rankedEvents.IndexOf(item) + 1)" Color="Color.Primary" Overlap="true" Class="mr-3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" />
|
||||
</MudBadge>
|
||||
<div class="flex-grow-1">
|
||||
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
|
||||
<MudText Typo="Typo.caption">
|
||||
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</SortableItemTemplate>
|
||||
</SortableList>
|
||||
@if (_rankedEvents.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
No ranked events yet. Add events from the list on the right.
|
||||
</MudAlert>
|
||||
}
|
||||
@for (var i = 0; i < _rankedEvents.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var item = _rankedEvents[index];
|
||||
var rank = index + 1;
|
||||
<MudCard @key="item.Id" Outlined="true" Class="mb-2">
|
||||
<MudCardContent Class="pa-2">
|
||||
<div class="d-flex align-center">
|
||||
<MudBadge Content="@rank" Color="Color.Primary" Overlap="true" Class="mr-3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" />
|
||||
</MudBadge>
|
||||
<div class="flex-grow-1">
|
||||
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
|
||||
<MudText Typo="Typo.caption">
|
||||
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
|
||||
</MudText>
|
||||
</div>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowUp"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == 0)"
|
||||
aria-label="Move up"
|
||||
OnClick="@(() => MoveRanked(item, -1))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowDown"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == _rankedEvents.Count - 1)"
|
||||
aria-label="Move down"
|
||||
OnClick="@(() => MoveRanked(item, 1))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
aria-label="Remove from ranked events"
|
||||
OnClick="@(() => RemoveRanked(item))" />
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6" xl="4">
|
||||
<MudPaper Class="pa-3 mb-3" Elevation="2">
|
||||
@@ -71,99 +91,117 @@ else
|
||||
<MudIcon Icon="@AppIcons.Events" Class="mr-2" />
|
||||
<MudText Typo="Typo.h6">Available Events</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Drag events to rank them</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@if (AtMaxRank)
|
||||
{
|
||||
<text>Maximum of @StudentEventRanking.MaxRank ranked events reached. Remove one to add another.</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Use Add to place an event at the end of the ranked list.</text>
|
||||
}
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
<SortableList
|
||||
Group="GroupId" Id="ListId2" Context="item"
|
||||
Items="_availableEvents" OnRemove="AvailableEventsRemove" Sort="false">
|
||||
<SortableItemTemplate>
|
||||
<MudCard Outlined="true" Class="mb-2">
|
||||
<MudCardContent Class="pa-2">
|
||||
@if (_availableEvents.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true">
|
||||
@(_rankedEvents.Count == 0 ? "No events are available to rank." : "All events are already ranked.")
|
||||
</MudAlert>
|
||||
}
|
||||
@foreach (var item in _availableEvents)
|
||||
{
|
||||
<MudCard @key="item.Id" Outlined="true" Class="mb-2">
|
||||
<MudCardContent Class="pa-2">
|
||||
<div class="d-flex align-center">
|
||||
<div class="flex-grow-1">
|
||||
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
|
||||
<MudText Typo="Typo.caption">
|
||||
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
|
||||
</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</SortableItemTemplate>
|
||||
</SortableList>
|
||||
</div>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Add"
|
||||
Color="Color.Primary"
|
||||
Disabled="@AtMaxRank"
|
||||
aria-label="@($"Add {item.Name} to ranked events")"
|
||||
OnClick="@(() => AddRanked(item))" />
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
private const string ListId1 = "SharedListId1";
|
||||
private const string ListId2 = "SharedListId2";
|
||||
private const string GroupId = "CommonGroup";
|
||||
|
||||
[Parameter] public int? StudentId { get; set; }
|
||||
|
||||
private Student? _student;
|
||||
private List<EventDefinition>? _events;
|
||||
|
||||
public List<EventDefinition> _rankedEvents = [];
|
||||
public List<EventDefinition> _availableEvents = [];
|
||||
private List<EventDefinition> _rankedEvents = [];
|
||||
private List<EventDefinition> _availableEvents = [];
|
||||
|
||||
private void RankedEventsRemove((int oldIndex, int newIndex) indices)
|
||||
{
|
||||
// get the item at the old index in list 1
|
||||
var item = _rankedEvents[indices.oldIndex];
|
||||
|
||||
// add it to the new index in list 2
|
||||
_availableEvents.Insert(indices.newIndex, item);
|
||||
|
||||
// remove the item from the old index in list 1
|
||||
_rankedEvents.Remove(_rankedEvents[indices.oldIndex]);
|
||||
}
|
||||
|
||||
private void AvailableEventsRemove((int oldIndex, int newIndex) indices)
|
||||
{
|
||||
// get the item at the old index in list 2
|
||||
var item = _availableEvents[indices.oldIndex];
|
||||
|
||||
// add it to the new index in list 1
|
||||
_rankedEvents.Insert(indices.newIndex, item);
|
||||
|
||||
// remove the item from the old index in list 2
|
||||
_availableEvents.Remove(_availableEvents[indices.oldIndex]);
|
||||
}
|
||||
private bool AtMaxRank => _rankedEvents.Count >= StudentEventRanking.MaxRank;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_student =
|
||||
await Context.Students
|
||||
.Include(e => e.EventRankings)
|
||||
.Where(e => e.Id == StudentId).FirstAsync();
|
||||
_events =
|
||||
.ThenInclude(r => r.EventDefinition)
|
||||
.Where(e => e.Id == StudentId)
|
||||
.FirstAsync();
|
||||
|
||||
var events =
|
||||
await Context.Events
|
||||
.OrderBy(e => e.Name)
|
||||
.ToListAsync();
|
||||
.ToListAsync();
|
||||
|
||||
_rankedEvents = _student.EventRankings.OrderBy(e => e.Rank).Select(e => e.EventDefinition).ToList();
|
||||
_availableEvents = _events.Where(e => !_rankedEvents.Contains(e)).ToList();
|
||||
_rankedEvents = _student.EventRankings
|
||||
.Where(e => e.EventDefinition != null)
|
||||
.OrderBy(e => e.Rank)
|
||||
.Select(e => e.EventDefinition)
|
||||
.DistinctBy(e => e.Id)
|
||||
.Take(StudentEventRanking.MaxRank)
|
||||
.ToList();
|
||||
|
||||
var rankedIds = _rankedEvents.Select(e => e.Id).ToHashSet();
|
||||
_availableEvents = events.Where(e => !rankedIds.Contains(e.Id)).ToList();
|
||||
}
|
||||
|
||||
private void Update((int oldIndex, int newIndex) indices)
|
||||
private void AddRanked(EventDefinition item)
|
||||
{
|
||||
var (oldIndex, newIndex) = indices;
|
||||
|
||||
var items = _rankedEvents;
|
||||
var itemToMove = items[oldIndex];
|
||||
items.RemoveAt(oldIndex);
|
||||
|
||||
if (newIndex < items.Count)
|
||||
if (AtMaxRank || _rankedEvents.Any(e => e.Id == item.Id))
|
||||
{
|
||||
items.Insert(newIndex, itemToMove);
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(itemToMove);
|
||||
return;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
_availableEvents.RemoveAll(e => e.Id == item.Id);
|
||||
_rankedEvents.Add(item);
|
||||
}
|
||||
|
||||
private void RemoveRanked(EventDefinition item)
|
||||
{
|
||||
_rankedEvents.RemoveAll(e => e.Id == item.Id);
|
||||
if (_availableEvents.All(e => e.Id != item.Id))
|
||||
{
|
||||
_availableEvents.Add(item);
|
||||
_availableEvents.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveRanked(EventDefinition item, int offset)
|
||||
{
|
||||
var index = _rankedEvents.FindIndex(e => e.Id == item.Id);
|
||||
var newIndex = index + offset;
|
||||
if (index < 0 || newIndex < 0 || newIndex >= _rankedEvents.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rankedEvents.RemoveAt(index);
|
||||
_rankedEvents.Insert(newIndex, item);
|
||||
}
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
@@ -172,10 +210,12 @@ else
|
||||
|
||||
try
|
||||
{
|
||||
var uniqueRanked = _rankedEvents.DistinctBy(e => e.Id).Take(StudentEventRanking.MaxRank).ToList();
|
||||
|
||||
_student.EventRankings.Clear();
|
||||
for (var index = 0; index < _rankedEvents.Count; index++)
|
||||
for (var index = 0; index < uniqueRanked.Count; index++)
|
||||
{
|
||||
var evt = _rankedEvents[index];
|
||||
var evt = uniqueRanked[index];
|
||||
_student.EventRankings.Add(new StudentEventRanking
|
||||
{
|
||||
EventDefinition = evt,
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
@page "/students/event-ranking/import"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Core.Models
|
||||
@using Core.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@inject IStudentEventRankingImportService ImportService
|
||||
@inject IStudentEventRankingSaveService SaveService
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject ILogger<EventRankingImport> Logger
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageHeader
|
||||
Title="Import Event Rankings"
|
||||
Description="Upload a CSV of student event preferences, preview matches, then save."
|
||||
Icon="@AppIcons.EventRank"
|
||||
ShowBackButton="true"
|
||||
BackButtonUrl="/students/event-ranking" />
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.body2">
|
||||
Required column: <code>Student Name</code>. Rank columns are <code>1</code> through <code>10</code>.
|
||||
Names can be <code>Last, First</code> or <code>First Last</code>. Event cells can be a full name, short name, or a close match.
|
||||
</MudText>
|
||||
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
|
||||
@if (!string.IsNullOrEmpty(_fileName))
|
||||
{
|
||||
<MudText Typo="Typo.caption">@_fileName</MudText>
|
||||
}
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudButton
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Article"
|
||||
OnClick="HandleParse"
|
||||
Disabled="@(_isParsing || _fileBytes is null)">
|
||||
Parse
|
||||
</MudButton>
|
||||
<MudButton
|
||||
Variant="Variant.Text"
|
||||
OnClick="HandleClear"
|
||||
Disabled="@_isParsing">
|
||||
Clear
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
|
||||
|
||||
@if (_isParsing)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Class="mb-4" />
|
||||
<MudText>Parsing...</MudText>
|
||||
}
|
||||
else if (_parseResult == null)
|
||||
{
|
||||
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="3">
|
||||
@foreach (var error in _parseResult.Errors)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@error</MudAlert>
|
||||
}
|
||||
|
||||
@foreach (var warning in _parseResult.Warnings)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true">@warning</MudAlert>
|
||||
}
|
||||
|
||||
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Success" Dense="true">
|
||||
Matched @_parseResult.TotalParsed ranking(s) for @_parseResult.StudentsWithAcceptedRanks.Count student(s)
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (_parseResult.Issues.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Elevation="0">
|
||||
<MudExpansionPanel Text="@($"Issues ({_parseResult.Issues.Count})")"
|
||||
Icon="@Icons.Material.Filled.Warning">
|
||||
<MudTable Items="@_parseResult.Issues" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Row</MudTh>
|
||||
<MudTh>Rank</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Student</MudTh>
|
||||
<MudTh>Event text</MudTh>
|
||||
<MudTh>Message</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Row">@context.RowNumber</MudTd>
|
||||
<MudTd DataLabel="Rank">@(context.Rank > 0 ? context.Rank.ToString() : "-")</MudTd>
|
||||
<MudTd DataLabel="Type">
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetIssueTypeColor(context.IssueType)">
|
||||
@context.IssueType
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Student">@context.RawStudentName</MudTd>
|
||||
<MudTd DataLabel="Event text">@context.RawEventName</MudTd>
|
||||
<MudTd DataLabel="Message">@context.Message</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
|
||||
@if (_parseResult.IsSuccess && _parseResult.Matches.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.h6">Matched rankings</MudText>
|
||||
<MudTable Items="@_parseResult.Matches" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Student</MudTh>
|
||||
<MudTh>Rank</MudTh>
|
||||
<MudTh>CSV text</MudTh>
|
||||
<MudTh>Matched event</MudTh>
|
||||
<MudTh>Score</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Student">@context.Ranking.Student.FirstNameLastName</MudTd>
|
||||
<MudTd DataLabel="Rank">@context.Ranking.Rank</MudTd>
|
||||
<MudTd DataLabel="CSV text">@context.RawEventName</MudTd>
|
||||
<MudTd DataLabel="Matched event">@context.Ranking.EventDefinition.Name</MudTd>
|
||||
<MudTd DataLabel="Score">@context.EventScore</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
||||
<MudButton
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="HandleSave"
|
||||
Disabled="@_isSaving">
|
||||
Save to Database
|
||||
</MudButton>
|
||||
<MudButton
|
||||
Variant="Variant.Text"
|
||||
OnClick="HandleClearResults">
|
||||
Clear Results
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private byte[]? _fileBytes;
|
||||
private string? _fileName;
|
||||
private StudentEventRankingParseResult? _parseResult;
|
||||
private bool _isParsing;
|
||||
private bool _isSaving;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
private async Task HandleFileChanged(InputFileChangeEventArgs args)
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
|
||||
await using var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
|
||||
_fileBytes = memory.ToArray();
|
||||
_fileName = args.File.Name;
|
||||
_parseResult = null;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error reading ranking CSV");
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleParse()
|
||||
{
|
||||
if (_fileBytes is null)
|
||||
{
|
||||
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_isParsing = true;
|
||||
try
|
||||
{
|
||||
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
var students = await Context.Students
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.LastName)
|
||||
.ThenBy(s => s.FirstName)
|
||||
.ToListAsync(token);
|
||||
var events = await Context.Events
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.Name)
|
||||
.ToListAsync(token);
|
||||
|
||||
await using var stream = new MemoryStream(_fileBytes, writable: false);
|
||||
_parseResult = ImportService.Parse(stream, students, events);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error parsing ranking CSV");
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error parsing CSV: {ex.Message}", Severity.Error);
|
||||
_parseResult = new StudentEventRankingParseResult
|
||||
{
|
||||
Errors = { $"Error: {ex.Message}" }
|
||||
};
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isParsing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleClear()
|
||||
{
|
||||
_fileBytes = null;
|
||||
_fileName = null;
|
||||
_parseResult = null;
|
||||
}
|
||||
|
||||
private void HandleClearResults()
|
||||
{
|
||||
_parseResult = null;
|
||||
}
|
||||
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (_parseResult is null || !_parseResult.IsSuccess || _parseResult.TotalParsed == 0)
|
||||
{
|
||||
Snackbar.Add("No valid rankings to save", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
var existing = await SaveService.GetStudentsWithExistingRankingsAsync(_parseResult, token);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
var preview = string.Join(", ", existing.Take(8));
|
||||
var remaining = existing.Count > 8 ? $" and {existing.Count - 8} more" : string.Empty;
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Replace existing rankings?",
|
||||
$"This will replace current rankings for {existing.Count} student(s): {preview}{remaining}. Continue?",
|
||||
yesText: "Replace rankings",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true || _isDisposed)
|
||||
return;
|
||||
}
|
||||
|
||||
_isSaving = true;
|
||||
var saveResult = await SaveService.SaveAsync(_parseResult, token);
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
Snackbar.Add(
|
||||
$"Saved {saveResult.RankingsSaved} ranking(s) for {saveResult.StudentsUpdated} student(s)",
|
||||
Severity.Success);
|
||||
NavigationManager.NavigateTo("/students/event-ranking");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error saving imported rankings");
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add($"Error saving rankings: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Color GetIssueTypeColor(StudentEventRankingIssueType issueType) =>
|
||||
issueType switch
|
||||
{
|
||||
StudentEventRankingIssueType.UnmatchedStudent => Color.Error,
|
||||
StudentEventRankingIssueType.UnmatchedEvent => Color.Warning,
|
||||
StudentEventRankingIssueType.AmbiguousEvent => Color.Warning,
|
||||
StudentEventRankingIssueType.DuplicateEvent => Color.Info,
|
||||
StudentEventRankingIssueType.DuplicateRank => Color.Info,
|
||||
StudentEventRankingIssueType.InvalidFormat => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<PageHeader
|
||||
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
|
||||
Description="Yearly theme: Unity Through Community" />
|
||||
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
|
||||
|
||||
@if (_teams == null || _students == null)
|
||||
{
|
||||
@@ -74,7 +74,7 @@ else
|
||||
{
|
||||
<MudItem xs="3">
|
||||
<MudText Class="d-flex py-1">
|
||||
<i>Theme for 2025-26:</i>
|
||||
<i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="8">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<PageHeader
|
||||
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
|
||||
Description="Yearly theme: Unity Through Community" />
|
||||
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
|
||||
|
||||
<Legend></Legend>
|
||||
@if (_teams == null)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
@using WebApp.Authentication
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using System.Text.Json
|
||||
@using WebApp.Services
|
||||
@using Core.Models
|
||||
@inject IWebHostEnvironment Environment
|
||||
@inject IConfiguration Configuration
|
||||
@inject IChapterSettingsWriter ChapterSettingsWriter
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
MaxLength="4"
|
||||
Required="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="_settings.YearlyTheme"
|
||||
Label="Yearly Theme"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="National TSA yearly theme (e.g., Unity Through Community)" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect T="SchoolLevel?" @bind-Value="_settings.SchoolLevel"
|
||||
Label="School Level"
|
||||
@@ -125,19 +131,10 @@
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Load from IConfiguration
|
||||
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
||||
?? new Models.ChapterSettings();
|
||||
}
|
||||
|
||||
private string GetAppSettingsPath()
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.ContentRootPath,
|
||||
"Data",
|
||||
"appsettings.json");
|
||||
}
|
||||
|
||||
private async Task SaveSettings()
|
||||
{
|
||||
if (_settings == null) return;
|
||||
@@ -147,41 +144,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var appSettingsPath = GetAppSettingsPath();
|
||||
|
||||
// Ensure Data directory exists
|
||||
var dataDir = Path.GetDirectoryName(appSettingsPath);
|
||||
if (dataDir != null && !Directory.Exists(dataDir))
|
||||
{
|
||||
Directory.CreateDirectory(dataDir);
|
||||
}
|
||||
|
||||
// Read existing appsettings or create new
|
||||
JsonDocument? existingDoc = null;
|
||||
Dictionary<string, object?> settings;
|
||||
|
||||
if (File.Exists(appSettingsPath))
|
||||
{
|
||||
var existingJson = await File.ReadAllTextAsync(appSettingsPath);
|
||||
existingDoc = JsonDocument.Parse(existingJson);
|
||||
settings = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
|
||||
?? new Dictionary<string, object?>();
|
||||
}
|
||||
else
|
||||
{
|
||||
settings = new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
// Update ChapterSettings section
|
||||
settings["ChapterSettings"] = _settings;
|
||||
|
||||
// Write back to file
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
var json = JsonSerializer.Serialize(settings, options);
|
||||
await File.WriteAllTextAsync(appSettingsPath, json);
|
||||
|
||||
existingDoc?.Dispose();
|
||||
|
||||
await ChapterSettingsWriter.WriteAsync(_settings);
|
||||
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
||||
_statusSeverity = Severity.Success;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
@page "/settings/new-year"
|
||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||
@implements IAsyncDisposable
|
||||
@using Core.Entities
|
||||
@using Core.Models
|
||||
@using Core.YearTransition
|
||||
@using Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Authentication
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using WebApp.Services
|
||||
@inject AppDbContext Context
|
||||
@inject IConfiguration Configuration
|
||||
@inject IYearRolloverService YearRolloverService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<YearRollover> Logger
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageHeader
|
||||
Title="New Year Rollover"
|
||||
Description="Promote returning students, set officers, and clear last season's data after an automatic database backup."
|
||||
ShowBackButton="true"
|
||||
BackButtonUrl="/settings/chapter" />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 mb-8">
|
||||
@if (_result != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Success" Class="mb-4" Variant="Variant.Filled">
|
||||
Rollover to @_result.CompetitionYear completed successfully.
|
||||
</MudAlert>
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-3">Summary</MudText>
|
||||
<MudText>Backup: <code>@_result.BackupPath</code></MudText>
|
||||
<MudText>Students promoted: @_result.StudentsPromoted</MudText>
|
||||
<MudText>Students removed: @_result.StudentsRemoved</MudText>
|
||||
<MudText>Teams deleted: @_result.TeamsDeleted</MudText>
|
||||
<MudText>Event rankings deleted: @_result.RankingsDeleted</MudText>
|
||||
<MudText>Meeting histories deleted: @_result.MeetingHistoriesDeleted</MudText>
|
||||
<MudText>Event occurrences deleted: @_result.EventOccurrencesDeleted</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mt-3 mb-1">Officers</MudText>
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var line in _result.OfficerSummary)
|
||||
{
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.Badge">@line</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4" Dense="true">
|
||||
Restart the application so printouts and the home page show the new competition year.
|
||||
Then add new students, import the new state schedule, and clear Meeting Schedule browser state with Reset.
|
||||
</MudAlert>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (_students == null)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" />
|
||||
}
|
||||
else if (!_wizardUnlocked)
|
||||
{
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="mb-4" Icon="@Icons.Material.Filled.Lock">
|
||||
This wizard permanently changes production chapter data. It is locked until you intentionally unlock it.
|
||||
</MudAlert>
|
||||
|
||||
<MudText Typo="Typo.h5" Class="mb-3">Before you continue</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Unlocking lets you plan a rollover. Applying it will still require a second typed confirmation.
|
||||
An automatic database backup is created immediately before apply and is the only undo.
|
||||
</MudText>
|
||||
|
||||
<MudList T="string" Dense="true" Class="mb-4">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.DeleteForever" IconColor="Color.Error">
|
||||
Non-returning students are permanently deleted
|
||||
</MudListItem>
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.Groups" IconColor="Color.Error">
|
||||
All teams, event rankings, and meeting history are cleared
|
||||
</MudListItem>
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.Event" IconColor="Color.Warning">
|
||||
Event occurrences are cleared by default (state schedule)
|
||||
</MudListItem>
|
||||
</MudList>
|
||||
|
||||
<MudCheckBox @bind-Value="_ackDestructive" Color="Color.Error" Class="mb-2"
|
||||
Label="I understand this permanently deletes students and season data" />
|
||||
<MudCheckBox @bind-Value="_ackBackupOnlyUndo" Color="Color.Error" Class="mb-4"
|
||||
Label="I understand the automatic backup is the only undo" />
|
||||
|
||||
<MudTextField @bind-Value="_unlockPhrase"
|
||||
Label="@($"Type {UnlockPhrase} to unlock")"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="@($"Confirmation is case-insensitive. Type exactly: {UnlockPhrase}")"
|
||||
Class="mb-4"
|
||||
Style="max-width: 320px;"
|
||||
Immediate="true" />
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.LockOpen"
|
||||
Disabled="!CanUnlockWizard"
|
||||
OnClick="UnlockWizard">
|
||||
Unlock wizard
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.LockOpen">
|
||||
Wizard unlocked for this session. Close or refresh this page to lock it again.
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Default" Class="ml-2" OnClick="LockWizard">
|
||||
Lock again
|
||||
</MudButton>
|
||||
</MudAlert>
|
||||
|
||||
<MudStepper @bind-ActiveIndex="Step" Class="mb-4">
|
||||
<MudStep Title="Year">Year & grades</MudStep>
|
||||
<MudStep Title="Roster">Returning roster</MudStep>
|
||||
<MudStep Title="Officers">Officers</MudStep>
|
||||
<MudStep Title="Reset">Season reset</MudStep>
|
||||
<MudStep Title="Apply">Preview & apply</MudStep>
|
||||
</MudStepper>
|
||||
|
||||
@if (Step == 0)
|
||||
{
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-3">Competition year</MudText>
|
||||
<MudTextField @bind-Value="_targetYear"
|
||||
Label="Target competition year"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Defaults to current year + 1"
|
||||
Class="mb-4"
|
||||
Style="max-width: 200px;" />
|
||||
|
||||
<MudText Typo="Typo.h5" Class="mb-2">Chapter type</MudText>
|
||||
@if (_configuredSchoolLevel is { } configured)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
@GraduatingGradeResolver.Describe(configured, _graduatingGrade!.Value)
|
||||
<MudLink Href="/settings/chapter" Class="ml-2">Change in Chapter Settings</MudLink>
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
||||
School level is not set in Chapter Settings (Both MS and HS). Choose the chapter type for this rollover,
|
||||
then set it permanently on the <MudLink Href="/settings/chapter">Chapter Settings</MudLink> page.
|
||||
</MudAlert>
|
||||
<MudSelect T="SchoolLevel?" Value="_overrideSchoolLevel" Label="Chapter type for this rollover"
|
||||
Variant="Variant.Outlined" Class="mb-3" Style="max-width: 320px;"
|
||||
ValueChanged="OnOverrideSchoolLevelChanged">
|
||||
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.MiddleSchool">Middle School (graduate after grade 8)</MudSelectItem>
|
||||
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.HighSchool">High School (graduate after grade 12)</MudSelectItem>
|
||||
</MudSelect>
|
||||
}
|
||||
|
||||
<MudAlert Severity="Severity.Normal" Dense="true" Class="mt-2">
|
||||
Applying the rollover will create an automatic backup at
|
||||
<code>Data/backups/pre-rollover-*.db</code> before making any changes.
|
||||
That backup is the only undo.
|
||||
</MudAlert>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (Step == 1)
|
||||
{
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-3">Returning students</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3 mud-text-secondary">
|
||||
Students at or above graduating grade @_graduatingGrade are unchecked by default.
|
||||
Paste a list of names (one per line) to check matches.
|
||||
</MudText>
|
||||
|
||||
<MudTextField @bind-Value="_pasteBox"
|
||||
Label="Paste returning names (optional)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="4"
|
||||
Class="mb-2" />
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Class="mb-4" OnClick="ApplyPastedNames"
|
||||
StartIcon="@Icons.Material.Filled.ContentPaste">
|
||||
Apply pasted names
|
||||
</MudButton>
|
||||
|
||||
@if (_pasteUnmatched.Count > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
||||
Unmatched: @string.Join("; ", _pasteUnmatched)
|
||||
</MudAlert>
|
||||
}
|
||||
@if (_pasteAmbiguous.Count > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
||||
Ambiguous (check manually): @string.Join("; ", _pasteAmbiguous)
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudTable Items="_students" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Returning</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Grade</MudTh>
|
||||
<MudTh>TSA Year</MudTh>
|
||||
<MudTh>Officer</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudCheckBox T="bool" Value="_returningIds.Contains(context.Id)"
|
||||
ValueChanged="(bool v) => SetReturning(context.Id, v)"
|
||||
Dense="true" Color="Color.Primary" />
|
||||
</MudTd>
|
||||
<MudTd>@context.LastNameFirstName</MudTd>
|
||||
<MudTd>@context.Grade</MudTd>
|
||||
<MudTd>@context.TsaYear</MudTd>
|
||||
<MudTd>@(context.OfficerRole?.ToString() ?? "—")</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
<MudText Typo="Typo.caption" Class="mt-2">
|
||||
@_returningIds.Count returning · @(_students.Count - _returningIds.Count) will be removed
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (Step == 2)
|
||||
{
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-3">New officer slate</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary">
|
||||
Leave a role blank to leave that office vacant. Only returning students are listed.
|
||||
New students who will be officers can be assigned later on the student edit page.
|
||||
</MudText>
|
||||
<MudGrid>
|
||||
@foreach (var role in _officerRoles)
|
||||
{
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect T="int?" Value="GetOfficerSelection(role)"
|
||||
ValueChanged="(int? id) => SetOfficerSelection(role, id)"
|
||||
Label="@role.ToString()"
|
||||
Variant="Variant.Outlined"
|
||||
Clearable="true">
|
||||
@foreach (var student in ReturningStudents)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@student.Id">@student.LastNameFirstName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (Step == 3)
|
||||
{
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-3">Season reset</MudText>
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
||||
The following are always cleared: all teams, all event rankings, and all meeting history records.
|
||||
Written notes on the Notes page are not affected.
|
||||
</MudAlert>
|
||||
<MudCheckBox @bind-Value="_clearEventOccurrences" Color="Color.Primary" Label="Clear all event occurrences (state schedule)" />
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary">
|
||||
Leave this checked unless you plan to keep last year's calendar rows. Import the new schedule afterward.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (Step == 4)
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-3">Preview</MudText>
|
||||
<MudText>Competition year → <strong>@plan.TargetCompetitionYear</strong></MudText>
|
||||
<MudText>Promote <strong>@plan.ReturningCount</strong> students · Remove <strong>@plan.RemovalCount</strong> students</MudText>
|
||||
<MudText>Clear teams, rankings, meeting history@( _clearEventOccurrences ? ", and event occurrences" : "" )</MudText>
|
||||
|
||||
@if (plan.Warnings.Count > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mt-3 mb-2">
|
||||
<MudText Typo="Typo.subtitle2">Warnings</MudText>
|
||||
<ul class="mb-0">
|
||||
@foreach (var warning in plan.Warnings)
|
||||
{
|
||||
<li>@warning</li>
|
||||
}
|
||||
</ul>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudExpansionPanels Class="mt-3 mb-3">
|
||||
<MudExpansionPanel Text="@($"Promotions ({plan.ReturningCount})")">
|
||||
<MudTable Items="plan.Promotions" Dense="true" Hover="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Grade</MudTh>
|
||||
<MudTh>TSA Year</MudTh>
|
||||
<MudTh>Officer</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Student.LastNameFirstName</MudTd>
|
||||
<MudTd>@context.PreviousGrade → @context.NewGrade</MudTd>
|
||||
<MudTd>@context.PreviousTsaYear → @context.NewTsaYear</MudTd>
|
||||
<MudTd>@(context.PreviousOfficerRole?.ToString() ?? "—") → @(context.NewOfficerRole?.ToString() ?? "—")</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="@($"Removals ({plan.RemovalCount})")">
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var student in plan.StudentsToRemove)
|
||||
{
|
||||
<MudListItem T="string">@student.LastNameFirstName (grade @student.Grade)</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="Officers">
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var change in plan.OfficerChanges)
|
||||
{
|
||||
<MudListItem T="string">
|
||||
@change.Role:
|
||||
@(change.NewOfficer?.LastNameFirstName ?? "(vacant)")
|
||||
@if (change.PreviousOfficer != null)
|
||||
{
|
||||
<span class="mud-text-secondary"> (was @change.PreviousOfficer.LastNameFirstName)</span>
|
||||
}
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
|
||||
<MudTextField @bind-Value="_applyConfirmYear"
|
||||
Label="@($"Type {_targetYear.Trim()} to enable Apply")"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Must exactly match the target competition year above"
|
||||
Class="mb-4"
|
||||
Style="max-width: 280px;"
|
||||
Immediate="true" />
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Warning"
|
||||
Disabled="_isApplying || !CanApply"
|
||||
OnClick="ConfirmAndApply">
|
||||
@if (_isApplying)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Applying...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Apply rollover</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mt-2">
|
||||
<MudButton Variant="Variant.Text"
|
||||
Disabled="Step == 0 || _isApplying"
|
||||
OnClick="() => Step--">
|
||||
Back
|
||||
</MudButton>
|
||||
@if (Step < 4)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanGoNext"
|
||||
OnClick="GoNext">
|
||||
Next
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private const string UnlockPhrase = "ROLLOVER";
|
||||
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed;
|
||||
private bool _wizardUnlocked;
|
||||
private bool _ackDestructive;
|
||||
private bool _ackBackupOnlyUndo;
|
||||
private string _unlockPhrase = "";
|
||||
private string _applyConfirmYear = "";
|
||||
private int _step;
|
||||
private int Step
|
||||
{
|
||||
get => _step;
|
||||
set
|
||||
{
|
||||
_step = value;
|
||||
if (_step >= 1)
|
||||
EnsureReturningDefaults();
|
||||
if (_step >= 2)
|
||||
PruneOfficerSelections();
|
||||
}
|
||||
}
|
||||
private List<Student>? _students;
|
||||
private HashSet<int> _returningIds = [];
|
||||
private Dictionary<OfficerRole, int?> _officerSelections = [];
|
||||
private readonly OfficerRole[] _officerRoles = Enum.GetValues<OfficerRole>();
|
||||
|
||||
private string _targetYear = "2027";
|
||||
private SchoolLevel? _configuredSchoolLevel;
|
||||
private SchoolLevel? _overrideSchoolLevel;
|
||||
private int? _graduatingGrade;
|
||||
private string _pasteBox = "";
|
||||
private List<string> _pasteUnmatched = [];
|
||||
private List<string> _pasteAmbiguous = [];
|
||||
private bool _clearEventOccurrences = true;
|
||||
private bool _isApplying;
|
||||
private YearRolloverResult? _result;
|
||||
private bool _returningInitialized;
|
||||
|
||||
private IEnumerable<Student> ReturningStudents =>
|
||||
_students?.Where(s => _returningIds.Contains(s.Id)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName)
|
||||
?? Enumerable.Empty<Student>();
|
||||
|
||||
private bool CanUnlockWizard =>
|
||||
_ackDestructive &&
|
||||
_ackBackupOnlyUndo &&
|
||||
string.Equals(_unlockPhrase.Trim(), UnlockPhrase, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private bool CanApply =>
|
||||
_wizardUnlocked &&
|
||||
_graduatingGrade.HasValue &&
|
||||
!string.IsNullOrWhiteSpace(_targetYear) &&
|
||||
string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal);
|
||||
|
||||
private bool CanGoNext => Step switch
|
||||
{
|
||||
0 => _graduatingGrade.HasValue && !string.IsNullOrWhiteSpace(_targetYear),
|
||||
_ => true
|
||||
};
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
var currentYear = Configuration["ChapterSettings:CompetitionYear"] ?? "2026";
|
||||
if (int.TryParse(currentYear, out var year))
|
||||
_targetYear = (year + 1).ToString();
|
||||
else
|
||||
_targetYear = currentYear;
|
||||
|
||||
_configuredSchoolLevel = Configuration.GetSection("ChapterSettings").Get<WebApp.Models.ChapterSettings>()?.SchoolLevel
|
||||
?? ParseSchoolLevel(Configuration["ChapterSettings:SchoolLevel"]);
|
||||
_graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(_configuredSchoolLevel);
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
_students = await Context.Students
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.LastName)
|
||||
.ThenBy(s => s.FirstName)
|
||||
.ToListAsync(token);
|
||||
|
||||
foreach (var role in _officerRoles)
|
||||
_officerSelections[role] = null;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// disposed
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to load students for year rollover");
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add($"Failed to load students: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static SchoolLevel? ParseSchoolLevel(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
return Enum.TryParse<SchoolLevel>(value, ignoreCase: true, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private void UnlockWizard()
|
||||
{
|
||||
if (!CanUnlockWizard)
|
||||
return;
|
||||
|
||||
_wizardUnlocked = true;
|
||||
Step = 0;
|
||||
Snackbar.Add("Year rollover wizard unlocked for this session", Severity.Warning);
|
||||
}
|
||||
|
||||
private void LockWizard()
|
||||
{
|
||||
_wizardUnlocked = false;
|
||||
_ackDestructive = false;
|
||||
_ackBackupOnlyUndo = false;
|
||||
_unlockPhrase = "";
|
||||
_applyConfirmYear = "";
|
||||
Step = 0;
|
||||
}
|
||||
|
||||
private void OnOverrideSchoolLevelChanged(SchoolLevel? value)
|
||||
{
|
||||
_overrideSchoolLevel = value;
|
||||
_graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(value);
|
||||
_returningInitialized = false;
|
||||
}
|
||||
|
||||
private void EnsureReturningDefaults()
|
||||
{
|
||||
if (_returningInitialized || _students == null || !_graduatingGrade.HasValue)
|
||||
return;
|
||||
|
||||
_returningIds = _students
|
||||
.Where(s => YearTransitionPlanner.SuggestReturning(s, _graduatingGrade.Value))
|
||||
.Select(s => s.Id)
|
||||
.ToHashSet();
|
||||
_returningInitialized = true;
|
||||
}
|
||||
|
||||
private void GoNext()
|
||||
{
|
||||
Step++;
|
||||
}
|
||||
|
||||
private void SetReturning(int studentId, bool returning)
|
||||
{
|
||||
if (returning)
|
||||
_returningIds.Add(studentId);
|
||||
else
|
||||
{
|
||||
_returningIds.Remove(studentId);
|
||||
PruneOfficerSelections();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyPastedNames()
|
||||
{
|
||||
if (_students == null)
|
||||
return;
|
||||
|
||||
var lines = _pasteBox.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var result = YearTransitionPlanner.MatchPastedNames(_students, lines);
|
||||
foreach (var id in result.MatchedStudentIds)
|
||||
_returningIds.Add(id);
|
||||
_pasteUnmatched = result.UnmatchedNames.ToList();
|
||||
_pasteAmbiguous = result.AmbiguousNames.ToList();
|
||||
}
|
||||
|
||||
private int? GetOfficerSelection(OfficerRole role) =>
|
||||
_officerSelections.TryGetValue(role, out var id) ? id : null;
|
||||
|
||||
private void SetOfficerSelection(OfficerRole role, int? studentId)
|
||||
{
|
||||
_officerSelections[role] = studentId;
|
||||
}
|
||||
|
||||
private void PruneOfficerSelections()
|
||||
{
|
||||
foreach (var role in _officerRoles)
|
||||
{
|
||||
if (_officerSelections.TryGetValue(role, out var id) &&
|
||||
id.HasValue &&
|
||||
!_returningIds.Contains(id.Value))
|
||||
{
|
||||
_officerSelections[role] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private YearTransitionPlan BuildPlan()
|
||||
{
|
||||
return YearTransitionPlanner.Build(new YearTransitionRequest
|
||||
{
|
||||
Students = _students ?? [],
|
||||
ReturningStudentIds = _returningIds,
|
||||
OfficerAssignments = _officerSelections,
|
||||
GraduatingGrade = _graduatingGrade ?? 8,
|
||||
TargetCompetitionYear = _targetYear.Trim(),
|
||||
PastedNames = []
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ConfirmAndApply()
|
||||
{
|
||||
if (_isDisposed || !CanApply || !_wizardUnlocked)
|
||||
return;
|
||||
|
||||
if (!string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
Snackbar.Add("Type the target competition year exactly to confirm.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var plan = BuildPlan();
|
||||
var message =
|
||||
$"This will permanently delete {plan.RemovalCount} student(s), all teams, all event rankings, " +
|
||||
$"all meeting history{(_clearEventOccurrences ? ", and all event occurrences" : "")}. " +
|
||||
$"An automatic database backup will be created first and is the only undo. Continue?";
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Confirm year rollover",
|
||||
message,
|
||||
yesText: "Yes, apply rollover",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true || _isDisposed || !_wizardUnlocked)
|
||||
return;
|
||||
|
||||
_isApplying = true;
|
||||
try
|
||||
{
|
||||
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
_result = await YearRolloverService.ApplyAsync(new YearRolloverOptions
|
||||
{
|
||||
Plan = plan,
|
||||
ClearEventOccurrences = _clearEventOccurrences
|
||||
}, token);
|
||||
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add($"Rollover to {_result.CompetitionYear} complete", Severity.Success);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// disposed
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// connection lost
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Year rollover apply failed");
|
||||
if (!_isDisposed)
|
||||
Snackbar.Add($"Rollover failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
<AuthorizeView Roles="Administrator">
|
||||
<MudDivider Class="my-2"/>
|
||||
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
|
||||
<MudNavLink Href="/settings/new-year" Icon="@Icons.Material.Filled.EventRepeat">New Year Rollover (locked)</MudNavLink>
|
||||
<MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink>
|
||||
</AuthorizeView>
|
||||
</MudNavMenu>
|
||||
|
||||
@@ -37,6 +37,11 @@ public class ChapterSettings
|
||||
/// </summary>
|
||||
public string CompetitionYear { get; set; } = "2026";
|
||||
|
||||
/// <summary>
|
||||
/// National TSA yearly theme (e.g., "Unity Through Community")
|
||||
/// </summary>
|
||||
public string YearlyTheme { get; set; } = "Unity Through Community";
|
||||
|
||||
/// <summary>
|
||||
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
|
||||
/// </summary>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using BlazorSortableList;
|
||||
using Core.Entities;
|
||||
|
||||
namespace WebApp.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Class SharedSortableListGroup.
|
||||
/// Used for BlazorSortableList
|
||||
/// </summary>
|
||||
internal class SharedSortableListGroup : MultiSortableListGroup<EventDefinition>
|
||||
{
|
||||
public SharedSortableListGroup(Action refreshComponent)
|
||||
: base(refreshComponent)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,11 @@ builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
|
||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
|
||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
|
||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
|
||||
builder.Services.AddScoped<WebApp.Services.IChapterSettingsWriter, WebApp.Services.ChapterSettingsWriter>();
|
||||
builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Services.DatabaseBackupService>();
|
||||
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
|
||||
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
|
||||
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
|
||||
|
||||
builder.Services.Configure<StateScheduleHandoutOptions>(
|
||||
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Text.Json;
|
||||
using WebApp.Models;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists chapter settings to <c>Data/appsettings.json</c>.
|
||||
/// </summary>
|
||||
public class ChapterSettingsWriter : IChapterSettingsWriter
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ChapterSettingsWriter> _logger;
|
||||
|
||||
public ChapterSettingsWriter(
|
||||
IWebHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
ILogger<ChapterSettingsWriter> logger)
|
||||
{
|
||||
_environment = environment;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
var appSettingsPath = GetAppSettingsPath();
|
||||
var dataDir = Path.GetDirectoryName(appSettingsPath);
|
||||
if (dataDir != null && !Directory.Exists(dataDir))
|
||||
{
|
||||
Directory.CreateDirectory(dataDir);
|
||||
}
|
||||
|
||||
Dictionary<string, object?> root;
|
||||
if (File.Exists(appSettingsPath))
|
||||
{
|
||||
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
|
||||
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
|
||||
?? [];
|
||||
}
|
||||
else
|
||||
{
|
||||
root = [];
|
||||
}
|
||||
|
||||
root["ChapterSettings"] = settings;
|
||||
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
var json = JsonSerializer.Serialize(root, options);
|
||||
await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken);
|
||||
|
||||
_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();
|
||||
settings.CompetitionYear = competitionYear;
|
||||
await WriteAsync(settings, cancellationToken);
|
||||
}
|
||||
|
||||
private string GetAppSettingsPath() =>
|
||||
Path.Combine(_environment.ContentRootPath, "Data", "appsettings.json");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates SQLite database backups via VACUUM INTO.
|
||||
/// </summary>
|
||||
public class DatabaseBackupService : IDatabaseBackupService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<DatabaseBackupService> _logger;
|
||||
|
||||
public DatabaseBackupService(
|
||||
AppDbContext context,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<DatabaseBackupService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var backupsDir = Path.Combine(_environment.ContentRootPath, "Data", "backups");
|
||||
Directory.CreateDirectory(backupsDir);
|
||||
|
||||
var fileName = $"pre-rollover-{DateTime.Now:yyyyMMdd-HHmmss}.db";
|
||||
var backupPath = Path.Combine(backupsDir, fileName);
|
||||
|
||||
// Path is server-generated (never user input); escape single quotes for SQLite string literal.
|
||||
var escapedPath = backupPath.Replace("'", "''", StringComparison.Ordinal);
|
||||
#pragma warning disable EF1002 // Path is fully server-controlled; VACUUM INTO cannot use parameters.
|
||||
await _context.Database.ExecuteSqlRawAsync($"VACUUM INTO '{escapedPath}'", cancellationToken);
|
||||
#pragma warning restore EF1002
|
||||
|
||||
if (!File.Exists(backupPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Database backup was requested but the file was not created at '{backupPath}'.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Created pre-rollover database backup at {BackupPath}", backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using WebApp.Models;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists chapter settings to <c>Data/appsettings.json</c>.
|
||||
/// </summary>
|
||||
public interface IChapterSettingsWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes the given chapter settings, preserving other top-level sections in the file.
|
||||
/// </summary>
|
||||
Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates only the competition year while preserving other chapter settings from configuration.
|
||||
/// </summary>
|
||||
Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates SQLite database backups.
|
||||
/// </summary>
|
||||
public interface IDatabaseBackupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a pre-rollover backup of the application database using SQLite VACUUM INTO.
|
||||
/// </summary>
|
||||
/// <returns>The absolute path of the backup file.</returns>
|
||||
Task<string> CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Core.Models;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists parsed student event rankings.
|
||||
/// </summary>
|
||||
public interface IStudentEventRankingSaveService
|
||||
{
|
||||
/// <summary>
|
||||
/// Students in the parse result who already have rankings stored.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetStudentsWithExistingRankingsAsync(
|
||||
StudentEventRankingParseResult parseResult,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces rankings for students who have at least one accepted rank in the parse result.
|
||||
/// Other students are left unchanged.
|
||||
/// </summary>
|
||||
Task<StudentEventRankingSaveResult> SaveAsync(
|
||||
StudentEventRankingParseResult parseResult,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of saving imported event rankings.
|
||||
/// </summary>
|
||||
public class StudentEventRankingSaveResult
|
||||
{
|
||||
public int StudentsUpdated { get; set; }
|
||||
|
||||
public int RankingsSaved { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Core.YearTransition;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Options for applying a year rollover.
|
||||
/// </summary>
|
||||
public sealed class YearRolloverOptions
|
||||
{
|
||||
public required YearTransitionPlan Plan { get; init; }
|
||||
public bool ClearEventOccurrences { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a successful year rollover.
|
||||
/// </summary>
|
||||
public sealed class YearRolloverResult
|
||||
{
|
||||
public required string BackupPath { get; init; }
|
||||
public required int StudentsRemoved { get; init; }
|
||||
public required int StudentsPromoted { get; init; }
|
||||
public required int TeamsDeleted { get; init; }
|
||||
public required int RankingsDeleted { get; init; }
|
||||
public required int MeetingHistoriesDeleted { get; init; }
|
||||
public required int EventOccurrencesDeleted { get; init; }
|
||||
public required string CompetitionYear { get; init; }
|
||||
public required IReadOnlyList<string> OfficerSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a year-transition plan to the database.
|
||||
/// </summary>
|
||||
public interface IYearRolloverService
|
||||
{
|
||||
Task<YearRolloverResult> ApplyAsync(YearRolloverOptions options, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces event rankings for students present in a successful ranking import.
|
||||
/// </summary>
|
||||
public class StudentEventRankingSaveService : IStudentEventRankingSaveService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<StudentEventRankingSaveService> _logger;
|
||||
|
||||
public StudentEventRankingSaveService(AppDbContext context, ILogger<StudentEventRankingSaveService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> GetStudentsWithExistingRankingsAsync(
|
||||
StudentEventRankingParseResult parseResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var studentIds = GetStudentIds(parseResult);
|
||||
if (studentIds.Count == 0)
|
||||
return [];
|
||||
|
||||
return await _context.Students
|
||||
.AsNoTracking()
|
||||
.Where(s => studentIds.Contains(s.Id) && s.EventRankings.Any())
|
||||
.OrderBy(s => s.LastName)
|
||||
.ThenBy(s => s.FirstName)
|
||||
.Select(s => s.FirstName + " " + s.LastName)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StudentEventRankingSaveResult> SaveAsync(
|
||||
StudentEventRankingParseResult parseResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var studentIds = GetStudentIds(parseResult);
|
||||
if (studentIds.Count == 0)
|
||||
return new StudentEventRankingSaveResult();
|
||||
|
||||
var students = await _context.Students
|
||||
.Include(s => s.EventRankings)
|
||||
.Where(s => studentIds.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var eventIds = parseResult.Matches
|
||||
.Select(m => m.Ranking.EventDefinition.Id)
|
||||
.Where(id => id != 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var events = await _context.Events
|
||||
.Where(e => eventIds.Contains(e.Id))
|
||||
.ToDictionaryAsync(e => e.Id, cancellationToken);
|
||||
|
||||
var matchesByStudent = parseResult.Matches
|
||||
.Where(m => m.Ranking.Student.Id != 0)
|
||||
.GroupBy(m => m.Ranking.Student.Id)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var rankingsSaved = 0;
|
||||
foreach (var student in students)
|
||||
{
|
||||
if (!matchesByStudent.TryGetValue(student.Id, out var matches))
|
||||
continue;
|
||||
|
||||
student.EventRankings.Clear();
|
||||
foreach (var match in matches.OrderBy(m => m.Ranking.Rank))
|
||||
{
|
||||
if (!events.TryGetValue(match.Ranking.EventDefinition.Id, out var eventDefinition))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping ranking for student {StudentId}: event {EventId} was not found",
|
||||
student.Id,
|
||||
match.Ranking.EventDefinition.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
student.EventRankings.Add(new StudentEventRanking
|
||||
{
|
||||
Student = student,
|
||||
EventDefinition = eventDefinition,
|
||||
Rank = match.Ranking.Rank
|
||||
});
|
||||
rankingsSaved++;
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new StudentEventRankingSaveResult
|
||||
{
|
||||
StudentsUpdated = students.Count,
|
||||
RankingsSaved = rankingsSaved
|
||||
};
|
||||
}
|
||||
|
||||
private static List<int> GetStudentIds(StudentEventRankingParseResult parseResult) =>
|
||||
[.. parseResult.Matches
|
||||
.Select(m => m.Ranking.Student.Id)
|
||||
.Where(id => id != 0)
|
||||
.Distinct()];
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Core.YearTransition;
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a year-transition plan: backup, wipe season data, promote/remove students, update year.
|
||||
/// </summary>
|
||||
public class YearRolloverService : IYearRolloverService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IDatabaseBackupService _backupService;
|
||||
private readonly IChapterSettingsWriter _chapterSettingsWriter;
|
||||
private readonly ILogger<YearRolloverService> _logger;
|
||||
|
||||
public YearRolloverService(
|
||||
AppDbContext context,
|
||||
IDatabaseBackupService backupService,
|
||||
IChapterSettingsWriter chapterSettingsWriter,
|
||||
ILogger<YearRolloverService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_backupService = backupService;
|
||||
_chapterSettingsWriter = chapterSettingsWriter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<YearRolloverResult> ApplyAsync(
|
||||
YearRolloverOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(options.Plan);
|
||||
|
||||
var plan = options.Plan;
|
||||
var returningIds = plan.Promotions.Select(p => p.Student.Id).ToHashSet();
|
||||
var removalIds = plan.StudentsToRemove.Select(s => s.Id).ToHashSet();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting year rollover to {Year}: {Returning} returning, {Removing} removing, clearOccurrences={ClearOccurrences}",
|
||||
plan.TargetCompetitionYear,
|
||||
plan.ReturningCount,
|
||||
plan.RemovalCount,
|
||||
options.ClearEventOccurrences);
|
||||
|
||||
// VACUUM INTO cannot run inside a transaction — backup first and abort if it fails.
|
||||
var backupPath = await _backupService.CreatePreRolloverBackupAsync(cancellationToken);
|
||||
|
||||
int meetingHistoriesDeleted;
|
||||
int teamsDeleted;
|
||||
int rankingsDeleted;
|
||||
int eventOccurrencesDeleted;
|
||||
int studentsRemoved;
|
||||
int studentsPromoted;
|
||||
List<string> officerSummary;
|
||||
|
||||
await using (var transaction = await _context.Database.BeginTransactionAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete season data with ExecuteDelete / SQL so we never leave tracked Team
|
||||
// entities in the change tracker (Include+Remove then ExecuteDelete caused
|
||||
// optimistic concurrency failures when later deleting captain students).
|
||||
meetingHistoriesDeleted = await _context.TeamMeetingHistories.CountAsync(cancellationToken);
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"""DELETE FROM "TeamMeetingHistoryTeams" """, cancellationToken);
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"""DELETE FROM "TeamMeetingHistoryStudents" """, cancellationToken);
|
||||
await _context.TeamMeetingHistories.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
teamsDeleted = await _context.Teams.ExecuteDeleteAsync(cancellationToken);
|
||||
rankingsDeleted = await _context.StudentEventRanking.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
eventOccurrencesDeleted = 0;
|
||||
if (options.ClearEventOccurrences)
|
||||
{
|
||||
eventOccurrencesDeleted = await _context.EventOccurrences.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Drop any stale tracked entities from earlier queries in this request scope.
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var students = await _context.Students.ToListAsync(cancellationToken);
|
||||
var toRemove = students.Where(s => removalIds.Contains(s.Id)).ToList();
|
||||
var toPromote = students.Where(s => returningIds.Contains(s.Id)).ToList();
|
||||
|
||||
if (toRemove.Count != removalIds.Count)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Some students marked for removal were not found in the database. Aborting rollover.");
|
||||
}
|
||||
|
||||
if (toPromote.Count != returningIds.Count)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Some returning students were not found in the database. Aborting rollover.");
|
||||
}
|
||||
|
||||
// Extra students added while the wizard was open — refuse rather than leave them unprocessed.
|
||||
var plannedIds = returningIds.Union(removalIds).ToHashSet();
|
||||
var unexpected = students.Where(s => !plannedIds.Contains(s.Id)).ToList();
|
||||
if (unexpected.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", unexpected.Select(s => s.LastNameFirstName));
|
||||
throw new InvalidOperationException(
|
||||
$"Roster changed since the wizard loaded ({unexpected.Count} unexpected student(s): {names}). Reload the page and try again.");
|
||||
}
|
||||
|
||||
_context.Students.RemoveRange(toRemove);
|
||||
|
||||
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
|
||||
foreach (var student in toPromote)
|
||||
{
|
||||
var promotion = promotionById[student.Id];
|
||||
student.Grade = promotion.NewGrade;
|
||||
student.TsaYear = promotion.NewTsaYear;
|
||||
student.OfficerRole = promotion.NewOfficerRole;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
studentsRemoved = toRemove.Count;
|
||||
studentsPromoted = toPromote.Count;
|
||||
officerSummary = plan.OfficerChanges
|
||||
.Select(c => c.NewOfficer == null
|
||||
? $"{c.Role}: (vacant)"
|
||||
: $"{c.Role}: {c.NewOfficer.LastNameFirstName}")
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Year rollover failed after backup at {BackupPath}; rolling back database changes", backupPath);
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _chapterSettingsWriter.UpdateCompetitionYearAsync(
|
||||
plan.TargetCompetitionYear,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Year rollover DB changes committed but CompetitionYear file update failed. Backup={BackupPath}",
|
||||
backupPath);
|
||||
throw new InvalidOperationException(
|
||||
$"Database rollover succeeded (backup at '{backupPath}'), but updating CompetitionYear failed: {ex.Message}. Set the year in Chapter Settings, then restart.",
|
||||
ex);
|
||||
}
|
||||
|
||||
var result = new YearRolloverResult
|
||||
{
|
||||
BackupPath = backupPath,
|
||||
StudentsRemoved = studentsRemoved,
|
||||
StudentsPromoted = studentsPromoted,
|
||||
TeamsDeleted = teamsDeleted,
|
||||
RankingsDeleted = rankingsDeleted,
|
||||
MeetingHistoriesDeleted = meetingHistoriesDeleted,
|
||||
EventOccurrencesDeleted = eventOccurrencesDeleted,
|
||||
CompetitionYear = plan.TargetCompetitionYear,
|
||||
OfficerSummary = officerSummary
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Year rollover complete. Backup={BackupPath}, Removed={Removed}, Promoted={Promoted}, Teams={Teams}, Rankings={Rankings}, Histories={Histories}, Occurrences={Occurrences}, Officers={Officers}",
|
||||
result.BackupPath,
|
||||
result.StudentsRemoved,
|
||||
result.StudentsPromoted,
|
||||
result.TeamsDeleted,
|
||||
result.RankingsDeleted,
|
||||
result.MeetingHistoriesDeleted,
|
||||
result.EventOccurrencesDeleted,
|
||||
string.Join("; ", officerSummary));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="BlazorSortableList" Version="2.1.0" />
|
||||
<PackageReference Include="Heron.MudCalendar" Version="3.4.0" />
|
||||
<PackageReference Include="VisNetwork.Blazor" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"NationalId": "2227",
|
||||
"StateId": "12227",
|
||||
"RegionalId": "12227",
|
||||
"CompetitionYear": "2026"
|
||||
"CompetitionYear": "2026",
|
||||
"YearlyTheme": "Unity Through Community"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"StateId": "00000",
|
||||
"RegionalId": "00000",
|
||||
"CompetitionYear": "2026",
|
||||
"YearlyTheme": "Unity Through Community",
|
||||
"StateAbbrev": "ST",
|
||||
"StateScheduleHandout": {
|
||||
"StudentSpecialEventTypes": [
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
window.tsaLogin = {
|
||||
/**
|
||||
* Copies Blazor-bound credentials into the classic form fields and submits.
|
||||
* Avoids building JS via string eval (empty returnUrl previously produced ".value = ;").
|
||||
*/
|
||||
submitForm: function (email, password, rememberMe, returnUrl) {
|
||||
var emailInput = document.getElementById('emailInput');
|
||||
var passwordInput = document.getElementById('passwordInput');
|
||||
var rememberMeInput = document.getElementById('rememberMeInput');
|
||||
var returnUrlInput = document.getElementById('returnUrlInput');
|
||||
var form = document.getElementById('loginForm');
|
||||
|
||||
if (!emailInput || !passwordInput || !rememberMeInput || !returnUrlInput || !form) {
|
||||
console.error('tsaLogin.submitForm: login form elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
emailInput.value = email ?? '';
|
||||
passwordInput.value = password ?? '';
|
||||
rememberMeInput.value = rememberMe ? 'true' : 'false';
|
||||
returnUrlInput.value = returnUrl ?? '';
|
||||
form.submit();
|
||||
},
|
||||
|
||||
blurActiveElement: function () {
|
||||
if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -26,8 +26,9 @@ services:
|
||||
# Option 1: Volume-mounted secrets file (recommended for easy editing)
|
||||
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
|
||||
|
||||
# Database persistence
|
||||
- ./data:/app/data
|
||||
# Database persistence — must use capital D (/app/Data). Linux paths are
|
||||
# case-sensitive; ./data:/app/data will NOT persist app.db or rollover backups.
|
||||
- ./data:/app/Data
|
||||
|
||||
# HTTPS certificate (if needed)
|
||||
# - ./certs:/https:ro
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Import Student Event Rankings
|
||||
|
||||
**Created:** 2026-08-28
|
||||
**Last updated:** 2026-08-28
|
||||
**Description:** How to import student event preference rankings from a CSV.
|
||||
|
||||
## CSV format
|
||||
|
||||
```
|
||||
Student Name,1,2,3,4,5,6
|
||||
"Last, First",Video Game Design,Coding,Flight
|
||||
First Last,Video Game,Coding
|
||||
```
|
||||
|
||||
- `Student Name` is required. `Last, First` and `First Last` both work.
|
||||
- Rank columns are `1` through `10`. Empty cells are skipped. Extra columns are ignored.
|
||||
- Event cells may be the official name, the catalog short name, or a close miss. Fuzzy matching scores both.
|
||||
- Known nicknames that fuzzy cannot reach are also accepted, including `Solar Racer`, `Challenging Tech`, `Digital Photo`, `Forensics`, `Micro Controller`, `Med Tech`, `Innovations & Inventions`, `Systems Control Tech`, `Structural Eng`, `Drone Challenge`, `Robotics`, and `Audio Podcast`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Sign in and open **Student Event Ranks** (`/students/event-ranking`).
|
||||
2. Click **Import**.
|
||||
3. Choose the CSV and click **Parse**.
|
||||
4. Review matched rows (raw text, chosen event, score) and any unmatched or ambiguous issues.
|
||||
5. Click **Save to Database**. If any matched student already has rankings, confirm the replace dialog.
|
||||
|
||||
Save replaces rankings only for students who have at least one accepted rank in the file. Other students are unchanged.
|
||||
|
||||
## What is not imported
|
||||
|
||||
Unmatched students, unmatched events, and ambiguous event names are reported in the preview and are not saved. Fix the CSV text or add the student/event in the app, then parse again.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Year Rollover Runbook
|
||||
|
||||
**Created:** 2026-08-14
|
||||
**Last updated:** 2026-08-28
|
||||
**Description:** How to roll the chapter into a new competition year using the locked New Year wizard.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Confirm **School Level** is set correctly on [Chapter Settings](/settings/chapter) (`Middle School` or `High School`). That drives the graduating grade (8 or 12).
|
||||
2. Have the list of **returning students** and the **new officer slate** ready.
|
||||
3. Plan a short maintenance window: after apply you must **restart the app** so printouts pick up the new competition year.
|
||||
4. On Docker, confirm the data volume is `./data:/app/Data` (capital **D**). See `DEPLOYMENT.md` — otherwise the automatic backup may not land on the host.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Sign in as an **Administrator**.
|
||||
2. Open **New Year Rollover** at `/settings/new-year` (Admin nav: “New Year Rollover (locked)”).
|
||||
3. **Unlock the wizard** (required every session):
|
||||
- Check both acknowledgment boxes.
|
||||
- Type `ROLLOVER` and click **Unlock wizard**.
|
||||
- Refreshing or using **Lock again** re-locks it.
|
||||
4. **Step 1 — Year & grades**
|
||||
- Confirm the target competition year (defaults to current + 1).
|
||||
- Confirm the chapter type / graduating grade shown from Chapter Settings.
|
||||
5. **Step 2 — Returning roster**
|
||||
- Uncheck anyone who is not returning (grade at/above graduating is unchecked by default).
|
||||
- Optionally paste a name list (`Last, First` or `First Last`) and click **Apply pasted names**.
|
||||
6. **Step 3 — Officers**
|
||||
- Assign each office from returning students, or leave vacant.
|
||||
- Officers who are brand-new students can be set later on the student edit page.
|
||||
7. **Step 4 — Season reset**
|
||||
- Teams, event rankings, and meeting history are always cleared.
|
||||
- Leave **Clear all event occurrences** checked unless you have a reason to keep last year's calendar rows.
|
||||
8. **Step 5 — Preview & apply**
|
||||
- Review promotions, removals, officers, and warnings.
|
||||
- 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.
|
||||
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.
|
||||
|
||||
## What is deleted vs kept
|
||||
|
||||
| Cleared | Kept |
|
||||
|---------|------|
|
||||
| Non-returning students | Returning students (promoted) |
|
||||
| All teams | Event definitions (national catalog) |
|
||||
| All event rankings | Notes (including meeting notes by title) |
|
||||
| All meeting history attendance snapshots | Database backup under `Data/backups/` |
|
||||
| Event occurrences (when checked) | |
|
||||
|
||||
## Backup location
|
||||
|
||||
| Environment | Backup path |
|
||||
|-------------|-------------|
|
||||
| Local (`dotnet run`) | `WebApp/Data/backups/pre-rollover-*.db` |
|
||||
| Docker (with `./data:/app/Data`) | Host: `./data/backups/pre-rollover-*.db` (container: `/app/Data/backups/`) |
|
||||
|
||||
## Restore from backup (emergency)
|
||||
|
||||
1. Stop the application (or `docker-compose stop`).
|
||||
2. Replace `Data/app.db` (local) or `./data/app.db` (Docker host) with a copy of the `pre-rollover-*.db` file from the backups folder.
|
||||
3. If needed, restore `CompetitionYear` in `Data/appsettings.json` / `./data/appsettings.json` (or Chapter Settings after restart).
|
||||
4. Start the application.
|
||||
Reference in New Issue
Block a user