feat: add locked new-year rollover wizard for season transitions
Promote returning students, assign officers, and clear last season's data after an automatic SQLite backup, with Docker volume path docs fixed for /app/Data. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
## Managing Users
|
||||||
|
|
||||||
### Adding a New User
|
### Adding a New User
|
||||||
@@ -310,10 +335,11 @@ curl http://localhost:8080
|
|||||||
- [ ] Set file permissions to 600
|
- [ ] Set file permissions to 600
|
||||||
- [ ] Configured HTTPS/SSL certificates
|
- [ ] Configured HTTPS/SSL certificates
|
||||||
- [ ] Updated `ASPNETCORE_URLS` for production domain
|
- [ ] 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)
|
- [ ] Removed development endpoints (already done in code)
|
||||||
- [ ] Set up log monitoring
|
- [ ] 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 login with all user roles
|
||||||
- [ ] Tested rate limiting (5 failed attempts)
|
- [ ] Tested rate limiting (5 failed attempts)
|
||||||
- [ ] Documented admin password securely
|
- [ ] Documented admin password securely
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
@using WebApp.Authentication
|
@using WebApp.Authentication
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@using System.Text.Json
|
@using WebApp.Services
|
||||||
@using Core.Models
|
@using Core.Models
|
||||||
@inject IWebHostEnvironment Environment
|
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
|
@inject IChapterSettingsWriter ChapterSettingsWriter
|
||||||
|
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -125,19 +125,10 @@
|
|||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
// Load from IConfiguration
|
|
||||||
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
||||||
?? new Models.ChapterSettings();
|
?? new Models.ChapterSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetAppSettingsPath()
|
|
||||||
{
|
|
||||||
return Path.Combine(
|
|
||||||
Environment.ContentRootPath,
|
|
||||||
"Data",
|
|
||||||
"appsettings.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveSettings()
|
private async Task SaveSettings()
|
||||||
{
|
{
|
||||||
if (_settings == null) return;
|
if (_settings == null) return;
|
||||||
@@ -147,41 +138,7 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appSettingsPath = GetAppSettingsPath();
|
await ChapterSettingsWriter.WriteAsync(_settings);
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
||||||
_statusSeverity = Severity.Success;
|
_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">
|
<AuthorizeView Roles="Administrator">
|
||||||
<MudDivider Class="my-2"/>
|
<MudDivider Class="my-2"/>
|
||||||
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
|
<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>
|
<MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
|
|||||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
|
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
|
||||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
|
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
|
||||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
|
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.Configure<StateScheduleHandoutOptions>(
|
builder.Services.Configure<StateScheduleHandoutOptions>(
|
||||||
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
|
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,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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,9 @@ services:
|
|||||||
# Option 1: Volume-mounted secrets file (recommended for easy editing)
|
# Option 1: Volume-mounted secrets file (recommended for easy editing)
|
||||||
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
|
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
|
||||||
|
|
||||||
# Database persistence
|
# Database persistence — must use capital D (/app/Data). Linux paths are
|
||||||
- ./data:/app/data
|
# case-sensitive; ./data:/app/data will NOT persist app.db or rollover backups.
|
||||||
|
- ./data:/app/Data
|
||||||
|
|
||||||
# HTTPS certificate (if needed)
|
# HTTPS certificate (if needed)
|
||||||
# - ./certs:/https:ro
|
# - ./certs:/https:ro
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Year Rollover Runbook
|
||||||
|
|
||||||
|
**Created:** 2026-08-14
|
||||||
|
**Last updated:** 2026-08-14
|
||||||
|
**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 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