Compare commits
162
Commits
ea21406309
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432caa0fe8 | ||
|
|
8d7c6b103c | ||
|
|
5d2d019e87 | ||
|
|
ea1bb70740 | ||
|
|
4401e4a3ec | ||
|
|
a9036d5d04 | ||
|
|
4dcd9e5aab | ||
|
|
336bbb1dec | ||
|
|
675f04afec | ||
|
|
84eaf338a9 | ||
|
|
15d7edec8f | ||
|
|
46836fde2e | ||
|
|
d0ce71397b | ||
|
|
840a8edbf1 | ||
|
|
680f61241a | ||
|
|
083e81aa25 | ||
|
|
bba0f5f618 | ||
|
|
9ab241ed77 | ||
|
|
804e12ca22 | ||
|
|
cc6e0d71a7 | ||
|
|
a503655f97 | ||
|
|
6e2834f2be | ||
|
|
48861eb6a6 | ||
|
|
455be30821 | ||
|
|
ddb743847d | ||
|
|
649a0061cf | ||
|
|
6bc4c2e7f2 | ||
|
|
9ed9c93540 | ||
|
|
7679a458e0 | ||
|
|
84b31800ad | ||
|
|
b4c11cd0a6 | ||
|
|
e6eb35ee67 | ||
|
|
947d95893f | ||
|
|
8b0451c2ec | ||
|
|
5f2d7b5b31 | ||
|
|
5c4aaf91df | ||
|
|
5fdda08627 | ||
|
|
68311f4012 | ||
|
|
27f08b3718 | ||
|
|
34658e9697 | ||
|
|
8c4c21f204 | ||
|
|
52bf303537 | ||
|
|
c505bf42dd | ||
|
|
aba8ea3ae3 | ||
|
|
e2a5767b04 | ||
|
|
1601610226 | ||
|
|
f8c22690d4 | ||
|
|
6cd4418142 | ||
|
|
6acbc4e852 | ||
|
|
5a1b3fad2e | ||
|
|
8af86e22d9 | ||
|
|
e53403c934 | ||
|
|
5e6d61d400 | ||
|
|
37e82646b8 | ||
|
|
e77f34ff9f | ||
|
|
45edcf5e5f | ||
|
|
ecd6173a44 | ||
|
|
b7e812bb63 | ||
|
|
99880e78c7 | ||
|
|
440893c84d | ||
|
|
eb342cd6a6 | ||
|
|
c4e7edf3db | ||
|
|
cf2c0d8068 | ||
|
|
bcd0acb480 | ||
|
|
44fd38b7ac | ||
|
|
8183c0200d | ||
|
|
ea1a4a04ad | ||
|
|
2eae3f205c | ||
|
|
19e5ef0675 | ||
|
|
f916cfad6b | ||
|
|
7ddc55f672 | ||
|
|
f32ce649cd | ||
|
|
5fdd5fadba | ||
|
|
c937192496 | ||
|
|
2d3b29176f | ||
|
|
c73fdbfba4 | ||
|
|
db4f1ba6fc | ||
|
|
76f285b3af | ||
|
|
87db67f979 | ||
|
|
2aaefb2491 | ||
|
|
01056401e5 | ||
|
|
83522ac52c | ||
|
|
c6fb00c7f4 | ||
|
|
3bd076afb3 | ||
|
|
1d3167710d | ||
|
|
7266ab609b | ||
|
|
2c9aa1c223 | ||
|
|
065a83442c | ||
|
|
0358763601 | ||
|
|
06b2db0b4c | ||
|
|
8967d0f8a4 | ||
|
|
d0fd7469af | ||
|
|
f144f0f8f0 | ||
|
|
62b6ae06fd | ||
|
|
b3d7577c32 | ||
|
|
c9ef169989 | ||
|
|
c462ed4561 | ||
|
|
cd34be1f82 | ||
|
|
3a809f18a6 | ||
|
|
eaefdfaedd | ||
|
|
9668ec162d | ||
|
|
5c1e0b7444 | ||
|
|
d19326781a | ||
|
|
cb362d6754 | ||
|
|
872a1cae64 | ||
|
|
dbe2b259f6 | ||
|
|
58cae98357 | ||
|
|
4c05188867 | ||
|
|
057aa31bf8 | ||
|
|
ed9487a5ad | ||
|
|
75a64faff0 | ||
|
|
768b46da2d | ||
|
|
e17d15aaff | ||
|
|
f395dba043 | ||
|
|
f2389fa1c1 | ||
|
|
19a7164b8b | ||
|
|
77b5683804 | ||
|
|
059a16b958 | ||
|
|
023e6c289a | ||
|
|
1e36a5661b | ||
|
|
fa13039203 | ||
|
|
aedf168e8a | ||
|
|
1f5aba7f04 | ||
|
|
c77d2117cd | ||
|
|
73ad730b38 | ||
|
|
1c7e704ad3 | ||
|
|
215b9dccca | ||
|
|
b465235096 | ||
|
|
aeafdcee1a | ||
|
|
27dc995bb8 | ||
|
|
8289b7139a | ||
|
|
abd4840ec1 | ||
|
|
5ea3289d39 | ||
|
|
1c5017bf51 | ||
|
|
56dbd549fd | ||
|
|
826eac1372 | ||
|
|
2d5d075879 | ||
|
|
bd04483bed | ||
|
|
6fc1ec1192 | ||
|
|
a0313687da | ||
|
|
54875e970c | ||
|
|
1c38003027 | ||
|
|
8039e751d8 | ||
|
|
feaaf76f46 | ||
|
|
3461f94854 | ||
|
|
69dd517d73 | ||
|
|
bfaebfbb13 | ||
|
|
a2857d56e0 | ||
|
|
e5bf3692f6 | ||
|
|
382fffe1d4 | ||
|
|
46843fea0f | ||
|
|
688dfd6d0f | ||
|
|
d8f2a3cf52 | ||
|
|
1aaebe5dce | ||
|
|
72da28992f | ||
|
|
87be3e9c68 | ||
|
|
d188d4fbd1 | ||
|
|
307c6e103f | ||
|
|
0228abab01 | ||
|
|
b7fa7fcbe1 | ||
|
|
cf9949876d | ||
|
|
967aa567e8 |
+13
@@ -23,3 +23,16 @@ _ReSharper*/
|
|||||||
|
|
||||||
DataBackup/
|
DataBackup/
|
||||||
*.db
|
*.db
|
||||||
|
/.*/*
|
||||||
|
.*rules
|
||||||
|
|
||||||
|
# Production secrets and configuration
|
||||||
|
auth-secrets.json
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.override.yml
|
||||||
|
/WebApp/logs/*
|
||||||
|
/WebApp/DataProtectionKeys/*
|
||||||
|
|
||||||
|
# Runtime data directory
|
||||||
|
/WebApp/Data/*
|
||||||
|
|
||||||
|
|||||||
+220
-166
@@ -1,109 +1,159 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Google.OrTools.Sat;
|
using Google.OrTools.Sat;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using IntVar = Google.OrTools.Sat.IntVar;
|
|
||||||
|
|
||||||
namespace Core.Calculation
|
namespace Core.Calculation
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Solves the event assignment problem using constraint programming.
|
||||||
|
/// Assigns students to events based on rankings, capacity constraints, and team requirements.
|
||||||
|
/// </summary>
|
||||||
public class EventAssignment
|
public class EventAssignment
|
||||||
{
|
{
|
||||||
|
// Constants for magic numbers
|
||||||
|
private const double TEAM_DIVISION_MULTIPLIER = 1.25;
|
||||||
|
private const int MIN_REGIONAL_EVENTS = 1;
|
||||||
|
private const int MAX_REGIONAL_EVENTS = 2;
|
||||||
|
|
||||||
private readonly IList<EventDefinition> _events;
|
private readonly IList<EventDefinition> _events;
|
||||||
private readonly IList<Student> _students;
|
private readonly IList<Student> _students;
|
||||||
private readonly AssignmentParameters _parameters;
|
private readonly AssignmentParameters _parameters;
|
||||||
|
private readonly double _maxSolveTimeSeconds;
|
||||||
private readonly int[] _allEvents;
|
private readonly int[] _allEvents;
|
||||||
private readonly int[] _allStudents;
|
private readonly int[] _allStudents;
|
||||||
// how many students have picked each eventDefinition?
|
// how many students have picked each event?
|
||||||
private readonly int[] _eventPickCounts;
|
private readonly int[] _eventPickCounts;
|
||||||
private IList<AssignmentRequirement> _assignmentRequirements = new List<AssignmentRequirement>();
|
private IList<AssignmentRequirement> _assignmentRequirements = [];
|
||||||
private IList<EventDefinition> _droppedEvents = new List<EventDefinition>();
|
private IList<EventDefinition> _droppedEvents = [];
|
||||||
private IList<EventDefinition> _includedEvents = new List<EventDefinition>();
|
private IList<EventDefinition> _includedEvents = [];
|
||||||
private IList<EventDefinition> _twoTeams = new List<EventDefinition>();
|
private IList<EventDefinition> _twoTeams = [];
|
||||||
|
|
||||||
public EventAssignment(IList<EventDefinition> events, IList<Student> students, AssignmentParameters parameters)
|
/// <summary>
|
||||||
|
/// Creates a new event assignment optimizer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="events">The events to assign students to (must not be null or empty)</param>
|
||||||
|
/// <param name="students">The students to assign to events (must not be null or empty)</param>
|
||||||
|
/// <param name="parameters">Assignment parameters and constraints</param>
|
||||||
|
/// <param name="maxSolveTimeSeconds">Maximum solver time in seconds (default: 60.0)</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when events, students, or parameters is null</exception>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when collections are empty</exception>
|
||||||
|
public EventAssignment(IList<EventDefinition> events, IList<Student> students,
|
||||||
|
AssignmentParameters parameters, double maxSolveTimeSeconds = 60.0)
|
||||||
{
|
{
|
||||||
|
if (events == null)
|
||||||
|
throw new ArgumentNullException(nameof(events));
|
||||||
|
if (students == null)
|
||||||
|
throw new ArgumentNullException(nameof(students));
|
||||||
|
if (parameters == null)
|
||||||
|
throw new ArgumentNullException(nameof(parameters));
|
||||||
|
|
||||||
|
if (events.Count == 0)
|
||||||
|
throw new ArgumentException("Events collection cannot be empty", nameof(events));
|
||||||
|
if (students.Count == 0)
|
||||||
|
throw new ArgumentException("Students collection cannot be empty", nameof(students));
|
||||||
|
|
||||||
_events = events;
|
_events = events;
|
||||||
_students = students;
|
_students = students;
|
||||||
_parameters = parameters;
|
_parameters = parameters;
|
||||||
|
_maxSolveTimeSeconds = maxSolveTimeSeconds;
|
||||||
_allEvents = Enumerable.Range(0, _events.Count).ToArray();
|
_allEvents = Enumerable.Range(0, _events.Count).ToArray();
|
||||||
_allStudents = Enumerable.Range(0, _students.Count).ToArray();
|
_allStudents = Enumerable.Range(0, _students.Count).ToArray();
|
||||||
_eventPickCounts = new int[_allEvents.Length];
|
_eventPickCounts = new int[_allEvents.Length];
|
||||||
|
|
||||||
for (var i = 0; i < _events.Count; i++)
|
for (var i = 0; i < _events.Count; i++)
|
||||||
{
|
{
|
||||||
var e = _events[i];
|
var e = _events[i];
|
||||||
|
// Performance: Use Any() instead of Count() > 0
|
||||||
_eventPickCounts[i] = _students.Count(s => s.EventRankings.Count(er => er.EventDefinition == e) > 0);
|
_eventPickCounts[i] = _students.Count(s => s.EventRankings.Any(er => er.EventDefinition == e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a requirement to include or exclude a specific student-event pairing.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assignmentRequirement">The assignment requirement to add</param>
|
||||||
public void AddAssignmentRequirement(AssignmentRequirement assignmentRequirement)
|
public void AddAssignmentRequirement(AssignmentRequirement assignmentRequirement)
|
||||||
{
|
{
|
||||||
_assignmentRequirements.Add(assignmentRequirement);
|
_assignmentRequirements.Add(assignmentRequirement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks events to be excluded from the assignment solution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="events">Events to drop from consideration</param>
|
||||||
public void RemoveEvents(IList<EventDefinition> events)
|
public void RemoveEvents(IList<EventDefinition> events)
|
||||||
{
|
{
|
||||||
_droppedEvents = events;
|
_droppedEvents = events;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void IncludedEvents(IList<EventDefinition> events)
|
/// <summary>
|
||||||
|
/// Forces specific events to be included in the solution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="events">Events that must be included</param>
|
||||||
|
public void SetIncludedEvents(IList<EventDefinition> events)
|
||||||
{
|
{
|
||||||
_includedEvents = events;
|
_includedEvents = events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allows specific events to have two teams instead of one.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="events">Events that can have two teams</param>
|
||||||
public void AllowTwoTeams(IList<EventDefinition> events)
|
public void AllowTwoTeams(IList<EventDefinition> events)
|
||||||
{
|
{
|
||||||
_twoTeams = events;
|
_twoTeams = events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Solves the event assignment problem using constraint programming.
|
||||||
|
/// Assigns students to events based on rankings, capacity constraints, and requirements.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A solution containing team assignments and status</returns>
|
||||||
public async Task<EventAssignmentSolution> Solve()
|
public async Task<EventAssignmentSolution> Solve()
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Debug.WriteLine(_parameters);
|
Debug.WriteLine(_parameters);
|
||||||
|
|
||||||
// Model.
|
// Create constraint programming model
|
||||||
var model = new CpModel();
|
var model = new CpModel();
|
||||||
|
|
||||||
// Variables.
|
// Decision variables: x[e,s] = 1 if student s is assigned to event e
|
||||||
var x = new BoolVar[_allEvents.Length, _allStudents.Length];
|
var x = new BoolVar[_allEvents.Length, _allStudents.Length];
|
||||||
foreach (var e in _allEvents)
|
foreach (var e in _allEvents)
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
x[e, s] = model.NewBoolVar($"eventAssignments[{e},{s}]");
|
x[e, s] = model.NewBoolVar($"eventAssignments[{e},{s}]");
|
||||||
|
|
||||||
|
// Add all constraints
|
||||||
AddAssignmentRequirements(model, x);
|
AddAssignmentRequirements(model, x);
|
||||||
|
|
||||||
var assignmentThresholdsList = AddEventAssignmentThresholds(model, x);
|
var assignmentThresholdsList = AddEventAssignmentThresholds(model, x);
|
||||||
|
|
||||||
LimitStudentAssignment(model, x);
|
LimitStudentAssignment(model, x);
|
||||||
|
|
||||||
// set the range for level of effort
|
|
||||||
SetLevelOfEffort(model, x);
|
SetLevelOfEffort(model, x);
|
||||||
|
|
||||||
// each student should be assigned at least one on site activity
|
|
||||||
if (_parameters.RequireOnSite)
|
if (_parameters.RequireOnSite)
|
||||||
RequireOnSiteActivity(model, x);
|
RequireOnSiteActivity(model, x);
|
||||||
|
|
||||||
// students should have at least one regional event
|
|
||||||
if (_parameters.RequireRegional)
|
if (_parameters.RequireRegional)
|
||||||
RequireRegionalEvent(model, x);
|
RequireRegionalEvent(model, x);
|
||||||
|
|
||||||
// students should have at maximum one individual event
|
|
||||||
AtMostOneIndividualEvent(model, x);
|
AtMostOneIndividualEvent(model, x);
|
||||||
|
|
||||||
//EventHasInterestedStudent(model, x);
|
|
||||||
|
|
||||||
IndividualEventsMustBeRanked(model, x);
|
IndividualEventsMustBeRanked(model, x);
|
||||||
|
|
||||||
OptimizeStudentEventRankings(model, x);
|
OptimizeStudentEventRankings(model, x);
|
||||||
|
|
||||||
|
// Configure and run solver
|
||||||
Debug.WriteLine("Starting optimization");
|
Debug.WriteLine("Starting optimization");
|
||||||
var solver = new CpSolver();
|
var solver = new CpSolver();
|
||||||
|
solver.StringParameters = $"max_time_in_seconds:{_maxSolveTimeSeconds}";
|
||||||
var cpSolverStatus = await Task.Run(() => solver.Solve(model));
|
var cpSolverStatus = await Task.Run(() => solver.Solve(model));
|
||||||
|
|
||||||
// print solver status
|
|
||||||
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
||||||
|
|
||||||
|
if (cpSolverStatus == CpSolverStatus.Infeasible)
|
||||||
|
{
|
||||||
|
Debug.WriteLine("Problem is infeasible - constraints cannot be satisfied");
|
||||||
|
}
|
||||||
|
|
||||||
var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus);
|
var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus);
|
||||||
|
|
||||||
var eventAssignmentSolution =
|
var eventAssignmentSolution =
|
||||||
@@ -116,8 +166,16 @@ namespace Core.Calculation
|
|||||||
|
|
||||||
return eventAssignmentSolution;
|
return eventAssignmentSolution;
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Error solving event assignment: {ex}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Take the solution and map it back to the entities
|
/// <summary>
|
||||||
|
/// Extracts the solution from the solver results.
|
||||||
|
/// </summary>
|
||||||
private List<Team> GetEventAssignments(BoolVar[,] x, CpSolver solver, CpSolverStatus cpSolverStatus)
|
private List<Team> GetEventAssignments(BoolVar[,] x, CpSolver solver, CpSolverStatus cpSolverStatus)
|
||||||
{
|
{
|
||||||
if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible))
|
if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible))
|
||||||
@@ -140,7 +198,9 @@ namespace Core.Calculation
|
|||||||
return eventAssignments.ToList();
|
return eventAssignments.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximize student event rankings
|
/// <summary>
|
||||||
|
/// Objective function: Maximize student event rankings (students get higher-ranked events).
|
||||||
|
/// </summary>
|
||||||
private void OptimizeStudentEventRankings(CpModel model, BoolVar[,] x)
|
private void OptimizeStudentEventRankings(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
var maximizePicks = LinearExpr.NewBuilder();
|
var maximizePicks = LinearExpr.NewBuilder();
|
||||||
@@ -157,58 +217,64 @@ namespace Core.Calculation
|
|||||||
model.Maximize(maximizePicks);
|
model.Maximize(maximizePicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constraint: Each student must have 1-2 regional events.
|
||||||
|
/// </summary>
|
||||||
private void RequireRegionalEvent(CpModel model, BoolVar[,] x)
|
private void RequireRegionalEvent(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
foreach (var s in _allStudents)
|
AddStudentConstraint(model, x,
|
||||||
{
|
evt => evt.RegionalEvent,
|
||||||
var regionalEvent = new List<ILiteral>();
|
(m, list) => m.AddLinearConstraint(LinearExpr.Sum(list), MIN_REGIONAL_EVENTS, MAX_REGIONAL_EVENTS));
|
||||||
foreach (var e in _allEvents)
|
|
||||||
{
|
|
||||||
if (_events[e].RegionalEvent)
|
|
||||||
regionalEvent.Add(x[e, s]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// between 1 and 2 regional events
|
|
||||||
model.AddLinearConstraint(LinearExpr.Sum(regionalEvent), 1, 2);
|
|
||||||
regionalEvent.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constraint: Each student can have at most one individual event.
|
||||||
|
/// </summary>
|
||||||
private void AtMostOneIndividualEvent(CpModel model, BoolVar[,] x)
|
private void AtMostOneIndividualEvent(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
foreach (var s in _allStudents)
|
AddStudentConstraint(model, x,
|
||||||
{
|
evt => evt.EventFormat == EventFormat.Individual,
|
||||||
var individualEvent = new List<ILiteral>();
|
(m, list) => m.AddAtMostOne(list));
|
||||||
foreach (var e in _allEvents)
|
|
||||||
{
|
|
||||||
if (_events[e].EventFormat == EventFormat.Individual)
|
|
||||||
individualEvent.Add(x[e, s]);
|
|
||||||
}
|
|
||||||
|
|
||||||
model.AddAtMostOne(individualEvent);
|
|
||||||
individualEvent.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constraint: Each student must have at least one on-site activity.
|
||||||
|
/// </summary>
|
||||||
private void RequireOnSiteActivity(CpModel model, BoolVar[,] x)
|
private void RequireOnSiteActivity(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
|
AddStudentConstraint(model, x,
|
||||||
|
evt => evt.OnSiteActivity,
|
||||||
|
(m, list) => m.AddAtLeastOne(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to add constraints for all students based on event filters.
|
||||||
|
/// Reduces code duplication and improves performance by reusing the list buffer.
|
||||||
|
/// </summary>
|
||||||
|
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
||||||
|
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
||||||
|
{
|
||||||
|
List<ILiteral> buffer = [];
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
var onSiteActivity = new List<ILiteral>();
|
buffer.Clear();
|
||||||
foreach (var e in _allEvents)
|
foreach (var e in _allEvents)
|
||||||
{
|
{
|
||||||
if (_events[e].OnSiteActivity)
|
if (eventFilter(_events[e]))
|
||||||
onSiteActivity.Add(x[e, s]);
|
buffer.Add(x[e, s]);
|
||||||
}
|
}
|
||||||
|
if (buffer.Count > 0)
|
||||||
//if (_parameters.RequireOnSite)
|
constraintAction(model, buffer);
|
||||||
model.AddAtLeastOne(onSiteActivity);
|
|
||||||
onSiteActivity.Clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constraint: Individual events can only be assigned if the student ranked them.
|
||||||
|
/// </summary>
|
||||||
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
|
List<IntVar> prohibitVar = [];
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
var student = _students[s];
|
var student = _students[s];
|
||||||
@@ -216,15 +282,20 @@ namespace Core.Calculation
|
|||||||
{
|
{
|
||||||
var evt = _events[e];
|
var evt = _events[e];
|
||||||
|
|
||||||
var prohibitVar = new List<IntVar> { x[e, s] };
|
|
||||||
if (evt.EventFormat == EventFormat.Individual
|
if (evt.EventFormat == EventFormat.Individual
|
||||||
&& student.EventRankings.Find(er => er.EventDefinition == evt) == null)
|
&& student.EventRankings.Find(er => er.EventDefinition == evt) == null)
|
||||||
model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0);
|
{
|
||||||
prohibitVar.Clear();
|
prohibitVar.Clear();
|
||||||
|
prohibitVar.Add(x[e, s]);
|
||||||
|
model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constraint: Each student's total level of effort must be within bounds.
|
||||||
|
/// </summary>
|
||||||
private void SetLevelOfEffort(CpModel model, BoolVar[,] x)
|
private void SetLevelOfEffort(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
long[] eventEffortCoefficients = _events.Select(
|
long[] eventEffortCoefficients = _events.Select(
|
||||||
@@ -240,12 +311,7 @@ namespace Core.Calculation
|
|||||||
effortVar[e] = x[e, s];
|
effortVar[e] = x[e, s];
|
||||||
}
|
}
|
||||||
var student = _students[s];
|
var student = _students[s];
|
||||||
var experienceOffset = 0;
|
var experienceOffset = student.TsaYear == 1 ? 1 : 0;
|
||||||
switch (student.TsaYear)
|
|
||||||
{
|
|
||||||
case 1: experienceOffset = 1; break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ub = _parameters.EffortUpperBound - experienceOffset;
|
var ub = _parameters.EffortUpperBound - experienceOffset;
|
||||||
var lb = _parameters.EffortLowerBound;
|
var lb = _parameters.EffortLowerBound;
|
||||||
@@ -257,12 +323,16 @@ namespace Core.Calculation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit the number of events a student is assigned
|
/// <summary>
|
||||||
|
/// Constraint: Limit the number of events a student is assigned.
|
||||||
|
/// </summary>
|
||||||
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
|
List<IntVar> studentCapacity = [];
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
var studentCapacity = new List<IntVar>();
|
studentCapacity.Clear();
|
||||||
foreach (var e in _allEvents)
|
foreach (var e in _allEvents)
|
||||||
{
|
{
|
||||||
studentCapacity.Add(x[e, s]);
|
studentCapacity.Add(x[e, s]);
|
||||||
@@ -270,72 +340,69 @@ namespace Core.Calculation
|
|||||||
|
|
||||||
model.AddLinearConstraint(
|
model.AddLinearConstraint(
|
||||||
LinearExpr.Sum(studentCapacity), _parameters.EventsLowerBound, _parameters.EventsUpperBound);
|
LinearExpr.Sum(studentCapacity), _parameters.EventsLowerBound, _parameters.EventsUpperBound);
|
||||||
studentCapacity.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EventHasInterestedStudent(CpModel model, BoolVar[,] x)
|
|
||||||
{
|
|
||||||
foreach (var e in _allEvents)
|
|
||||||
{
|
|
||||||
var evt = _events[e];
|
|
||||||
var studentInterest = new List<IntVar>();
|
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
|
||||||
{
|
|
||||||
var student = _students[s];
|
|
||||||
if (student.EventRankings.Find(er => er.EventDefinition == evt) != null)
|
|
||||||
studentInterest.Add(x[e, s]);
|
|
||||||
}
|
|
||||||
model.AddLinearConstraint(
|
|
||||||
LinearExpr.Sum(studentInterest), 1, 10);
|
|
||||||
studentInterest.Clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds event capacity constraints and calculates assignment thresholds.
|
||||||
|
/// </summary>
|
||||||
private List<EventAssignmentThresholds> AddEventAssignmentThresholds(CpModel model, BoolVar[,] x)
|
private List<EventAssignmentThresholds> AddEventAssignmentThresholds(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
var assignmentThresholdsList = new List<EventAssignmentThresholds>();
|
var assignmentThresholdsList = new List<EventAssignmentThresholds>();
|
||||||
// Limit the capacity of each event
|
|
||||||
foreach (var e in _allEvents)
|
foreach (var e in _allEvents)
|
||||||
{
|
{
|
||||||
var evt = _events[e];
|
var evt = _events[e];
|
||||||
var eventPickCounts = _eventPickCounts[e];
|
var teamCount = CalculateTeamCount(evt, e);
|
||||||
|
var (lb, ub) = CalculateEventBounds(evt, teamCount);
|
||||||
|
|
||||||
var evtMinTeamSize = evt.MinTeamSize;
|
AddEventConstraint(model, x, e, lb, ub);
|
||||||
var evtMaxTeamSize = evt.MaxTeamSize;
|
assignmentThresholdsList.Add(CreateThreshold(evt, teamCount, e));
|
||||||
|
|
||||||
|
Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignmentThresholdsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates how many teams should be formed for an event.
|
||||||
|
/// </summary>
|
||||||
|
private int CalculateTeamCount(EventDefinition evt, int eventIndex)
|
||||||
|
{
|
||||||
|
var eventPickCounts = _eventPickCounts[eventIndex];
|
||||||
|
var teamDivs = eventPickCounts / (evt.MinTeamSize * TEAM_DIVISION_MULTIPLIER);
|
||||||
|
|
||||||
var teamDivs = eventPickCounts / (evtMinTeamSize * 1.25);
|
|
||||||
if (_includedEvents.Contains(evt))
|
if (_includedEvents.Contains(evt))
|
||||||
teamDivs = 1;
|
return 1;
|
||||||
|
|
||||||
//var teamsCount = (int)Math.Ceiling(teamDivs);
|
|
||||||
var teamCount = (int)Math.Round(teamDivs);
|
var teamCount = (int)Math.Round(teamDivs);
|
||||||
if (teamCount > evt.ChapterEligibilityCountState)
|
if (teamCount > evt.ChapterEligibilityCountState)
|
||||||
teamCount = evt.ChapterEligibilityCountState;
|
teamCount = evt.ChapterEligibilityCountState;
|
||||||
|
|
||||||
// limit to one team for group events
|
// Limit to one team for group events
|
||||||
if (_parameters.LimitTeamsToOne
|
if (_parameters.LimitTeamsToOne
|
||||||
&& evt.EventFormat is EventFormat.Team
|
&& evt.EventFormat is EventFormat.Team
|
||||||
&& teamCount > 1
|
&& teamCount > 1
|
||||||
&& !_twoTeams.Contains(evt)
|
&& !_twoTeams.Contains(evt))
|
||||||
)
|
|
||||||
teamCount = 1;
|
teamCount = 1;
|
||||||
|
|
||||||
if (_twoTeams.Contains(evt))
|
if (_twoTeams.Contains(evt))
|
||||||
teamCount = 2;
|
teamCount = 2;
|
||||||
|
|
||||||
if (evt.Name == "Tech Bowl")
|
if (_droppedEvents.Contains(evt))
|
||||||
teamCount = 1;
|
teamCount = 0;
|
||||||
|
|
||||||
var eventCapacity = new List<IntVar>();
|
return teamCount;
|
||||||
foreach (var s in _allStudents)
|
|
||||||
{
|
|
||||||
eventCapacity.Add(x[e, s]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_droppedEvents != null && _droppedEvents.Contains(evt))
|
/// <summary>
|
||||||
teamCount = 0;
|
/// Calculates the lower and upper bounds for event capacity.
|
||||||
|
/// </summary>
|
||||||
|
private (int lb, int ub) CalculateEventBounds(EventDefinition evt, int teamCount)
|
||||||
|
{
|
||||||
|
var evtMinTeamSize = evt.MinTeamSize;
|
||||||
|
var evtMaxTeamSize = evt.MaxTeamSize;
|
||||||
|
|
||||||
if (evt.EventFormat == EventFormat.Individual)
|
if (evt.EventFormat == EventFormat.Individual)
|
||||||
evtMinTeamSize = 0;
|
evtMinTeamSize = 0;
|
||||||
@@ -343,89 +410,76 @@ namespace Core.Calculation
|
|||||||
var lb = evtMinTeamSize * teamCount;
|
var lb = evtMinTeamSize * teamCount;
|
||||||
var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit * teamCount);
|
var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit * teamCount);
|
||||||
|
|
||||||
assignmentThresholdsList.Add(
|
return (lb, ub);
|
||||||
new EventAssignmentThresholds
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds capacity constraint for a specific event.
|
||||||
|
/// </summary>
|
||||||
|
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
||||||
|
{
|
||||||
|
List<IntVar> eventCapacity = [];
|
||||||
|
foreach (var s in _allStudents)
|
||||||
|
{
|
||||||
|
eventCapacity.Add(x[eventIndex, s]);
|
||||||
|
}
|
||||||
|
|
||||||
|
model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub);
|
||||||
|
model.Minimize(LinearExpr.Sum(eventCapacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a threshold object for tracking event assignment metadata.
|
||||||
|
/// </summary>
|
||||||
|
private EventAssignmentThresholds CreateThreshold(EventDefinition evt, int teamCount, int eventIndex)
|
||||||
|
{
|
||||||
|
return new EventAssignmentThresholds
|
||||||
{
|
{
|
||||||
Event = evt,
|
Event = evt,
|
||||||
TeamCount = teamCount,
|
TeamCount = teamCount,
|
||||||
LowerBound = evtMinTeamSize,
|
LowerBound = evt.MinTeamSize,
|
||||||
UpperBound = evtMaxTeamSize,
|
UpperBound = evt.MaxTeamSize,
|
||||||
StudentRankingCount = eventPickCounts
|
StudentRankingCount = _eventPickCounts[eventIndex]
|
||||||
});
|
};
|
||||||
|
|
||||||
|
|
||||||
model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub);
|
|
||||||
|
|
||||||
Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}");
|
|
||||||
|
|
||||||
model.Minimize(LinearExpr.Sum(eventCapacity));
|
|
||||||
eventCapacity.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return assignmentThresholdsList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds user-specified assignment requirements (include/exclude student-event pairs).
|
||||||
|
/// </summary>
|
||||||
private void AddAssignmentRequirements(CpModel model, BoolVar[,] x)
|
private void AddAssignmentRequirements(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
foreach (var includedAssignment in _assignmentRequirements.Where(e => e.Requirement == Requirement.Include))
|
foreach (var includedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Include))
|
||||||
{
|
{
|
||||||
var e = _events.IndexOf(includedAssignment.EventDefinition);
|
var e = _events.IndexOf(includedAssignment.EventDefinition);
|
||||||
var s = _students.IndexOf(includedAssignment.Student);
|
var s = _students.IndexOf(includedAssignment.Student);
|
||||||
model.AddAssumption(x[e, s]);
|
model.AddAssumption(x[e, s]);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var excludedAssignment in _assignmentRequirements.Where(e => e.Requirement == Requirement.Exclude))
|
List<IntVar> prohibitVar = [];
|
||||||
|
foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude))
|
||||||
{
|
{
|
||||||
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
||||||
var s = _students.IndexOf(excludedAssignment.Student);
|
var s = _students.IndexOf(excludedAssignment.Student);
|
||||||
|
|
||||||
var prohibitVar = new List<IntVar> { x[e, s] };
|
|
||||||
|
|
||||||
model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0);
|
|
||||||
prohibitVar.Clear();
|
prohibitVar.Clear();
|
||||||
|
prohibitVar.Add(x[e, s]);
|
||||||
|
model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates coefficients for optimizing student preferences.
|
||||||
|
/// Higher-ranked events get higher coefficients.
|
||||||
|
/// </summary>
|
||||||
private static long[] GetEventPickCoefficients(IList<EventDefinition> events, List<StudentEventRanking> eventRankings)
|
private static long[] GetEventPickCoefficients(IList<EventDefinition> events, List<StudentEventRanking> eventRankings)
|
||||||
{
|
{
|
||||||
return
|
return events.Select(e =>
|
||||||
events.Select(e =>
|
|
||||||
{
|
{
|
||||||
var eventRank = eventRankings.FirstOrDefault(er => er.EventDefinition == e);
|
var eventRank = eventRankings.FirstOrDefault(er => er.EventDefinition == e);
|
||||||
return
|
return eventRank == null
|
||||||
eventRank == null
|
|
||||||
? 0L
|
? 0L
|
||||||
// TODO: MaxRank can be calculated
|
: StudentEventRanking.MaxRank - eventRank.Rank; // Inverse ranking
|
||||||
: StudentEventRanking.MaxRank - eventRank.Rank; // inverse
|
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SolutionPrinter : CpSolverSolutionCallback
|
|
||||||
{
|
|
||||||
private readonly IList<EventDefinition> _events;
|
|
||||||
private readonly IList<Student> _students;
|
|
||||||
private readonly BoolVar[,] _eventAssignment;
|
|
||||||
|
|
||||||
public SolutionPrinter(IList<EventDefinition> events, IList<Student> students, BoolVar[,] eventAssignment)
|
|
||||||
{
|
|
||||||
_events = events;
|
|
||||||
_students = students;
|
|
||||||
_eventAssignment = eventAssignment;
|
|
||||||
}
|
|
||||||
public override void OnSolutionCallback()
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Solution ");
|
|
||||||
foreach (var evt in Enumerable.Range(0, _events.Count))
|
|
||||||
{
|
|
||||||
foreach (var student in Enumerable.Range(0, _students.Count))
|
|
||||||
{
|
|
||||||
if (Value(_eventAssignment[evt, student]) == 1L)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ namespace Core.Calculation;
|
|||||||
|
|
||||||
public class EventAssignmentThresholds
|
public class EventAssignmentThresholds
|
||||||
{
|
{
|
||||||
public EventDefinition Event { get; set; }
|
public EventDefinition Event { get; set; } = null!;
|
||||||
public int TeamCount { get; set; }
|
public int TeamCount { get; set; }
|
||||||
public int LowerBound { get; set; }
|
public int LowerBound { get; set; }
|
||||||
public int UpperBound { get; set; }
|
public int UpperBound { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for calculating overlaps in team scheduling solutions.
|
||||||
|
/// </summary>
|
||||||
|
public static class OverlapCalculationHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates teams with excluded students and absent students filtered out for overlap calculation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">The teams to filter</param>
|
||||||
|
/// <param name="timeSlotIndex">The time slot index for exclusion lookups</param>
|
||||||
|
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
|
||||||
|
/// <param name="absentStudentIds">Set of absent student IDs to exclude from overlap calculations</param>
|
||||||
|
/// <returns>Teams with excluded and absent students removed</returns>
|
||||||
|
public static Team[] GetTeamsWithoutExcludedStudents(
|
||||||
|
Team[] teams,
|
||||||
|
int timeSlotIndex,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
|
||||||
|
HashSet<int> absentStudentIds)
|
||||||
|
{
|
||||||
|
return teams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find excluded students for this team in this time slot
|
||||||
|
// Also exclude absent students from overlap calculations
|
||||||
|
var includedStudents = team.Students
|
||||||
|
.Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, s.Id, excludedStudents) &&
|
||||||
|
!absentStudentIds.Contains(s.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// If no students are excluded, return original team
|
||||||
|
if (includedStudents.Count == team.Students.Count)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create a temporary team with excluded and absent students removed
|
||||||
|
return new Team
|
||||||
|
{
|
||||||
|
Id = team.Id,
|
||||||
|
Event = team.Event,
|
||||||
|
Students = includedStudents,
|
||||||
|
Captain = team.Captain,
|
||||||
|
Identifier = team.Identifier
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStudentExcluded(
|
||||||
|
int teamId,
|
||||||
|
int timeSlotIndex,
|
||||||
|
int studentId,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
var key = (teamId, timeSlotIndex, studentId);
|
||||||
|
return excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
public class TeamIdComparer : IEqualityComparer<Team>
|
||||||
|
{
|
||||||
|
public bool Equals(Team? x, Team? y)
|
||||||
|
{
|
||||||
|
return x != null && y != null && x.Id == y.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetHashCode(Team obj)
|
||||||
|
{
|
||||||
|
return obj.Id.GetHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single time slot in the team meeting schedule.
|
||||||
|
/// </summary>
|
||||||
|
public class TeamScheduleTimeSlot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of this time slot.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the teams scheduled in this time slot.
|
||||||
|
/// </summary>
|
||||||
|
public Team[] Teams = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the students who are not scheduled in any team during this time slot.
|
||||||
|
/// </summary>
|
||||||
|
public Student[] UnscheduledStudents = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the students who have overlapping team meetings in this time slot.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<(Student student, IEnumerable<Team> teams)> StudentOverlaps = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a student has overlapping team meetings in this time slot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student to check</param>
|
||||||
|
/// <returns>True if the student has overlaps, otherwise false</returns>
|
||||||
|
public bool StudentHasOverlaps(Student student)
|
||||||
|
{
|
||||||
|
return StudentOverlaps.Any(o => o.student.Equals(student));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,68 +3,111 @@ using Core.Entities;
|
|||||||
using Google.OrTools.Sat;
|
using Google.OrTools.Sat;
|
||||||
|
|
||||||
namespace Core.Calculation;
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Solves the team meeting scheduling problem using constraint programming.
|
||||||
|
/// Assigns teams to time slots while minimizing student schedule conflicts.
|
||||||
|
/// </summary>
|
||||||
public class TeamScheduler
|
public class TeamScheduler
|
||||||
{
|
{
|
||||||
private readonly IList<Student> _studentObjects;
|
private readonly IList<Student> _studentObjects;
|
||||||
private readonly IList<Team> _teamObjects;
|
private readonly IList<Team> _teamObjects;
|
||||||
|
private readonly double _maxSolveTimeSeconds;
|
||||||
|
|
||||||
private readonly int[] _students;
|
private readonly int[] _students;
|
||||||
private readonly int[] _teams;
|
private readonly int[] _teams;
|
||||||
private readonly int[] _timeSlots;
|
private readonly int[] _timeSlots;
|
||||||
|
|
||||||
private readonly List<Tuple<int,int>> _scheduleSeparateTeams = [];
|
private readonly List<(int team1, int team2)> _scheduleSeparateTeams = [];
|
||||||
|
|
||||||
public TeamScheduler(IEnumerable<Team> teams, int numTimeSlots)
|
/// <summary>
|
||||||
|
/// Creates a new team scheduler instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">The teams to schedule (must not be null or empty)</param>
|
||||||
|
/// <param name="numTimeSlots">The number of available time slots (must be positive)</param>
|
||||||
|
/// <param name="allStudents">All students participating in teams (must not be null or empty)</param>
|
||||||
|
/// <param name="maxSolveTimeSeconds">Maximum solver time in seconds (default: 10.0)</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when teams or allStudents is null</exception>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when collections are empty or numTimeSlots is invalid</exception>
|
||||||
|
public TeamScheduler(IEnumerable<Team> teams, int numTimeSlots, IEnumerable<Student> allStudents, double maxSolveTimeSeconds = 10.0)
|
||||||
{
|
{
|
||||||
|
if (teams == null)
|
||||||
|
throw new ArgumentNullException(nameof(teams));
|
||||||
|
if (allStudents == null)
|
||||||
|
throw new ArgumentNullException(nameof(allStudents));
|
||||||
|
if (numTimeSlots <= 0)
|
||||||
|
throw new ArgumentException("Number of time slots must be positive", nameof(numTimeSlots));
|
||||||
|
|
||||||
_teamObjects = teams.ToArray();
|
_teamObjects = teams.ToArray();
|
||||||
_studentObjects = teams.SelectMany(t => t.Students).Distinct().ToList();
|
_studentObjects = allStudents.ToList();
|
||||||
|
_maxSolveTimeSeconds = maxSolveTimeSeconds;
|
||||||
|
|
||||||
|
if (_teamObjects.Count == 0)
|
||||||
|
throw new ArgumentException("Teams collection cannot be empty", nameof(teams));
|
||||||
|
if (_studentObjects.Count == 0)
|
||||||
|
throw new ArgumentException("Students collection cannot be empty", nameof(allStudents));
|
||||||
|
|
||||||
_students = Enumerable.Range(0, _studentObjects.Count).ToArray();
|
_students = Enumerable.Range(0, _studentObjects.Count).ToArray();
|
||||||
_teams = Enumerable.Range(0, _teamObjects.Count).ToArray();
|
_teams = Enumerable.Range(0, _teamObjects.Count).ToArray();
|
||||||
_timeSlots = Enumerable.Range(0, numTimeSlots).ToArray();
|
_timeSlots = Enumerable.Range(0, numTimeSlots).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a constraint requiring two teams to be scheduled in different time slots.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="team1">First team</param>
|
||||||
|
/// <param name="team2">Second team</param>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when either team is not found in the scheduler</exception>
|
||||||
public void ScheduleSeparate(Team team1, Team team2)
|
public void ScheduleSeparate(Team team1, Team team2)
|
||||||
{
|
{
|
||||||
var one = _teamObjects.IndexOf(team1);
|
var one = _teamObjects.IndexOf(team1);
|
||||||
var two = _teamObjects.IndexOf(team2);
|
var two = _teamObjects.IndexOf(team2);
|
||||||
_scheduleSeparateTeams.Add(Tuple.Create(one,two));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TeamScheduler CreateInstance(IEnumerable<Team> teams, int numTimeSlots)
|
if (one == -1)
|
||||||
{
|
throw new ArgumentException($"Team '{team1}' not found in scheduler", nameof(team1));
|
||||||
return new TeamScheduler(teams, numTimeSlots);
|
if (two == -1)
|
||||||
|
throw new ArgumentException($"Team '{team2}' not found in scheduler", nameof(team2));
|
||||||
|
|
||||||
|
_scheduleSeparateTeams.Add((one, two));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Solves the team scheduling problem using constraint programming.
|
||||||
|
/// Minimizes the number of time slots where students have conflicting team meetings.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A solution containing team assignments to time slots and conflict information</returns>
|
||||||
public TeamSchedulerSolution Solve()
|
public TeamSchedulerSolution Solve()
|
||||||
{
|
{
|
||||||
// Model.
|
// Create constraint programming model
|
||||||
var model = new CpModel();
|
var model = new CpModel();
|
||||||
|
|
||||||
// Data
|
// Build membership matrix: m[i,t] = 1 if student i is on team t, else 0
|
||||||
|
// Use team.Students to support PartialTeam objects with omitted students
|
||||||
var m = new int[_students.Length,_teams.Length];
|
var m = new int[_students.Length,_teams.Length];
|
||||||
foreach (var i in _students)
|
foreach (var i in _students)
|
||||||
foreach (var t in _teams)
|
foreach (var t in _teams)
|
||||||
m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0;
|
m[i, t] = _teamObjects[t].Students.Any(s => s.Id == _studentObjects[i].Id) ? 1 : 0;
|
||||||
|
|
||||||
// Variables.
|
// Decision variables:
|
||||||
// x - 1 if meeting of team t takes place at time slot s, else 0
|
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
|
||||||
var x = new IntVar[_teams.Length, _timeSlots.Length];
|
var x = new IntVar[_teams.Length, _timeSlots.Length];
|
||||||
foreach (var t in _teams)
|
foreach (var t in _teams)
|
||||||
foreach (var s in _timeSlots)
|
foreach (var s in _timeSlots)
|
||||||
x[t, s] = model.NewIntVar(0, 1,$"team time slots[{t},{s}]");
|
x[t, s] = model.NewIntVar(0, 1,$"team time slots[{t},{s}]");
|
||||||
|
|
||||||
// y - 1 if individual i has meetings at time slot s, 0 otherwise
|
// y[i,s] = 1 if student i has at least one meeting at time slot s, else 0
|
||||||
var y = new IntVar[_students.Length, _timeSlots.Length];
|
var y = new IntVar[_students.Length, _timeSlots.Length];
|
||||||
foreach (var i in _students)
|
foreach (var i in _students)
|
||||||
foreach (var s in _timeSlots)
|
foreach (var s in _timeSlots)
|
||||||
y[i, s] = model.NewIntVar(0, 1, $"individual time slots[{i},{s}]");
|
y[i, s] = model.NewIntVar(0, 1, $"individual time slots[{i},{s}]");
|
||||||
|
|
||||||
// each team meets exactly one time
|
// Constraint: each team meets exactly once
|
||||||
foreach (var t in _teams)
|
foreach (var t in _teams)
|
||||||
model.AddLinearConstraint(LinearExpr.Sum(_timeSlots.Select(s => x[t, s])), 1L, 1L);
|
model.AddLinearConstraint(LinearExpr.Sum(_timeSlots.Select(s => x[t, s])), 1L, 1L);
|
||||||
|
|
||||||
// individual must have at least one team meeting at the given time slot to attend
|
// Constraint: Link y[i,s] to whether student i has meetings at slot s
|
||||||
|
// y[i,s] <= sum over all teams t of (m[i,t] * x[t,s])
|
||||||
|
// This forces y[i,s] to be 1 if student i has at least one team meeting at slot s
|
||||||
foreach (var i in _students)
|
foreach (var i in _students)
|
||||||
foreach (var s in _timeSlots)
|
foreach (var s in _timeSlots)
|
||||||
model.Add(
|
model.Add(
|
||||||
@@ -73,35 +116,36 @@ public class TeamScheduler
|
|||||||
LinearExpr.Sum(_teams.Select(t => m[i, t] * x[t, s])),
|
LinearExpr.Sum(_teams.Select(t => m[i, t] * x[t, s])),
|
||||||
false));
|
false));
|
||||||
|
|
||||||
// maximize number of times individuals meet
|
// Objective: minimize the sum of y[i,s] values (minimize student conflicts)
|
||||||
var indTimeSlotVars = LinearExpr.NewBuilder();
|
var indTimeSlotVars = LinearExpr.NewBuilder();
|
||||||
foreach (var i in _students)
|
foreach (var i in _students)
|
||||||
foreach (var s in _timeSlots)
|
foreach (var s in _timeSlots)
|
||||||
indTimeSlotVars.Add(y[i, s]);
|
indTimeSlotVars.Add(y[i, s]);
|
||||||
model.Minimize(indTimeSlotVars);
|
model.Minimize(indTimeSlotVars);
|
||||||
|
|
||||||
foreach (var ts in _scheduleSeparateTeams)
|
// Constraint: teams marked as "separate" must be in different time slots
|
||||||
|
foreach (var (team1, team2) in _scheduleSeparateTeams)
|
||||||
foreach (var s in _timeSlots)
|
foreach (var s in _timeSlots)
|
||||||
model.Add(x[ts.Item1, s] != x[ts.Item2, s]);
|
model.Add(x[team1, s] != x[team2, s]);
|
||||||
|
|
||||||
|
// Configure and run solver
|
||||||
var solver = new CpSolver();
|
var solver = new CpSolver();
|
||||||
solver.StringParameters = "max_time_in_seconds:2.0";
|
solver.StringParameters = $"max_time_in_seconds:{_maxSolveTimeSeconds}";
|
||||||
|
|
||||||
var cpSolverStatus = solver.Solve(model);
|
var cpSolverStatus = solver.Solve(model);
|
||||||
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
||||||
var timeSlotTeams = new Team[_timeSlots.Length][];
|
var timeSlotTeams = new Team[_timeSlots.Length][];
|
||||||
|
|
||||||
if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible))
|
if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible))
|
||||||
return new TeamSchedulerSolution(timeSlotTeams, cpSolverStatus.ToString());
|
return new TeamSchedulerSolution(timeSlotTeams, _studentObjects.ToArray(), cpSolverStatus.ToString());
|
||||||
|
|
||||||
Debug.WriteLine($"Total cost: {solver.ObjectiveValue}\n");
|
|
||||||
|
|
||||||
|
// Extract solution: which teams are assigned to each time slot
|
||||||
foreach (var s in _timeSlots)
|
foreach (var s in _timeSlots)
|
||||||
{
|
{
|
||||||
var teams = (from t in _teams where solver.Value(x[t, s]) > 0 select _teamObjects[t]).ToArray();
|
var teams = (from t in _teams where solver.Value(x[t, s]) > 0 select _teamObjects[t]).ToArray();
|
||||||
timeSlotTeams[s] = teams;
|
timeSlotTeams[s] = teams;
|
||||||
}
|
}
|
||||||
//Debug.WriteLine("No solution found.");
|
|
||||||
return new TeamSchedulerSolution(timeSlotTeams, cpSolverStatus.ToString());
|
return new TeamSchedulerSolution(timeSlotTeams, _studentObjects.ToArray(), cpSolverStatus.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Post-processing utilities for team scheduler solutions.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamSchedulerPostProcessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extends teams to adjacent time slots (both forward and backward).
|
||||||
|
/// Teams marked as extended will appear in consecutive time slots.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="solution">The scheduler solution to modify</param>
|
||||||
|
/// <param name="extendedTeams">Teams that should be extended to adjacent slots</param>
|
||||||
|
/// <param name="allStudents">All available students for overlap calculations</param>
|
||||||
|
/// <param name="getTeamsWithoutExcludedStudents">Function to filter teams for overlap calculation</param>
|
||||||
|
public static void ExtendTeamsInSolution(
|
||||||
|
TeamSchedulerSolution solution,
|
||||||
|
IEnumerable<Team> extendedTeams,
|
||||||
|
Student[] allStudents,
|
||||||
|
Func<Team[], int, Team[]> getTeamsWithoutExcludedStudents)
|
||||||
|
{
|
||||||
|
if (solution.TimeSlots == null || !solution.TimeSlots.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamsList = extendedTeams.ToList();
|
||||||
|
if (!extendedTeamsList.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamIds = extendedTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
// Find which time slot each extended team is in and extend both forward and backward
|
||||||
|
for (int slotIndex = 0; slotIndex < solution.TimeSlots.Length; slotIndex++)
|
||||||
|
{
|
||||||
|
var currentSlot = solution.TimeSlots[slotIndex];
|
||||||
|
var teamsToExtend = currentSlot.Teams.Where(t => extendedTeamIds.Contains(t.Id)).ToList();
|
||||||
|
|
||||||
|
if (!teamsToExtend.Any())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Extend forward: add to next time slot (if exists)
|
||||||
|
if (slotIndex + 1 < solution.TimeSlots.Length)
|
||||||
|
{
|
||||||
|
var nextSlot = solution.TimeSlots[slotIndex + 1];
|
||||||
|
var nextSlotTeamsList = nextSlot.Teams.ToList();
|
||||||
|
var nextSlotTeamIds = nextSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!nextSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
nextSlotTeamsList.Add(team);
|
||||||
|
nextSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSlot.Teams = nextSlotTeamsList.ToArray();
|
||||||
|
var nextSlotIndex = slotIndex + 1;
|
||||||
|
var nextSlotTeamsForOverlap = getTeamsWithoutExcludedStudents(nextSlot.Teams, nextSlotIndex);
|
||||||
|
nextSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(nextSlotTeamsForOverlap);
|
||||||
|
nextSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(nextSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend backward: add to previous time slot (if exists)
|
||||||
|
if (slotIndex > 0)
|
||||||
|
{
|
||||||
|
var previousSlot = solution.TimeSlots[slotIndex - 1];
|
||||||
|
var previousSlotTeamsList = previousSlot.Teams.ToList();
|
||||||
|
var previousSlotTeamIds = previousSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!previousSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
previousSlotTeamsList.Add(team);
|
||||||
|
previousSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSlot.Teams = previousSlotTeamsList.ToArray();
|
||||||
|
var previousSlotIndex = slotIndex - 1;
|
||||||
|
var previousSlotTeamsForOverlap = getTeamsWithoutExcludedStudents(previousSlot.Teams, previousSlotIndex);
|
||||||
|
previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlotTeamsForOverlap);
|
||||||
|
previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,50 +2,89 @@
|
|||||||
|
|
||||||
namespace Core.Calculation;
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a solution to the team scheduling problem.
|
||||||
|
/// Contains team assignments to time slots and information about student scheduling conflicts.
|
||||||
|
/// </summary>
|
||||||
public class TeamSchedulerSolution(
|
public class TeamSchedulerSolution(
|
||||||
Team[][] timeSlots,
|
Team[][] timeSlots,
|
||||||
|
Student[] students,
|
||||||
string status)
|
string status)
|
||||||
{
|
{
|
||||||
public Team[][] TimeSlots { get; set; } = timeSlots;
|
/// <summary>
|
||||||
public string Status { get; set; } = status;
|
/// Gets the solver status (e.g., "Optimal", "Feasible", "Infeasible").
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; } = status;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the scheduled time slots with team assignments and conflict information.
|
||||||
|
/// </summary>
|
||||||
|
public TeamScheduleTimeSlot[] TimeSlots { get; set; }
|
||||||
|
= timeSlots.Select( (teams,i) =>
|
||||||
|
new TeamScheduleTimeSlot{
|
||||||
|
Name = "Time Slot " + (i + 1),
|
||||||
|
Teams = teams,
|
||||||
|
StudentOverlaps = GetStudentTeamOverlaps(teams),
|
||||||
|
UnscheduledStudents = GetStudentsNotInTimSlot(teams, students)
|
||||||
|
}
|
||||||
|
).ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the total number of student conflicts across all time slots.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeSlots">The scheduled time slots</param>
|
||||||
|
/// <returns>Total count of students with overlapping team meetings</returns>
|
||||||
public static int GetStudentTeamOverlapCount(Team[][] timeSlots)
|
public static int GetStudentTeamOverlapCount(Team[][] timeSlots)
|
||||||
{
|
{
|
||||||
return timeSlots.Sum(GetStudentTeamOverlapCount);
|
return timeSlots.Sum(GetStudentTeamOverlapCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the number of student conflicts in a single time slot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeSlot">The time slot to analyze</param>
|
||||||
|
/// <returns>Count of students with multiple team meetings in this slot</returns>
|
||||||
private static int GetStudentTeamOverlapCount(Team[] timeSlot)
|
private static int GetStudentTeamOverlapCount(Team[] timeSlot)
|
||||||
{
|
{
|
||||||
return GetStudentTeamOverlaps(timeSlot).Count();
|
return GetStudentTeamOverlaps(timeSlot).Count();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<Tuple<Student, IEnumerable<Team>>> GetStudentTeamOverlaps(Team[] timeSlot)
|
/// <summary>
|
||||||
|
/// Identifies students who have multiple team meetings in the same time slot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeSlot">The time slot to analyze</param>
|
||||||
|
/// <returns>Students and their conflicting teams in this time slot</returns>
|
||||||
|
public static IEnumerable<(Student student, IEnumerable<Team> teams)> GetStudentTeamOverlaps(Team[] timeSlot)
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
from s in timeSlot.SelectMany(ts => ts.Students).Distinct()
|
from s in timeSlot.SelectMany(ts => ts.Students).Distinct()
|
||||||
group s by timeSlot.Where(t => t.Students.Contains(s))
|
group s by timeSlot.Where(t => t.Students.Contains(s))
|
||||||
into gs
|
into gs
|
||||||
where gs.Key.Count() > 1
|
where gs.Key.Count() > 1
|
||||||
select Tuple.Create(gs.First(), gs.Key);
|
select (gs.First(), gs.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies students who have no team meetings in the given time slot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeSlot">The time slot to analyze</param>
|
||||||
|
/// <param name="students">All students</param>
|
||||||
|
/// <returns>Students not scheduled in this time slot</returns>
|
||||||
public static Student[] GetStudentsNotInTimSlot(Team[] timeSlot, Student[] students)
|
public static Student[] GetStudentsNotInTimSlot(Team[] timeSlot, Student[] students)
|
||||||
{
|
{
|
||||||
var studentsInTimeSlot = timeSlot.SelectMany(ts => ts.Students).Distinct();
|
var studentsInTimeSlot = timeSlot.SelectMany(ts => ts.Students).Distinct().ToHashSet();
|
||||||
return
|
return students.Where(s => !studentsInTimeSlot.Contains(s)).ToArray();
|
||||||
(from allStudent in students
|
|
||||||
where studentsInTimeSlot.FirstOrDefault(e => e.Equals(allStudent)) == null
|
|
||||||
select allStudent
|
|
||||||
).ToArray();
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the teams a student is on that weren't assigned to any time slot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student to check</param>
|
||||||
|
/// <returns>Teams the student is on that have no scheduled meeting</returns>
|
||||||
public Team[] StudentUnassignedTeams(Student student)
|
public Team[] StudentUnassignedTeams(Student student)
|
||||||
{
|
{
|
||||||
var meetingTeams = TimeSlots.SelectMany(t => t);
|
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
|
||||||
return
|
var meetingTeamIds = meetingTeams.Select(t => t.Id).ToHashSet();
|
||||||
student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
return student.Teams.Where(e => !meetingTeamIds.Contains(e.Id)).ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ public class TeamScheduler_DecisionTree
|
|||||||
{
|
{
|
||||||
var timeSlots = new IList<Team>[_timeSlotCount];
|
var timeSlots = new IList<Team>[_timeSlotCount];
|
||||||
for (var i = 0; i < _timeSlotCount; i++)
|
for (var i = 0; i < _timeSlotCount; i++)
|
||||||
timeSlots[i] = new List<Team>();
|
timeSlots[i] = [];
|
||||||
|
|
||||||
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,7 @@ public class TeamScheduler_DecisionTree
|
|||||||
|
|
||||||
timeSlots[overlaps.First().Item1].Add(team);
|
timeSlots[overlaps.First().Item1].Add(team);
|
||||||
}
|
}
|
||||||
return new TeamSchedulerSolution(timeSlots.Select(e => e.ToArray()).ToArray(), "Success?");
|
return new TeamSchedulerSolution(timeSlots.Select(e => e.ToArray()).ToArray(), [], "Success?");
|
||||||
}
|
}
|
||||||
|
|
||||||
//public Team[][] SolveRecursive()
|
//public Team[][] SolveRecursive()
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ namespace Core.Calculation;
|
|||||||
public class UnassignedStudentScheduler
|
public class UnassignedStudentScheduler
|
||||||
{
|
{
|
||||||
private readonly Student[] _students;
|
private readonly Student[] _students;
|
||||||
private readonly Team[] _teams;
|
private readonly Team[] _allTeams;
|
||||||
private readonly IList<Team>[] _timeSlots;
|
private readonly IList<Team>[] _timeSlots;
|
||||||
|
|
||||||
public UnassignedStudentScheduler(Team[] teams, Team[][] timeslots)
|
public UnassignedStudentScheduler(Team[] allTeams, TeamScheduleTimeSlot[] timeslots)
|
||||||
|
: this(allTeams, timeslots.Select(e => e.Teams).ToArray())
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public UnassignedStudentScheduler(Team[] allTeams, Team[][] timeslots)
|
||||||
{
|
{
|
||||||
_teams = teams;
|
_allTeams = allTeams;
|
||||||
_students = teams.SelectMany(t => t.Students).Distinct().ToArray();
|
_students = allTeams.SelectMany(t => t.Students).Distinct().ToArray();
|
||||||
_timeSlots = timeslots.Select(ts => ts.Select(t => t.Clone()).ToList()).ToArray<IList<Team>>();
|
_timeSlots = timeslots.Select(ts => ts.Select(t => t.Clone()).ToList()).ToArray<IList<Team>>();
|
||||||
}
|
}
|
||||||
public static IEnumerable<Student> UnassignedStudents(IList<Student> students, IList<Team> timeSlot)
|
public static IEnumerable<Student> UnassignedStudents(IList<Student> students, IList<Team> timeSlot)
|
||||||
@@ -68,7 +72,7 @@ public class UnassignedStudentScheduler
|
|||||||
// find teams where several unassigned students can work together
|
// find teams where several unassigned students can work together
|
||||||
private IEnumerable<Team> GetAvailableTeams_BiggestGroup(
|
private IEnumerable<Team> GetAvailableTeams_BiggestGroup(
|
||||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||||
_teams
|
_allTeams
|
||||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||||
.Where(t => t.Students.Count > 1) //|| t.Event.EventFormat is EventFormat.Individual
|
.Where(t => t.Students.Count > 1) //|| t.Event.EventFormat is EventFormat.Individual
|
||||||
@@ -79,7 +83,7 @@ public class UnassignedStudentScheduler
|
|||||||
// find individual events unassigned students can work on
|
// find individual events unassigned students can work on
|
||||||
private IEnumerable<Team> GetAvailableTeams_Individual(
|
private IEnumerable<Team> GetAvailableTeams_Individual(
|
||||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||||
_teams
|
_allTeams
|
||||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||||
.Where(t => t.Event.EventFormat == EventFormat.Individual || t.Students.Count == 1)
|
.Where(t => t.Event.EventFormat == EventFormat.Individual || t.Students.Count == 1)
|
||||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||||
@@ -88,14 +92,14 @@ public class UnassignedStudentScheduler
|
|||||||
// find any unassigned eventDefinition students can work on
|
// find any unassigned eventDefinition students can work on
|
||||||
private IEnumerable<Team> GetAvailableTeams_AnyNotMeetingAlready(
|
private IEnumerable<Team> GetAvailableTeams_AnyNotMeetingAlready(
|
||||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||||
_teams
|
_allTeams
|
||||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||||
.Where(t => t.Students.Count > 0);
|
.Where(t => t.Students.Count > 0);
|
||||||
|
|
||||||
private IEnumerable<Team> GetAvailableTeams_Any(
|
private IEnumerable<Team> GetAvailableTeams_Any(
|
||||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||||
_teams
|
_allTeams
|
||||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||||
.Where(t => t.Students.Count > 0);
|
.Where(t => t.Students.Count > 0);
|
||||||
|
|
||||||
@@ -103,7 +107,7 @@ public class UnassignedStudentScheduler
|
|||||||
// find teams where several unassigned students can work together
|
// find teams where several unassigned students can work together
|
||||||
private IEnumerable<Team> GetAvailableTeams_LevelOfEffort(
|
private IEnumerable<Team> GetAvailableTeams_LevelOfEffort(
|
||||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||||
_teams
|
_allTeams
|
||||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||||
.Where(t => t.Students.Count > 1) //|| t.Event.EventFormat is EventFormat.Individual
|
.Where(t => t.Students.Count > 1) //|| t.Event.EventFormat is EventFormat.Individual
|
||||||
|
|||||||
+5
-1
@@ -8,7 +8,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||||
<PackageReference Include="Google.OrTools" Version="9.7.2996" />
|
<PackageReference Include="Google.OrTools" Version="9.14.6206" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Sprache" Version="2.3.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class Career
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(200, MinimumLength = 1)]
|
||||||
|
[Display(Name = "Career Name")]
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a career field cluster that groups related careers together.
|
||||||
|
/// </summary>
|
||||||
|
public class CareerField
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for the career field (1-25)
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display name of the career field
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short description of the career field
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact career names that belong to this field
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> DirectCareerMatches { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keywords for pattern matching (case-insensitive substring matching)
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> PatternKeywords { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new CareerField instance
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Unique identifier</param>
|
||||||
|
/// <param name="name">Display name</param>
|
||||||
|
/// <param name="description">Short description of the career field</param>
|
||||||
|
/// <param name="directCareerMatches">Exact career name matches</param>
|
||||||
|
/// <param name="patternKeywords">Keywords for pattern matching</param>
|
||||||
|
public CareerField(int id, string name, string description, IReadOnlyList<string> directCareerMatches, IReadOnlyList<string> patternKeywords)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
Description = description;
|
||||||
|
DirectCareerMatches = directCareerMatches ?? [];
|
||||||
|
PatternKeywords = patternKeywords ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Name;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Core.Entities;
|
|
||||||
|
|
||||||
public class EventAssignment(EventDefinition eventDefinition, Student student)
|
|
||||||
{
|
|
||||||
public EventDefinition EventDefinition { get; } = eventDefinition;
|
|
||||||
public Student Student { get; } = student;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ public class EventDefinition
|
|||||||
[Required]
|
[Required]
|
||||||
[StringLength(100, MinimumLength = 2)]
|
[StringLength(100, MinimumLength = 2)]
|
||||||
[Display(Name = "Event Name")]
|
[Display(Name = "Event Name")]
|
||||||
public string Name { get; set; }
|
public string Name { get; set; } = null!;
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(40, MinimumLength = 2)]
|
[StringLength(40, MinimumLength = 2)]
|
||||||
@@ -30,30 +30,15 @@ public class EventDefinition
|
|||||||
? MinTeamSize.ToString()
|
? MinTeamSize.ToString()
|
||||||
: $"{MinTeamSize.ToString()}-{MaxTeamSize.ToString()}";
|
: $"{MinTeamSize.ToString()}-{MaxTeamSize.ToString()}";
|
||||||
|
|
||||||
[StringLength(100, MinimumLength = 3)]
|
[StringLength(100)]
|
||||||
public string? SemifinalistActivity { get; set; }
|
public string? SemifinalistActivity { get; set; }
|
||||||
|
|
||||||
public bool InterviewOrPresentation
|
public bool InterviewOrPresentation
|
||||||
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
||||||
|
|
||||||
public bool OnSiteActivity { get; set; }
|
public bool OnSiteActivity { get; set; }
|
||||||
//=> SemifinalistActivity != null
|
|
||||||
// && (SemifinalistActivity.Contains("Challenge")
|
|
||||||
// || SemifinalistActivity.Contains("Race")
|
|
||||||
// || SemifinalistActivity.Contains("Speech")
|
|
||||||
// || SemifinalistActivity.Contains("Test")
|
|
||||||
// || SemifinalistActivity.Contains("Flight")
|
|
||||||
// || Name.Contains("Leadership")
|
|
||||||
// || Name.Contains("Forensic")
|
|
||||||
// || Name.Contains("Flight")
|
|
||||||
// || Name.Contains("Coding")
|
|
||||||
// || SemifinalistActivity.Contains("Debate")
|
|
||||||
// || SemifinalistActivity.Contains("Photography")
|
|
||||||
// || SemifinalistActivity.Contains("Build")
|
|
||||||
// || Name.Contains("Chapter")
|
|
||||||
// || Name.Contains("Podcast"));
|
|
||||||
|
|
||||||
[StringLength(1024, MinimumLength = 3)]
|
[StringLength(1024)]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
[Range(0, 3)]
|
[Range(0, 3)]
|
||||||
@@ -72,19 +57,24 @@ public class EventDefinition
|
|||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(256)]
|
[StringLength(256)]
|
||||||
public string Eligibility { get; set; }
|
public string Eligibility { get; set; } = null!;
|
||||||
[StringLength(4096)]
|
[StringLength(4096)]
|
||||||
public string? Theme { get; set; }
|
public string? Theme { get; set; }
|
||||||
[StringLength(1024)]
|
[StringLength(1024)]
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int? LevelOfEffort { get; set; }
|
public int? LevelOfEffort { get; set; }
|
||||||
|
|
||||||
public override string ToString()
|
public ICollection<Career> RelatedCareers { get; set; } = [];
|
||||||
{
|
|
||||||
return Name;
|
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||||
}
|
public string? RelatedCareersText { get; set; }
|
||||||
|
|
||||||
|
public override string ToString() => Name;
|
||||||
|
|
||||||
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
||||||
public static readonly EventDefinition VotingDelegates = new(){Name = "Voting Delegates"};
|
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
|
||||||
|
public static readonly EventDefinition ChapterOfficerMeeting = new(){Name = "Chapter Officer Meeting"};
|
||||||
|
public static readonly EventDefinition VotingDelegateMeeting = new(){Name = "Voting Delegate Meeting"};
|
||||||
|
public static readonly EventDefinition SocialGathering = new(){Name = "Social Gathering"};
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
|
namespace Core.Entities
|
||||||
namespace Core.Entities
|
|
||||||
{
|
{
|
||||||
public class EventOccurrence
|
public class EventOccurrence
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public int Id { get; set; }
|
||||||
public string Time { get; set; }
|
|
||||||
public string Date { get; set; }
|
public int? EventDefinitionId { get; set; }
|
||||||
|
|
||||||
|
public string? SpecialEventType { get; set; }
|
||||||
|
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string Time { get; set; } = null!;
|
||||||
|
public string Date { get; set; } = null!;
|
||||||
public DateTime StartTime { get; set; }
|
public DateTime StartTime { get; set; }
|
||||||
public DateTime? EndTime { get; set; }
|
public DateTime? EndTime { get; set; }
|
||||||
public string Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
|
// Navigation property
|
||||||
|
public EventDefinition? EventDefinition { get; set; }
|
||||||
|
|
||||||
public bool SignupSubmitPickup =>
|
public bool SignupSubmitPickup =>
|
||||||
Name.Contains("Sign-up") ||
|
Name.Contains("Sign-up") ||
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class Note
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(200)]
|
||||||
|
[Display(Name = "Title")]
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
|
||||||
|
[Display(Name = "Content")]
|
||||||
|
public string? Content { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Created At")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Updated At")]
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Created By")]
|
||||||
|
public string? CreatedBy { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Last Modified By")]
|
||||||
|
public string? LastModifiedBy { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Is Pinned")]
|
||||||
|
public bool IsPinned { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Is Deleted")]
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
|
public List<NoteHistory> NoteHistories { get; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class NoteHistory
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Note Id")]
|
||||||
|
public int NoteId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(200)]
|
||||||
|
[Display(Name = "Title")]
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
|
||||||
|
[Display(Name = "Content")]
|
||||||
|
public string? Content { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Modified By")]
|
||||||
|
public string? ModifiedBy { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Modified At")]
|
||||||
|
public DateTime ModifiedAt { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(50)]
|
||||||
|
[Display(Name = "Change Type")]
|
||||||
|
public string ChangeType { get; set; } = null!;
|
||||||
|
|
||||||
|
public Note Note { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace Core.Entities;
|
|
||||||
|
|
||||||
public class PartialTeam : Team
|
|
||||||
{
|
|
||||||
public IList<Student> OmittedStudents { get; set; }
|
|
||||||
|
|
||||||
public override Team CloneWithOmittedStudents(IEnumerable<Student> studentsToOmit)
|
|
||||||
{
|
|
||||||
var remainingStudents = Students.Where(s => !studentsToOmit.Contains(s)).ToList();
|
|
||||||
var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList();
|
|
||||||
return new PartialTeam{Identifier = Identifier, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,12 +10,12 @@ public class Student : IEquatable<Student>
|
|||||||
[Required]
|
[Required]
|
||||||
[StringLength(50,MinimumLength = 2)]
|
[StringLength(50,MinimumLength = 2)]
|
||||||
[Display(Name = "First Name")]
|
[Display(Name = "First Name")]
|
||||||
public string FirstName { get; set; }
|
public string FirstName { get; set; } = null!;
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(50, MinimumLength = 2)]
|
[StringLength(50, MinimumLength = 2)]
|
||||||
[Display(Name = "Last Name")]
|
[Display(Name = "Last Name")]
|
||||||
public string LastName { get; set; }
|
public string LastName { get; set; } = null!;
|
||||||
|
|
||||||
[Range(5,12)]
|
[Range(5,12)]
|
||||||
[Display(Name = "Grade")]
|
[Display(Name = "Grade")]
|
||||||
@@ -44,7 +44,7 @@ public class Student : IEquatable<Student>
|
|||||||
[Display(Name = "Officer Role")]
|
[Display(Name = "Officer Role")]
|
||||||
public OfficerRole? OfficerRole { get; set; }
|
public OfficerRole? OfficerRole { get; set; }
|
||||||
|
|
||||||
public List<Team> Teams { get; set; } = null;
|
public List<Team> Teams { get; set; } = null!;
|
||||||
public List<EventDefinition> RankedEvents { get; } = [];
|
public List<EventDefinition> RankedEvents { get; } = [];
|
||||||
public List<StudentEventRanking> EventRankings { get; } = [];
|
public List<StudentEventRanking> EventRankings { get; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
namespace Core.Entities;
|
namespace Core.Entities;
|
||||||
public class Team
|
public class Team
|
||||||
@@ -6,7 +7,7 @@ public class Team
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public EventDefinition Event { get; set; }
|
public EventDefinition Event { get; set; } = null!;
|
||||||
|
|
||||||
public List<Student> Students { get; set; } = [];
|
public List<Student> Students { get; set; } = [];
|
||||||
|
|
||||||
@@ -60,6 +61,6 @@ public class Team
|
|||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
return $"{Event?.Name ?? "(no event)"} {(Identifier != null ? $"({Identifier})" : "")}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class TeamMeetingHistory
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Meeting Date")]
|
||||||
|
public DateTime MeetingDate { get; set; }
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public List<Team> Teams { get; set; } = [];
|
||||||
|
public List<Student> Students { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Core.Entities
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models
|
||||||
{
|
{
|
||||||
public class AssignmentParameters(
|
public class AssignmentParameters(
|
||||||
int effortLowerBound = 6,
|
int effortLowerBound = 6,
|
||||||
@@ -29,3 +31,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Core.Entities;
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
public class AssignmentRequirement(EventDefinition eventDefinition, Student student, Requirement requirement)
|
public class AssignmentRequirement(EventDefinition eventDefinition, Student student, Requirement requirement)
|
||||||
{
|
{
|
||||||
@@ -6,3 +8,4 @@ public class AssignmentRequirement(EventDefinition eventDefinition, Student stud
|
|||||||
public Student Student { get; } = student;
|
public Student Student { get; } = student;
|
||||||
public Requirement Requirement { get; } = requirement;
|
public Requirement Requirement { get; } = requirement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of parsing event occurrence text data.
|
||||||
|
/// Contains parsed occurrences, errors, and warnings.
|
||||||
|
/// </summary>
|
||||||
|
public class EventOccurrenceParseResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dictionary of parsed event occurrences, keyed by EventDefinition.
|
||||||
|
/// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering),
|
||||||
|
/// the EventDefinition key will be the static instance.
|
||||||
|
/// </summary>
|
||||||
|
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of parsing errors (critical issues that prevented parsing).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Errors { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of parsing warnings (non-critical issues that occurred during parsing).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Warnings { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of detailed parsing issues with line numbers and specific problem descriptions.
|
||||||
|
/// </summary>
|
||||||
|
public List<ParsingIssue> Issues { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of section headers that were encountered but skipped
|
||||||
|
/// because they don't match the chapter's school level setting.
|
||||||
|
/// Headers may contain " - MS" or " - HS" to indicate school level.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> SkippedSectionHeaders { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of event occurrences that were skipped due to school level filtering.
|
||||||
|
/// This includes both MS and HS events that don't match the chapter's school level setting.
|
||||||
|
/// </summary>
|
||||||
|
public int SkippedEventCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Footnotes captured for each event definition. Footnotes are lines that start with "*" or are parenthetical notes.
|
||||||
|
/// Multiple footnotes are concatenated into a single string. These provide additional context or information about the event occurrences.
|
||||||
|
/// </summary>
|
||||||
|
public IDictionary<EventDefinition, string> Footnotes { get; set; } = new Dictionary<EventDefinition, string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total number of event occurrences successfully parsed.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalParsed => Occurrences.Values.Sum(list => list.Count);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether parsing was successful (no errors).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSuccess => Errors.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a detailed parsing issue encountered during event occurrence parsing.
|
||||||
|
/// </summary>
|
||||||
|
public class ParsingIssue
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The line number where the issue occurred (1-based).
|
||||||
|
/// </summary>
|
||||||
|
public int LineNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The column number where the issue occurred (1-based, 0 if not available).
|
||||||
|
/// </summary>
|
||||||
|
public int ColumnNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The actual line content where the issue occurred.
|
||||||
|
/// </summary>
|
||||||
|
public string LineContent { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The type of parsing issue.
|
||||||
|
/// </summary>
|
||||||
|
public ParsingIssueType IssueType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable description of the issue.
|
||||||
|
/// </summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What was expected at the error location (e.g., "month name", "MS or HS", "time value").
|
||||||
|
/// </summary>
|
||||||
|
public string Expected { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What was actually found at the error location.
|
||||||
|
/// </summary>
|
||||||
|
public string Found { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Types of parsing issues that can occur during event occurrence parsing.
|
||||||
|
/// </summary>
|
||||||
|
public enum ParsingIssueType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Line doesn't match the expected format pattern.
|
||||||
|
/// </summary>
|
||||||
|
UnmatchedLine,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Line matches format but event definition cannot be determined.
|
||||||
|
/// </summary>
|
||||||
|
MissingEventDefinition,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time parsing failed (regex doesn't match or parse errors).
|
||||||
|
/// </summary>
|
||||||
|
TimeParseFailure,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date parsing failed (invalid day of month, etc.).
|
||||||
|
/// </summary>
|
||||||
|
DateParseFailure,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalid format or other parsing issue.
|
||||||
|
/// </summary>
|
||||||
|
InvalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
|
public class PartialTeam : Team
|
||||||
|
{
|
||||||
|
public IList<Student> OmittedStudents { get; set; } = null!;
|
||||||
|
|
||||||
|
public override Team CloneWithOmittedStudents(IEnumerable<Student> studentsToOmit)
|
||||||
|
{
|
||||||
|
var remainingStudents = Students.Where(s => !studentsToOmit.Contains(s)).ToList();
|
||||||
|
var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList();
|
||||||
|
return new PartialTeam{Identifier = Identifier, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates PartialTeam instances from teams with excluded students.
|
||||||
|
/// Aggregates exclusions across all time slots for each team.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">The teams to process</param>
|
||||||
|
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
|
||||||
|
/// <returns>Array of teams, with PartialTeam instances for teams with exclusions</returns>
|
||||||
|
public static Team[] CreatePartialTeamsFromExclusions(
|
||||||
|
IEnumerable<Team> teams,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
return teams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find all students excluded for this team across all time slots
|
||||||
|
var excludedStudentIds = excludedStudents.Keys
|
||||||
|
.Where(k => k.teamId == team.Id && excludedStudents[k])
|
||||||
|
.Select(k => k.studentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
if (excludedStudentIds.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
var excludedStudentsList = team.Students.Where(s => excludedStudentIds.Contains(s.Id)).ToList();
|
||||||
|
if (excludedStudentsList.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create PartialTeam with excluded students
|
||||||
|
return team.CloneWithOmittedStudents(excludedStudentsList);
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// School level for filtering event occurrences.
|
||||||
|
/// </summary>
|
||||||
|
public enum SchoolLevel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Middle School level
|
||||||
|
/// </summary>
|
||||||
|
MiddleSchool,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High School level
|
||||||
|
/// </summary>
|
||||||
|
HighSchool
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
namespace Core.Entities;
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
public class StudentEventStatistics
|
public class StudentEventStatistics
|
||||||
{
|
{
|
||||||
public Student Student { get; set; }
|
public Student Student { get; set; } = null!;
|
||||||
public List<EventDefinition> Events { get; set; } = [];
|
public List<EventDefinition> Events { get; set; } = [];
|
||||||
|
|
||||||
public int? TotalLevelOfEffort => Events.Sum(e => e.LevelOfEffort);
|
public int? TotalLevelOfEffort => Events.Sum(e => e.LevelOfEffort);
|
||||||
@@ -30,3 +32,4 @@ public class StudentEventStatistics
|
|||||||
return statistics.Values.ToList();
|
return statistics.Values.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
namespace Core.Parsers;
|
namespace Core.Parsers;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ public class AssignmentRequirementParser : CsvParserBase
|
|||||||
CsvReader.Read();
|
CsvReader.Read();
|
||||||
CsvReader.ReadHeader();
|
CsvReader.ReadHeader();
|
||||||
var studentColumns =
|
var studentColumns =
|
||||||
CsvReader.HeaderRecord.Select(h => h.Trim()).Where(h => !string.IsNullOrEmpty(h)).ToArray();
|
CsvReader.HeaderRecord!.Select(h => h.Trim()).Where(h => !string.IsNullOrEmpty(h)).ToArray();
|
||||||
|
|
||||||
var studentArray =
|
var studentArray =
|
||||||
studentColumns
|
studentColumns
|
||||||
@@ -28,18 +29,22 @@ public class AssignmentRequirementParser : CsvParserBase
|
|||||||
var evt = events.FirstOrDefault(e => e.ShortName == eventShortName);
|
var evt = events.FirstOrDefault(e => e.ShortName == eventShortName);
|
||||||
if (evt == null)
|
if (evt == null)
|
||||||
throw new Exception($"Could not find eventDefinition named {eventShortName}");
|
throw new Exception($"Could not find eventDefinition named {eventShortName}");
|
||||||
for (int i = 0; i <= studentArray.Length; i++)
|
for (int i = 0; i < studentArray.Length; i++)
|
||||||
{
|
{
|
||||||
|
var student = studentArray[i];
|
||||||
|
if (student == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
var field = CsvReader.GetField(i + 1);
|
var field = CsvReader.GetField(i + 1);
|
||||||
switch (field)
|
switch (field)
|
||||||
{
|
{
|
||||||
case "x":
|
case "x":
|
||||||
case "X":
|
case "X":
|
||||||
assumptions.Add(new AssignmentRequirement(evt, studentArray[i], Requirement.Exclude));
|
assumptions.Add(new AssignmentRequirement(evt, student, Requirement.Exclude));
|
||||||
break;
|
break;
|
||||||
case "i":
|
case "i":
|
||||||
case "I":
|
case "I":
|
||||||
assumptions.Add(new AssignmentRequirement(evt, studentArray[i], Requirement.Include));
|
assumptions.Add(new AssignmentRequirement(evt, student, Requirement.Include));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class EventDefinitionParser : CsvParserBase
|
|||||||
var competitiveEvent = new EventDefinition
|
var competitiveEvent = new EventDefinition
|
||||||
{
|
{
|
||||||
Name = name.Trim(),
|
Name = name.Trim(),
|
||||||
ShortName = shortName.Trim(),
|
ShortName = shortName?.Trim() ?? string.Empty,
|
||||||
EventFormat = format,
|
EventFormat = format,
|
||||||
ChapterEligibilityCountState = stateTeams,
|
ChapterEligibilityCountState = stateTeams,
|
||||||
MinTeamSize = min,
|
MinTeamSize = min,
|
||||||
@@ -64,10 +64,10 @@ public class EventDefinitionParser : CsvParserBase
|
|||||||
//RegionalPresubmit = regionalPresubmit.Trim() == "TRUE",
|
//RegionalPresubmit = regionalPresubmit.Trim() == "TRUE",
|
||||||
Notes = regionalNotes,
|
Notes = regionalNotes,
|
||||||
Documentation= documentation,
|
Documentation= documentation,
|
||||||
Presubmission = statePresubmission.Trim() == "TRUE",
|
Presubmission = statePresubmission?.Trim() == "TRUE",
|
||||||
//StatePretesting = statePretesting.Trim() == "TRUE",
|
//StatePretesting = statePretesting.Trim() == "TRUE",
|
||||||
//StatePreliminaryRound = statePreliminary.Trim() == "TRUE",
|
//StatePreliminaryRound = statePreliminary.Trim() == "TRUE",
|
||||||
Eligibility = eligibility,
|
Eligibility = eligibility ?? string.Empty,
|
||||||
Theme = theme,
|
Theme = theme,
|
||||||
Description = description,
|
Description = description,
|
||||||
LevelOfEffort = levelOfEffort
|
LevelOfEffort = levelOfEffort
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves event definitions from occurrence name patterns or section context.
|
||||||
|
/// </summary>
|
||||||
|
public static class EventDefinitionResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps special event name patterns to their EventDefinition instances.
|
||||||
|
/// Patterns are checked in order, and the first match wins.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, EventDefinition> SpecialEventPatterns =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Meet the Candidates"] = EventDefinition.MeetTheCandidates,
|
||||||
|
["Chapter Officer Meeting"] = EventDefinition.ChapterOfficerMeeting,
|
||||||
|
["Voting Delegate Meeting"] = EventDefinition.VotingDelegateMeeting,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the EventDefinition for an occurrence based on its name pattern or current section context.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="occurrenceName">The name of the occurrence.</param>
|
||||||
|
/// <param name="currentEventDefinition">The current event definition from section header, if any.</param>
|
||||||
|
/// <returns>The resolved EventDefinition, or null if it cannot be determined.</returns>
|
||||||
|
public static EventDefinition? Resolve(string occurrenceName, EventDefinition? currentEventDefinition)
|
||||||
|
{
|
||||||
|
// Check for special event name patterns first (regardless of current section)
|
||||||
|
foreach (var (pattern, eventDef) in SpecialEventPatterns)
|
||||||
|
{
|
||||||
|
if (occurrenceName.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return eventDef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in a General Schedule/Session section and no pattern matched, use GeneralSchedule
|
||||||
|
if (currentEventDefinition == EventDefinition.GeneralSchedule)
|
||||||
|
return EventDefinition.GeneralSchedule;
|
||||||
|
|
||||||
|
// If we have a current event definition from section header (e.g., regular events), use it
|
||||||
|
return currentEventDefinition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies lines to determine if they should be skipped during parsing.
|
||||||
|
/// </summary>
|
||||||
|
public static class LineClassifier
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line is empty or contains only whitespace.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsEmptyLine(string line)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line is a comment (starts with "#").
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCommentLine(string line)
|
||||||
|
{
|
||||||
|
return EventOccurrenceGrammar.IsCommentLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if a line is a continuation/wrapped line that should be skipped.
|
||||||
|
/// These are typically lines that:
|
||||||
|
/// - Start with "*" (marks the start of a continuation block)
|
||||||
|
/// - Are parenthetical notes like "(Semifinalists only)"
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsContinuationLine(string line)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
|
||||||
|
// Check if line starts with "*" (marks continuation block start)
|
||||||
|
if (trimmed.StartsWith("*", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Skip parenthetical notes
|
||||||
|
if (trimmed.StartsWith("(", StringComparison.Ordinal) && trimmed.EndsWith(")", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using FuzzySharp;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches section headers to event definitions using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
public static class SectionHeaderMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line is a general schedule header.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsGeneralSchedule(string line)
|
||||||
|
{
|
||||||
|
return EventOccurrenceGrammar.IsGeneralSchedule(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line contains school level markers (MS or HS).
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasSchoolLevel(string line)
|
||||||
|
{
|
||||||
|
return line.Contains("MS", StringComparison.Ordinal) ||
|
||||||
|
line.Contains("HS", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches a section header to the best matching event definition using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionHeader">The section header text to match.</param>
|
||||||
|
/// <param name="events">The collection of available event definitions.</param>
|
||||||
|
/// <returns>The best matching EventDefinition, or null if no match is found (ratio > 50).</returns>
|
||||||
|
public static EventDefinition? MatchEventDefinition(string sectionHeader, ICollection<EventDefinition> events)
|
||||||
|
{
|
||||||
|
var evt =
|
||||||
|
(from e in events
|
||||||
|
let rat = Fuzz.Ratio(e.Name, sectionHeader)
|
||||||
|
where rat > 50
|
||||||
|
orderby rat descending
|
||||||
|
select e).FirstOrDefault();
|
||||||
|
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the best match ratio for a section header against events.
|
||||||
|
/// Useful for error messages when no match is found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionHeader">The section header text.</param>
|
||||||
|
/// <param name="events">The collection of available event definitions.</param>
|
||||||
|
/// <returns>The best match ratio, or 0 if no events are available.</returns>
|
||||||
|
public static int GetBestMatchRatio(string sectionHeader, ICollection<EventDefinition> events)
|
||||||
|
{
|
||||||
|
if (events.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var bestEvent = events.FirstOrDefault();
|
||||||
|
return bestEvent != null ? Fuzz.Ratio(sectionHeader, bestEvent.Name) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses time and location from combined strings.
|
||||||
|
/// Extracts time using regex, then uses everything after the time as the location.
|
||||||
|
/// </summary>
|
||||||
|
public static class TimeLocationParser
|
||||||
|
{
|
||||||
|
// Shared time value pattern: matches either NOON or a time with AM/PM (e.g., "10:30 a.m.", "3 p.m.")
|
||||||
|
private static string TimeValuePattern => TimePatterns.TimeValue;
|
||||||
|
|
||||||
|
// Regex to match time ranges like "10:30 a.m. - 12:00 p.m." or "10:30 a.m. - NOON"
|
||||||
|
// Matches: time1 (optional dash time2/NOON), then location
|
||||||
|
// The time group captures the full time range (including " - NOON" if present)
|
||||||
|
// Note: Input is normalized via SanitizeInput, so only regular hyphens need to be handled
|
||||||
|
private static readonly Regex TimeLocationRegex = new(
|
||||||
|
$@"(?<Time>{TimeValuePattern}(?:\s*-\s*{TimeValuePattern})?)(?:\s+(?<Location>.+))?",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Pattern for cleaning time components from location text
|
||||||
|
// Matches optional dash, whitespace, time pattern, optional whitespace at start
|
||||||
|
// Handles: "- 12:15 p.m. ", "12:15 p.m. ", "- NOON ", "NOON ", etc.
|
||||||
|
private static readonly Regex TimeInLocationPattern = new(
|
||||||
|
$@"^(?:-\s*)?{TimeValuePattern}(?:\s+|$)",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses time and location from the timeAndLocation string.
|
||||||
|
/// Extracts time using regex, then uses everything after the time as the location (after cleaning time fragments).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeAndLocation">The combined time and location string.</param>
|
||||||
|
/// <param name="time">Output parameter: the parsed time string.</param>
|
||||||
|
/// <param name="location">Output parameter: the parsed location string.</param>
|
||||||
|
public static void Parse(
|
||||||
|
string timeAndLocation,
|
||||||
|
out string time,
|
||||||
|
out string location)
|
||||||
|
{
|
||||||
|
// Extract time using regex
|
||||||
|
var timeLocationMatch = TimeLocationRegex.Match(timeAndLocation);
|
||||||
|
|
||||||
|
if (!timeLocationMatch.Success)
|
||||||
|
{
|
||||||
|
// If time regex doesn't match, use the whole string as time
|
||||||
|
time = timeAndLocation.Trim();
|
||||||
|
location = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
time = timeLocationMatch.Groups["Time"].Captures[0].Value.Trim();
|
||||||
|
var locationPart = timeLocationMatch.Groups["Location"].Success
|
||||||
|
? timeLocationMatch.Groups["Location"].Captures[0].Value.Trim()
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
// No location part found, which is valid (some events might not have locations)
|
||||||
|
if (string.IsNullOrWhiteSpace(locationPart))
|
||||||
|
{
|
||||||
|
location = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean location of any remaining time fragments
|
||||||
|
// (e.g., "– 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C")
|
||||||
|
location = CleanLocationText(locationPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans location text by removing any remaining time components from the start.
|
||||||
|
/// Handles cases like "- 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C"
|
||||||
|
/// Note: Input is normalized, so only regular hyphens need to be handled.
|
||||||
|
/// </summary>
|
||||||
|
public static string CleanLocationText(string locationText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(locationText))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Remove time pattern from start, repeat until no more matches
|
||||||
|
string previous;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
previous = locationText;
|
||||||
|
locationText = TimeInLocationPattern.Replace(locationText, "").Trim();
|
||||||
|
} while (locationText != previous && !string.IsNullOrWhiteSpace(locationText));
|
||||||
|
|
||||||
|
// If result is empty or only whitespace, return empty
|
||||||
|
return string.IsNullOrWhiteSpace(locationText) ? string.Empty : locationText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses time strings into TimeOnly objects.
|
||||||
|
/// Handles various time formats including NOON, TBD, time ranges, and AM/PM formats.
|
||||||
|
/// </summary>
|
||||||
|
public static class TimeParser
|
||||||
|
{
|
||||||
|
private static readonly Regex TimeRegex = new(
|
||||||
|
TimePatterns.TimeWithGroups,
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the start time from a time range string.
|
||||||
|
/// Example: "10:00 a.m. - 12:00 p.m." -> "10:00 a.m."
|
||||||
|
/// </summary>
|
||||||
|
public static string ExtractStartTime(string timeRange)
|
||||||
|
{
|
||||||
|
if (timeRange.Contains(" - ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return timeRange[..timeRange.IndexOf(" - ", StringComparison.Ordinal)];
|
||||||
|
}
|
||||||
|
return timeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a time string into a TimeOnly object.
|
||||||
|
/// Handles:
|
||||||
|
/// - NOON -> 12:00 PM
|
||||||
|
/// - TBD -> 00:00:00 (midnight as placeholder)
|
||||||
|
/// - Time ranges -> extracts start time (e.g., "10:00 a.m. - 12:00 p.m." -> parses "10:00 a.m.")
|
||||||
|
/// - Standard AM/PM formats (e.g., "3:00 p.m.", "10:30 am")
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="time">The time string to parse.</param>
|
||||||
|
/// <returns>A TimeOnly object representing the parsed time.</returns>
|
||||||
|
/// <exception cref="FormatException">Thrown when the time format is not recognized.</exception>
|
||||||
|
public static TimeOnly Parse(string time)
|
||||||
|
{
|
||||||
|
int hour = 0;
|
||||||
|
int minute = 0;
|
||||||
|
|
||||||
|
// Handle TBD (To Be Determined) times gracefully
|
||||||
|
if (string.Equals(time.Trim(), "TBD", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Use a placeholder time (midnight) for TBD - the occurrence will still be created
|
||||||
|
// but with a time that indicates it's TBD
|
||||||
|
return new TimeOnly(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract start time from range if present
|
||||||
|
var timeToParse = ExtractStartTime(time);
|
||||||
|
|
||||||
|
// Handle NOON
|
||||||
|
if (timeToParse == "NOON")
|
||||||
|
{
|
||||||
|
hour = 12;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Parse time with regex
|
||||||
|
var timeMatch = TimeRegex.Match(timeToParse);
|
||||||
|
if (timeMatch.Success)
|
||||||
|
{
|
||||||
|
hour = int.Parse(timeMatch.Groups["Hour"].Captures[0].Value);
|
||||||
|
if (timeMatch.Groups["Minute"].Success)
|
||||||
|
{
|
||||||
|
minute = int.Parse(timeMatch.Groups["Minute"].Captures[0].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert AM/PM times to 24-hour format
|
||||||
|
var apmValue = timeMatch.Groups["APM"].Captures[0].Value.ToLower();
|
||||||
|
if (apmValue is "p.m." or "pm")
|
||||||
|
{
|
||||||
|
// PM: add 12 unless it's 12 PM (which stays 12)
|
||||||
|
if (hour < 12)
|
||||||
|
hour += 12;
|
||||||
|
}
|
||||||
|
else if (apmValue is "a.m." or "am")
|
||||||
|
{
|
||||||
|
// AM: if it's 12 AM, convert to midnight (0)
|
||||||
|
if (hour == 12)
|
||||||
|
hour = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new FormatException($"Time format not recognized: {time}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TimeOnly(hour, minute, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared regex patterns for time parsing.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TimePatterns
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// AM/PM pattern (case-insensitive via IgnoreCase flag).
|
||||||
|
/// Matches: "a.m.", "am", "A.M.", "AM", "p.m.", "pm", "P.M.", "PM"
|
||||||
|
/// </summary>
|
||||||
|
public const string AmPm = @"(?:a|p)\.?m\.?";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hour pattern: matches 1-2 digits (1-12 or 1-23).
|
||||||
|
/// </summary>
|
||||||
|
public const string Hour = @"\d{1,2}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minute pattern with named group for parsing: matches exactly 2 digits if present.
|
||||||
|
/// Used when parsing to TimeOnly objects where minutes must be valid.
|
||||||
|
/// </summary>
|
||||||
|
public const string MinuteWithGroup = @"(?<Minute>\d{2})?";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minute pattern for matching: matches 0-2 digits.
|
||||||
|
/// Used when extracting time strings (more lenient for location parsing).
|
||||||
|
/// </summary>
|
||||||
|
public const string MinuteFlexible = @"\d{0,2}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time value pattern: matches either NOON or a time with AM/PM.
|
||||||
|
/// Used for matching time strings in location parsing (more lenient minute format).
|
||||||
|
/// </summary>
|
||||||
|
public static string TimeValue => $@"(?:NOON|{Hour}:?{MinuteFlexible}\s*{AmPm})";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time pattern with named groups for parsing hour, minute, and AM/PM.
|
||||||
|
/// Used when parsing to TimeOnly objects (requires 2-digit minutes if present).
|
||||||
|
/// </summary>
|
||||||
|
public static string TimeWithGroups => $@"(?<Hour>{Hour}):?{MinuteWithGroup}\s?(?<APM>{AmPm})";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using Sprache;
|
||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
|
namespace Core.Parsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grammar definitions for parsing event occurrence DSL using parser combinators.
|
||||||
|
/// Provides composable parsers for each grammar rule.
|
||||||
|
/// </summary>
|
||||||
|
public static class EventOccurrenceGrammar
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Array of all month names in order (January through December).
|
||||||
|
/// This is the single source of truth for month names used throughout the parser.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] MonthNames =
|
||||||
|
[
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build month parsers dynamically from MonthNames array
|
||||||
|
private static readonly Parser<string>[] MonthParsers = MonthNames
|
||||||
|
.Select(month => Parse.String(month).Text().Token())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for month names (January through December).
|
||||||
|
/// Built dynamically from MonthNames array.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> Month = MonthParsers
|
||||||
|
.Aggregate((current, next) => current.Or(next));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for day of month (1-31, optional semicolon).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<int> DayOfMonth =
|
||||||
|
from day in Parse.Number
|
||||||
|
from semicolon in Parse.Char(';').Optional()
|
||||||
|
select int.Parse(day);
|
||||||
|
|
||||||
|
// Time parsing components
|
||||||
|
private static readonly Parser<string> Noon = Parse.String("NOON").Text().Token();
|
||||||
|
private static readonly Parser<string> Tbd = Parse.String("TBD").Text().Token();
|
||||||
|
|
||||||
|
private static readonly Parser<string> AmPm =
|
||||||
|
Parse.String("a.m.").Or(Parse.String("am")).Or(Parse.String("A.M.")).Or(Parse.String("AM"))
|
||||||
|
.Or(Parse.String("p.m.")).Or(Parse.String("pm")).Or(Parse.String("P.M.")).Or(Parse.String("PM"))
|
||||||
|
.Text().Token();
|
||||||
|
|
||||||
|
private static readonly Parser<string> TimeValue =
|
||||||
|
from hour in Parse.Number
|
||||||
|
from colon in Parse.Char(':').Optional()
|
||||||
|
from minute in Parse.Number.Optional()
|
||||||
|
from ws in Parse.WhiteSpace.Many()
|
||||||
|
from ampm in AmPm
|
||||||
|
select $"{hour}:{(minute.IsDefined ? minute.Get() : "00")} {ampm}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for hyphen character.
|
||||||
|
/// Note: Input is assumed to be normalized (en-dash and em-dash converted to regular hyphen) via SanitizeInput.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<char> Hyphen = Parse.Char('-');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for time values, including ranges and special values (NOON, TBD).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> Time =
|
||||||
|
Noon.Or(Tbd)
|
||||||
|
.Or(
|
||||||
|
from start in TimeValue.Or(Noon)
|
||||||
|
from dash in Hyphen.Then(_ => Parse.WhiteSpace.Many()).Optional()
|
||||||
|
from end in TimeValue.Or(Noon).Optional()
|
||||||
|
select end.IsDefined
|
||||||
|
? $"{start} - {end.Get()}"
|
||||||
|
: start
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for section headers: EventName - (MS|HS).
|
||||||
|
/// Note: Input is assumed to be normalized (hyphens normalized) via SanitizeInput.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<(string EventName, string SchoolLevel)> SectionHeader =
|
||||||
|
from eventName in Parse.AnyChar.Except(Hyphen).Many().Text().Token()
|
||||||
|
from hyphen in Hyphen.Token()
|
||||||
|
from schoolLevel in Parse.String("MS").Or(Parse.String("HS")).Text().Token()
|
||||||
|
select (eventName.Trim(), schoolLevel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for General Schedule/Session headers.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> GeneralSchedule =
|
||||||
|
Parse.String("General Schedule").Or(Parse.String("General Session")).Text().Token();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for comment lines (starting with #).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> CommentLine =
|
||||||
|
from hash in Parse.Char('#')
|
||||||
|
from rest in Parse.AnyChar.Many().Text()
|
||||||
|
select rest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a section header from the given line.
|
||||||
|
/// Returns null if not a section header.
|
||||||
|
/// </summary>
|
||||||
|
public static (string EventName, string SchoolLevel)? TryParseSectionHeader(string line)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = SectionHeader.Parse(line);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Sprache.ParseException)
|
||||||
|
{
|
||||||
|
// Expected - line is not a section header, return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Unexpected exception, return null to continue parsing
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a General Schedule/Session header from the given line.
|
||||||
|
/// Returns null if not a General Schedule header.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsGeneralSchedule(string line)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GeneralSchedule.Parse(line);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Sprache.ParseException)
|
||||||
|
{
|
||||||
|
// Expected - line is not a General Schedule header
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Unexpected exception, return false to continue parsing
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a comment line from the given line.
|
||||||
|
/// Returns true if the line is a comment.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCommentLine(string line)
|
||||||
|
{
|
||||||
|
return line.TrimStart().StartsWith("#", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse an occurrence line from the given text.
|
||||||
|
/// Returns null if parsing fails.
|
||||||
|
/// Strategy: Find the first month name in the line, then parse from there.
|
||||||
|
/// </summary>
|
||||||
|
public static (string Name, string Month, int Day, string TimeAndLocation)? TryParseOccurrenceLine(string line)
|
||||||
|
{
|
||||||
|
// Find the first occurrence of any month name (using normalized MonthNames array)
|
||||||
|
int monthIndex = -1;
|
||||||
|
string foundMonth = string.Empty;
|
||||||
|
|
||||||
|
foreach (var month in MonthNames)
|
||||||
|
{
|
||||||
|
var index = line.IndexOf(month, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (index >= 0 && (monthIndex < 0 || index < monthIndex))
|
||||||
|
{
|
||||||
|
monthIndex = index;
|
||||||
|
foundMonth = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthIndex < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Extract name (everything before the month)
|
||||||
|
var name = line.Substring(0, monthIndex).Trim();
|
||||||
|
|
||||||
|
// Parse from the month onwards
|
||||||
|
var restOfLine = line.Substring(monthIndex);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var monthParser = Parse.String(foundMonth).Text().Token();
|
||||||
|
var result = from month in monthParser
|
||||||
|
from day in DayOfMonth.Token()
|
||||||
|
from timeAndLocation in Parse.AnyChar.Many().Text()
|
||||||
|
select (name, month, day, timeAndLocation.Trim());
|
||||||
|
|
||||||
|
var parsed = result.Parse(restOfLine);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
catch (Sprache.ParseException)
|
||||||
|
{
|
||||||
|
// Expected - line is not a valid occurrence line, return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Unexpected exception, return null to continue parsing
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,184 +1,391 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using FuzzySharp;
|
using Core.Models;
|
||||||
|
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
|
||||||
|
using Core.Utility;
|
||||||
|
using SchoolLevel = Core.Models.SchoolLevel;
|
||||||
|
|
||||||
namespace Core.Parsers;
|
namespace Core.Parsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of parsing event occurrence file, containing both occurrences and parsing issues.
|
||||||
|
/// </summary>
|
||||||
|
public class EventOccurrenceParserResult
|
||||||
|
{
|
||||||
|
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>();
|
||||||
|
public List<ParsingIssue> Issues { get; set; } = new();
|
||||||
|
public List<string> SkippedSectionHeaders { get; set; } = new();
|
||||||
|
public int SkippedEventCount { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Footnotes captured for each event definition. Footnotes are lines that start with "*" or are parenthetical notes.
|
||||||
|
/// Multiple footnotes are concatenated into a single string.
|
||||||
|
/// </summary>
|
||||||
|
public IDictionary<EventDefinition, string> Footnotes { get; set; } = new Dictionary<EventDefinition, string>();
|
||||||
|
}
|
||||||
|
|
||||||
public class EventOccurrenceParser
|
public class EventOccurrenceParser
|
||||||
{
|
{
|
||||||
private FileSystemInfo _txtFile;
|
private FileSystemInfo _txtFile;
|
||||||
private ICollection<EventDefinition> _events;
|
private ICollection<EventDefinition> _events;
|
||||||
|
private SchoolLevel? _schoolLevel;
|
||||||
|
|
||||||
public EventOccurrenceParser(FileSystemInfo txtFile, ICollection<EventDefinition> events)
|
public EventOccurrenceParser(FileSystemInfo txtFile, ICollection<EventDefinition> events, SchoolLevel? schoolLevel = null)
|
||||||
{
|
{
|
||||||
_events = events;
|
_events = events;
|
||||||
_txtFile = txtFile;
|
_txtFile = txtFile;
|
||||||
|
_schoolLevel = schoolLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Regex _re =
|
public EventOccurrenceParserResult Parse()
|
||||||
new (
|
|
||||||
@"" + //
|
|
||||||
@"(?<Name>^[^#].*)\s" +
|
|
||||||
@"(?<Month>February|March|April|May|June|July)\s" +
|
|
||||||
@"(?<DayOfMonth>\d{1,2});?\s" +
|
|
||||||
@"(?<TimeAndLocation>.*)"
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly Regex _timeRe = new(@"(?<Hour>\d{1,2}):?(?<Minute>\d{2})?\s?(?<APM>(?:a|p)\.?m\.?)");
|
|
||||||
|
|
||||||
private readonly Regex _timeLocationRegex = new(@"(?<Time>.*(?>[AaPp]\.?[Mm]\.?))(?<Location>[\s\t].*)?");
|
|
||||||
|
|
||||||
public IDictionary<EventDefinition, List<EventOccurrence>> Parse()
|
|
||||||
{
|
{
|
||||||
var occurrences = new Dictionary<EventDefinition, List<EventOccurrence>>();
|
var result = new EventOccurrenceParserResult();
|
||||||
EventDefinition currentEventDefinition = null;
|
var occurrences = result.Occurrences;
|
||||||
|
var issues = result.Issues;
|
||||||
|
var footnotes = result.Footnotes;
|
||||||
|
EventDefinition? currentEventDefinition = null;
|
||||||
|
bool inFootnoteMode = false;
|
||||||
|
SchoolLevel? currentSectionLevel = null;
|
||||||
|
|
||||||
var lines = File.ReadLines(_txtFile.FullName);
|
var lines = File.ReadLines(_txtFile.FullName);
|
||||||
foreach (var line in lines)
|
foreach (var (line, index) in lines.Select((line, index) => (line, index + 1)))
|
||||||
{
|
{
|
||||||
var match = _re.Match(line);
|
// Normalize input: trim and normalize hyphens (en-dash, em-dash -> regular hyphen)
|
||||||
if (!match.Success)
|
// This allows the grammar parser to assume normalized input
|
||||||
|
var normalizedLine = TextUtil.SanitizeInput(line.Trim());
|
||||||
|
|
||||||
|
// Skip empty lines
|
||||||
|
if (EventOccurrenceParsers.LineClassifier.IsEmptyLine(normalizedLine))
|
||||||
{
|
{
|
||||||
if (line.Contains("MS"))
|
// Empty lines break footnote mode
|
||||||
{
|
inFootnoteMode = false;
|
||||||
var evt =
|
|
||||||
(from e in _events
|
|
||||||
let rat = Fuzz.Ratio(e.Name, line.Trim())
|
|
||||||
where rat > 50
|
|
||||||
orderby rat descending
|
|
||||||
select e).FirstOrDefault();
|
|
||||||
if (evt == null)
|
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip comment lines (starting with "#") - use grammar parser
|
||||||
|
if (EventOccurrenceParsers.LineClassifier.IsCommentLine(normalizedLine))
|
||||||
|
{
|
||||||
|
// Comment lines break footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse occurrence line using grammar parser
|
||||||
|
var occurrenceLine = EventOccurrenceGrammar.TryParseOccurrenceLine(normalizedLine);
|
||||||
|
if (!occurrenceLine.HasValue)
|
||||||
|
{
|
||||||
|
// Not an occurrence line, try other line types
|
||||||
|
// Try to parse section header using grammar parser
|
||||||
|
var sectionHeader = EventOccurrenceGrammar.TryParseSectionHeader(normalizedLine);
|
||||||
|
if (sectionHeader.HasValue)
|
||||||
|
{
|
||||||
|
var (eventNamePart, schoolLevel) = sectionHeader.Value;
|
||||||
|
|
||||||
|
// Section headers break footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
|
||||||
|
// Convert string school level to enum
|
||||||
|
var sectionSchoolLevel = SetCurrentSectionLevel(schoolLevel);
|
||||||
|
|
||||||
|
// Determine if we should skip this event based on chapter's school level setting
|
||||||
|
if (ShouldSkipSection(sectionSchoolLevel, normalizedLine, result))
|
||||||
|
{
|
||||||
|
currentEventDefinition = null; // Skip subsequent occurrences
|
||||||
|
currentSectionLevel = sectionSchoolLevel; // Track that we're in a skipped section
|
||||||
|
continue; // No issue created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current section level for events we're processing
|
||||||
|
currentSectionLevel = sectionSchoolLevel;
|
||||||
|
|
||||||
|
// Use fuzzy matching to find the best matching event definition
|
||||||
|
var evt = EventOccurrenceParsers.SectionHeaderMatcher.MatchEventDefinition(eventNamePart, _events);
|
||||||
|
if (evt == null)
|
||||||
|
{
|
||||||
|
// For unmatched headers, create issue
|
||||||
|
var bestRatio = EventOccurrenceParsers.SectionHeaderMatcher.GetBestMatchRatio(eventNamePart, _events);
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.UnmatchedLine,
|
||||||
|
Message = $"Section header '{eventNamePart} - {schoolLevel}' found but no matching event definition (best match ratio: {bestRatio})"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
currentEventDefinition = evt;
|
currentEventDefinition = evt;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (line == "General Schedule")
|
|
||||||
|
// Check for General Schedule/Session using grammar parser
|
||||||
|
if (EventOccurrenceParsers.SectionHeaderMatcher.IsGeneralSchedule(normalizedLine))
|
||||||
{
|
{
|
||||||
|
// General schedule breaks footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
currentSectionLevel = null; // Reset section level
|
||||||
currentEventDefinition = EventDefinition.GeneralSchedule;
|
currentEventDefinition = EventDefinition.GeneralSchedule;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line == "Voting Delegates")
|
// Also check for simple "MS" or "HS" in line (backward compatibility)
|
||||||
|
if (EventOccurrenceParsers.SectionHeaderMatcher.HasSchoolLevel(normalizedLine))
|
||||||
{
|
{
|
||||||
currentEventDefinition = EventDefinition.VotingDelegates;
|
// Section headers break footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
|
||||||
|
// Extract school level from line
|
||||||
|
SchoolLevel? sectionSchoolLevel = null;
|
||||||
|
if (normalizedLine.Contains("MS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
sectionSchoolLevel = SchoolLevel.MiddleSchool;
|
||||||
|
else if (normalizedLine.Contains("HS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
sectionSchoolLevel = SchoolLevel.HighSchool;
|
||||||
|
|
||||||
|
// Determine if we should skip this event based on chapter's school level setting
|
||||||
|
if (ShouldSkipSection(sectionSchoolLevel, normalizedLine, result))
|
||||||
|
{
|
||||||
|
currentEventDefinition = null; // Skip subsequent occurrences
|
||||||
|
currentSectionLevel = sectionSchoolLevel; // Track that we're in a skipped section
|
||||||
|
continue; // No issue created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current section level for events we're processing
|
||||||
|
currentSectionLevel = sectionSchoolLevel;
|
||||||
|
|
||||||
|
// Use fuzzy matching to find the best matching event definition
|
||||||
|
var evt = EventOccurrenceParsers.SectionHeaderMatcher.MatchEventDefinition(normalizedLine, _events);
|
||||||
|
if (evt == null)
|
||||||
|
{
|
||||||
|
// For unmatched headers, create issue
|
||||||
|
var bestRatio = EventOccurrenceParsers.SectionHeaderMatcher.GetBestMatchRatio(normalizedLine, _events);
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.UnmatchedLine,
|
||||||
|
Message = $"Section header with 'MS' or 'HS' found but no matching event definition (best match ratio: {bestRatio})"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currentEventDefinition = evt;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if line starts with "*" to enter footnote mode
|
||||||
|
if (normalizedLine.TrimStart().StartsWith("*", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
inFootnoteMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture footnote lines (in footnote mode OR line starts with "*" or is parenthetical)
|
||||||
|
if (inFootnoteMode || EventOccurrenceParsers.LineClassifier.IsContinuationLine(normalizedLine))
|
||||||
|
{
|
||||||
|
// Capture footnote for current event definition if we have one
|
||||||
|
if (currentEventDefinition != null)
|
||||||
|
{
|
||||||
|
if (!footnotes.ContainsKey(currentEventDefinition))
|
||||||
|
{
|
||||||
|
footnotes[currentEventDefinition] = string.Empty;
|
||||||
|
}
|
||||||
|
// Append footnote, adding a space if there's already content
|
||||||
|
if (!string.IsNullOrEmpty(footnotes[currentEventDefinition]))
|
||||||
|
{
|
||||||
|
footnotes[currentEventDefinition] += " ";
|
||||||
|
}
|
||||||
|
footnotes[currentEventDefinition] += normalizedLine;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentEventDefinition == null)
|
// "Voting Delegates" section header is no longer used - occurrences are categorized by name pattern
|
||||||
|
// Track as unmatched line if it's not empty
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedLine))
|
||||||
|
{
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.UnmatchedLine,
|
||||||
|
Message = "Line does not match expected format (Name Month Day Time/Location)"
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var occurrenceName = match.Groups["Name"].Captures[0].Value;
|
// Occurrence lines break footnote mode
|
||||||
var month = match.Groups["Month"].Captures[0].Value;
|
inFootnoteMode = false;
|
||||||
var dayOfMonth = match.Groups["DayOfMonth"].Captures[0].Value;
|
|
||||||
var timeAndLocation = match.Groups["TimeAndLocation"].Captures[0].Value;
|
|
||||||
|
|
||||||
|
// Skip occurrences under sections that don't match the school level setting
|
||||||
|
if (ShouldSkipOccurrence(currentSectionLevel, result))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (occurrenceName, month, dayOfMonthStr, timeAndLocation) = occurrenceLine.Value;
|
||||||
|
|
||||||
|
// Remove weekday suffix from occurrence name if present
|
||||||
occurrenceName = Regex.Replace(occurrenceName,
|
occurrenceName = Regex.Replace(occurrenceName,
|
||||||
@"(?<Weekday>Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s?$", "").Trim();
|
@"(?<Weekday>Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s?$", "").Trim();
|
||||||
|
|
||||||
|
// Determine event definition based on occurrence name pattern or current section
|
||||||
|
EventDefinition? eventDefinition = EventOccurrenceParsers.EventDefinitionResolver.Resolve(occurrenceName, currentEventDefinition);
|
||||||
|
|
||||||
timeAndLocation = SanitizeInput(timeAndLocation);
|
// Track issue if we can't determine the event definition
|
||||||
var timeAndLocationMatch = _timeLocationRegex.Match(timeAndLocation);
|
if (eventDefinition == null)
|
||||||
|
|
||||||
var time = timeAndLocation;
|
|
||||||
var location = string.Empty;
|
|
||||||
|
|
||||||
if (timeAndLocationMatch.Success)
|
|
||||||
{
|
{
|
||||||
time= timeAndLocationMatch.Groups["Time"].Captures[0].Value;
|
issues.Add(new ParsingIssue
|
||||||
if (timeAndLocationMatch.Groups["Location"].Success)
|
{
|
||||||
location = timeAndLocationMatch.Groups["Location"].Captures[0].Value;
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.MissingEventDefinition,
|
||||||
|
Message = $"Cannot determine event definition for occurrence: {occurrenceName}"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startDate = ParseDate(month, dayOfMonth, DateTime.Now.Year);
|
// timeAndLocation is already normalized (hyphens normalized) since normalizedLine was sanitized
|
||||||
var startTime = ParseStartTime(time);
|
|
||||||
var t = new DateTime(startDate, startTime);
|
|
||||||
|
|
||||||
var eventOccurrence = new EventOccurrence
|
// Parse time and location - extract time using regex, then use everything after time as location
|
||||||
|
EventOccurrenceParsers.TimeLocationParser.Parse(timeAndLocation, out string time, out string location);
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
DateOnly? startDate = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Name = occurrenceName, StartTime = t, Time = $"{time}", Date = $"{month} {dayOfMonth}",
|
startDate = TextUtil.ParseDate(month, dayOfMonthStr.ToString(), DateTime.Now.Year);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.DateParseFailure,
|
||||||
|
Message = $"Failed to parse date: {ex.Message}"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time
|
||||||
|
TimeOnly? startTime = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
startTime = EventOccurrenceParsers.TimeParser.Parse(time);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.TimeParseFailure,
|
||||||
|
Message = $"Failed to parse time '{time}': {ex.Message}"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate == null || startTime == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var t = new DateTime(startDate.Value, startTime.Value);
|
||||||
|
|
||||||
|
var eventOccurrence = new Core.Entities.EventOccurrence
|
||||||
|
{
|
||||||
|
Name = occurrenceName,
|
||||||
|
StartTime = t,
|
||||||
|
Time = $"{time}",
|
||||||
|
Date = $"{month} {dayOfMonthStr}",
|
||||||
Location = location
|
Location = location
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!occurrences.ContainsKey(currentEventDefinition))
|
if (!occurrences.ContainsKey(eventDefinition))
|
||||||
occurrences.Add(currentEventDefinition, []);
|
occurrences.Add(eventDefinition, []);
|
||||||
occurrences[currentEventDefinition].Add(eventOccurrence);
|
occurrences[eventDefinition].Add(eventOccurrence);
|
||||||
|
|
||||||
|
// Reset section level when we successfully parse an occurrence (means we're in a valid section)
|
||||||
|
currentSectionLevel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return occurrences;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SanitizeInput(string input)
|
/// <summary>
|
||||||
|
/// Determines if a section should be skipped based on chapter's school level setting.
|
||||||
|
/// If no school level is set, all events are processed (no filtering).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionSchoolLevel">The school level of the section (null if no school level designation).</param>
|
||||||
|
/// <param name="normalizedLine">The normalized line content for tracking skipped headers.</param>
|
||||||
|
/// <param name="result">The parse result to update with skipped headers.</param>
|
||||||
|
/// <returns>True if the section should be skipped, false otherwise.</returns>
|
||||||
|
private bool ShouldSkipSection(SchoolLevel? sectionSchoolLevel, string normalizedLine, EventOccurrenceParserResult result)
|
||||||
{
|
{
|
||||||
|
if (!sectionSchoolLevel.HasValue)
|
||||||
|
return false; // Events without school level are never skipped
|
||||||
|
|
||||||
input = input.Replace("–", "-");
|
// If no school level is set, process all events (no filtering)
|
||||||
input = input.Replace("—", "-");
|
if (!_schoolLevel.HasValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
return input;
|
// School level is set - filter based on it
|
||||||
|
if (_schoolLevel.Value == SchoolLevel.MiddleSchool && sectionSchoolLevel.Value == SchoolLevel.HighSchool)
|
||||||
|
{
|
||||||
|
result.SkippedSectionHeaders.Add(normalizedLine);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_schoolLevel.Value == SchoolLevel.HighSchool && sectionSchoolLevel.Value == SchoolLevel.MiddleSchool)
|
||||||
|
{
|
||||||
|
result.SkippedSectionHeaders.Add(normalizedLine);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DateOnly ParseDate(string month, string dayOfMonth, int year)
|
return false;
|
||||||
{
|
|
||||||
int monthNum = 1;
|
|
||||||
switch (month)
|
|
||||||
{
|
|
||||||
case "February":
|
|
||||||
monthNum = 2;
|
|
||||||
break;
|
|
||||||
case "March":
|
|
||||||
monthNum = 3;
|
|
||||||
break;
|
|
||||||
case "April":
|
|
||||||
monthNum = 4;
|
|
||||||
break;
|
|
||||||
case "May":
|
|
||||||
monthNum = 5;
|
|
||||||
break;
|
|
||||||
case "June":
|
|
||||||
monthNum = 6;
|
|
||||||
break;
|
|
||||||
case "July":
|
|
||||||
monthNum = 7;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var day = int.Parse(dayOfMonth);
|
/// <summary>
|
||||||
return new DateOnly(year, monthNum, day); ;
|
/// Converts string school level ("MS", "HS", or empty) to SchoolLevel?.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="schoolLevelStr">The school level string from the section header.</param>
|
||||||
|
/// <returns>SchoolLevel? representing the school level, or null if no school level designation.</returns>
|
||||||
|
private static SchoolLevel? SetCurrentSectionLevel(string schoolLevelStr)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(schoolLevelStr))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (schoolLevelStr.Equals("MS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return SchoolLevel.MiddleSchool;
|
||||||
|
|
||||||
|
if (schoolLevelStr.Equals("HS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return SchoolLevel.HighSchool;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimeOnly ParseStartTime(string time)
|
/// <summary>
|
||||||
|
/// Checks if current occurrence should be skipped based on section level.
|
||||||
|
/// If no school level is set, all events are processed (no filtering).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="currentSectionLevel">The current section level.</param>
|
||||||
|
/// <param name="result">The parse result to update with skip counts.</param>
|
||||||
|
/// <returns>True if the occurrence should be skipped, false otherwise.</returns>
|
||||||
|
private bool ShouldSkipOccurrence(SchoolLevel? currentSectionLevel, EventOccurrenceParserResult result)
|
||||||
{
|
{
|
||||||
int hour = 0;
|
if (!currentSectionLevel.HasValue)
|
||||||
int minute = 0;
|
return false; // Events without school level are never skipped
|
||||||
|
|
||||||
// get the part of the time before a timespan
|
// If no school level is set, process all events (no filtering)
|
||||||
if (time.Contains(" - "))
|
if (!_schoolLevel.HasValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// School level is set - filter based on it
|
||||||
|
if (_schoolLevel.Value == SchoolLevel.MiddleSchool && currentSectionLevel.Value == SchoolLevel.HighSchool)
|
||||||
{
|
{
|
||||||
time = time[..time.IndexOf(" - ", StringComparison.Ordinal)];
|
result.SkippedEventCount++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_schoolLevel.Value == SchoolLevel.HighSchool && currentSectionLevel.Value == SchoolLevel.MiddleSchool)
|
||||||
|
{
|
||||||
|
result.SkippedEventCount++;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time == "NOON")
|
return false;
|
||||||
hour = 12;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var timeMatch = _timeRe.Match(time.ToLower());
|
|
||||||
if (timeMatch.Success)
|
|
||||||
{
|
|
||||||
hour = int.Parse(timeMatch.Groups["Hour"].Captures[0].Value);
|
|
||||||
if (timeMatch.Groups["Minute"].Success)
|
|
||||||
{
|
|
||||||
minute = int.Parse(timeMatch.Groups["Minute"].Captures[0].Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeMatch.Groups["APM"].Captures[0].Value is "p.m." or "pm" && hour < 12)
|
|
||||||
hour += 12;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TimeOnly(hour, minute, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ public class StudentEventRankingParser : CsvParserBase
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
|
||||||
var competitiveEvents = new List<EventDefinition>(6);
|
var competitiveEvents = new List<EventDefinition>();
|
||||||
|
|
||||||
for (var i = 1; i <= 6; i++)
|
for (var i = 1; i <= 6; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class StudentParser : CsvParserBase
|
|||||||
|
|
||||||
public Student[] Parse()
|
public Student[] Parse()
|
||||||
{
|
{
|
||||||
var s = new List<Student>();
|
var students = new List<Student>();
|
||||||
|
|
||||||
CsvReader.Read();
|
CsvReader.Read();
|
||||||
CsvReader.ReadHeader();
|
CsvReader.ReadHeader();
|
||||||
@@ -44,9 +44,9 @@ public class StudentParser : CsvParserBase
|
|||||||
RegionalId = regionalId,
|
RegionalId = regionalId,
|
||||||
NationalId = nationalId
|
NationalId = nationalId
|
||||||
};
|
};
|
||||||
s.Add(student);
|
students.Add(student);
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.ToArray();
|
return students.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using Core.Parsers;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using SchoolLevel = Core.Models.SchoolLevel;
|
||||||
|
|
||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service implementation for parsing event occurrence text data.
|
||||||
|
/// Wraps EventOccurrenceParser to support text input and error collection.
|
||||||
|
/// </summary>
|
||||||
|
public class EventOccurrenceParserService : IEventOccurrenceParserService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration? _configuration;
|
||||||
|
|
||||||
|
public EventOccurrenceParserService(IConfiguration? configuration = null)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public EventOccurrenceParseResult ParseFromText(string text, ICollection<EventDefinition> events)
|
||||||
|
{
|
||||||
|
var result = new EventOccurrenceParseResult();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
result.Errors.Add("Input text is empty or whitespace.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a temporary file from the text content
|
||||||
|
var tempFile = Path.GetTempFileName();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(tempFile, text, Encoding.UTF8);
|
||||||
|
var fileInfo = new FileInfo(tempFile);
|
||||||
|
|
||||||
|
// Read SchoolLevel from configuration
|
||||||
|
SchoolLevel? schoolLevel = null;
|
||||||
|
if (_configuration != null)
|
||||||
|
{
|
||||||
|
var schoolLevelStr = _configuration.GetSection("ChapterSettings:SchoolLevel").Get<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(schoolLevelStr))
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<SchoolLevel>(schoolLevelStr, ignoreCase: true, out var parsed))
|
||||||
|
{
|
||||||
|
schoolLevel = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the existing EventOccurrenceParser with school level
|
||||||
|
var parser = new EventOccurrenceParser(fileInfo, events, schoolLevel);
|
||||||
|
var parserResult = parser.Parse();
|
||||||
|
|
||||||
|
// Copy occurrences from parser result
|
||||||
|
var parsedOccurrences = parserResult.Occurrences;
|
||||||
|
|
||||||
|
// Convert parsed occurrences to result format, handling special event types
|
||||||
|
foreach (var kvp in parsedOccurrences)
|
||||||
|
{
|
||||||
|
var eventDefinition = kvp.Key;
|
||||||
|
var occurrences = kvp.Value;
|
||||||
|
|
||||||
|
// Check if this is a special event type (not stored in database)
|
||||||
|
if (eventDefinition == EventDefinition.GeneralSchedule ||
|
||||||
|
eventDefinition == EventDefinition.MeetTheCandidates ||
|
||||||
|
eventDefinition == EventDefinition.ChapterOfficerMeeting ||
|
||||||
|
eventDefinition == EventDefinition.VotingDelegateMeeting ||
|
||||||
|
eventDefinition == EventDefinition.SocialGathering)
|
||||||
|
{
|
||||||
|
// For special events, set EventDefinitionId to null and set SpecialEventType
|
||||||
|
foreach (var occurrence in occurrences)
|
||||||
|
{
|
||||||
|
occurrence.EventDefinitionId = null;
|
||||||
|
occurrence.SpecialEventType = eventDefinition switch
|
||||||
|
{
|
||||||
|
var ed when ed == EventDefinition.GeneralSchedule => "GeneralSchedule",
|
||||||
|
var ed when ed == EventDefinition.MeetTheCandidates => "MeetTheCandidates",
|
||||||
|
var ed when ed == EventDefinition.ChapterOfficerMeeting => "ChapterOfficerMeeting",
|
||||||
|
var ed when ed == EventDefinition.VotingDelegateMeeting => "VotingDelegateMeeting",
|
||||||
|
var ed when ed == EventDefinition.SocialGathering => "SocialGathering",
|
||||||
|
_ => throw new InvalidOperationException($"Unknown special event type: {eventDefinition.Name}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to result with the special EventDefinition as key
|
||||||
|
result.Occurrences[eventDefinition] = occurrences;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For regular events, set EventDefinitionId and ensure SpecialEventType is null
|
||||||
|
foreach (var occurrence in occurrences)
|
||||||
|
{
|
||||||
|
occurrence.EventDefinitionId = eventDefinition.Id;
|
||||||
|
occurrence.SpecialEventType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Occurrences[eventDefinition] = occurrences;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy parsing issues from parser result
|
||||||
|
result.Issues.AddRange(parserResult.Issues);
|
||||||
|
|
||||||
|
// Copy skipped section headers from parser result
|
||||||
|
result.SkippedSectionHeaders.AddRange(parserResult.SkippedSectionHeaders);
|
||||||
|
result.SkippedEventCount = parserResult.SkippedEventCount;
|
||||||
|
|
||||||
|
// Copy footnotes from parser result
|
||||||
|
foreach (var kvp in parserResult.Footnotes)
|
||||||
|
{
|
||||||
|
result.Footnotes[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add informational message about skipped events
|
||||||
|
if (parserResult.SkippedEventCount > 0)
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Skipped {parserResult.SkippedEventCount} event occurrence(s) from other school level based on school level setting");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate locations and add warnings for problematic ones
|
||||||
|
ValidateLocations(result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Clean up temporary file
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(tempFile))
|
||||||
|
{
|
||||||
|
File.Delete(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Error parsing text: {ex.Message}");
|
||||||
|
if (ex.InnerException != null)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Inner exception: {ex.InnerException.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates locations from parsed occurrences and adds warnings for problematic locations.
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateLocations(EventOccurrenceParseResult result)
|
||||||
|
{
|
||||||
|
// Collect all unique locations
|
||||||
|
var locations = result.Occurrences.Values
|
||||||
|
.SelectMany(list => list)
|
||||||
|
.Select(eo => eo.Location)
|
||||||
|
.Where(loc => !string.IsNullOrWhiteSpace(loc))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!locations.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check for long locations (>50 chars)
|
||||||
|
var longLocations = locations.Where(loc => loc != null && loc.Length > 50).ToList();
|
||||||
|
foreach (var loc in longLocations)
|
||||||
|
{
|
||||||
|
if (loc != null)
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Location '{loc}' is unusually long ({loc.Length} characters) and may contain multiple lines or extra text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for date/time patterns
|
||||||
|
// Pattern matches: month names with day numbers, time patterns (HH:MM AM/PM), and NOON
|
||||||
|
var dateTimePattern = new Regex(
|
||||||
|
@"\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2}\b|\b\d{1,2}:\d{2}\s*(a|p)\.?m\.?\b|\bNOON\b",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
var locationsWithDateTime = locations.Where(loc => loc != null && dateTimePattern.IsMatch(loc)).ToList();
|
||||||
|
foreach (var loc in locationsWithDateTime)
|
||||||
|
{
|
||||||
|
if (loc != null)
|
||||||
|
{
|
||||||
|
var match = dateTimePattern.Match(loc);
|
||||||
|
result.Warnings.Add($"Location '{loc}' may contain date/time information: '{match.Value}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for section header patterns (missing line break detection)
|
||||||
|
// Pattern matches: text ending with " - MS", " - HS"
|
||||||
|
// This indicates a missing line break where the next section header was concatenated to the location
|
||||||
|
// Note: Input is already sanitized (en-dash/em-dash -> regular hyphen), so we only need to match regular hyphens
|
||||||
|
var sectionHeaderPattern = new Regex(
|
||||||
|
@"-\s*(MS|HS)\s*$",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
var locationsWithSectionHeader = locations.Where(loc => loc != null && sectionHeaderPattern.IsMatch(loc)).ToList();
|
||||||
|
foreach (var loc in locationsWithSectionHeader)
|
||||||
|
{
|
||||||
|
if (loc != null)
|
||||||
|
{
|
||||||
|
var match = sectionHeaderPattern.Match(loc);
|
||||||
|
// Extract the section header part for better warning message
|
||||||
|
var sectionHeaderPart = match.Value.Trim();
|
||||||
|
result.Warnings.Add($"Location '{loc}' appears to contain a section header (ends with '{sectionHeaderPart}') - likely missing line break. The location may be corrupted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service interface for parsing event occurrence text data.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEventOccurrenceParserService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses event occurrence text data and returns parse results with occurrences, errors, and warnings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The text content to parse (typically multi-line text from paste/import)</param>
|
||||||
|
/// <param name="events">Collection of EventDefinitions to match against during parsing</param>
|
||||||
|
/// <returns>ParseResult containing parsed occurrences, errors, and warnings</returns>
|
||||||
|
EventOccurrenceParseResult ParseFromText(string text, ICollection<EventDefinition> events);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing note naming conventions throughout the system.
|
||||||
|
/// Centralizes the logic for generating note titles for meeting notes and page notes.
|
||||||
|
/// </summary>
|
||||||
|
public interface INoteNamingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title for a meeting note based on the meeting date.
|
||||||
|
/// Format: "#Meeting Notes MM/dd/yyyy"
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meetingDate">The date of the meeting</param>
|
||||||
|
/// <returns>The formatted meeting note title</returns>
|
||||||
|
string GetMeetingNoteTitle(DateTime meetingDate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title for a page note based on the page identifier.
|
||||||
|
/// Format: "#{pageIdentifier}"
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pageIdentifier">The page identifier (e.g., "students", "teams")</param>
|
||||||
|
/// <returns>The formatted page note title</returns>
|
||||||
|
string GetPageNoteTitle(string pageIdentifier);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a note title represents a page note (starts with "#").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noteTitle">The note title to check</param>
|
||||||
|
/// <returns>True if the note is a page note, false otherwise</returns>
|
||||||
|
bool IsPageNote(string noteTitle);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a note title represents a meeting note (starts with "#Meeting Notes").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noteTitle">The note title to check</param>
|
||||||
|
/// <returns>True if the note is a meeting note, false otherwise</returns>
|
||||||
|
bool IsMeetingNote(string noteTitle);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of INoteNamingService that provides note naming conventions.
|
||||||
|
/// Uses "#" as the prefix for page notes and meeting notes.
|
||||||
|
/// </summary>
|
||||||
|
public class NoteNamingService : INoteNamingService
|
||||||
|
{
|
||||||
|
private const string PageNotePrefix = "#";
|
||||||
|
private const string MeetingNotePrefix = "#Meeting Notes";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string GetMeetingNoteTitle(DateTime meetingDate)
|
||||||
|
{
|
||||||
|
return $"{MeetingNotePrefix} {meetingDate:MM/dd/yyyy}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string GetPageNoteTitle(string pageIdentifier)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pageIdentifier))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Page identifier cannot be null or empty", nameof(pageIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{PageNotePrefix}{pageIdentifier}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsPageNote(string noteTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(noteTitle))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteTitle.StartsWith(PageNotePrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsMeetingNote(string noteTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(noteTitle))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static class that defines all 25 career field clusters and provides methods to match careers to fields.
|
||||||
|
/// </summary>
|
||||||
|
public static class CareerFieldDefinitions
|
||||||
|
{
|
||||||
|
private static readonly Lazy<IReadOnlyList<CareerField>> _allFields = new(() => CreateAllCareerFields());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all 25 career field definitions
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<CareerField> GetAllCareerFields() => _allFields.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the career fields that are related to the given careers.
|
||||||
|
/// Uses both direct name matching and pattern-based keyword matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="careers">The careers to match against</param>
|
||||||
|
/// <returns>Distinct list of related CareerFields</returns>
|
||||||
|
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
|
||||||
|
{
|
||||||
|
if (careers == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var careerNames = careers
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
|
.Select(c => c.Name.Trim())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!careerNames.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var matchingFields = new HashSet<CareerField>();
|
||||||
|
var allFields = GetAllCareerFields();
|
||||||
|
|
||||||
|
foreach (var field in allFields)
|
||||||
|
{
|
||||||
|
bool matches = false;
|
||||||
|
|
||||||
|
// Check direct matches
|
||||||
|
foreach (var directMatch in field.DirectCareerMatches)
|
||||||
|
{
|
||||||
|
if (careerNames.Contains(directMatch))
|
||||||
|
{
|
||||||
|
matches = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pattern matches if no direct match found
|
||||||
|
if (!matches)
|
||||||
|
{
|
||||||
|
foreach (var keyword in field.PatternKeywords)
|
||||||
|
{
|
||||||
|
if (careerNames.Any(name => name.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
matches = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches)
|
||||||
|
{
|
||||||
|
matchingFields.Add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingFields.OrderBy(f => f.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CareerField> CreateAllCareerFields()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
// 1. Aerospace & Automotive Engineering
|
||||||
|
new CareerField(
|
||||||
|
1,
|
||||||
|
"Aerospace & Automotive Engineering",
|
||||||
|
"Careers focused on designing and engineering aircraft, spacecraft, and vehicles for transportation.",
|
||||||
|
[ "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" ],
|
||||||
|
[ "aeronautical", "aircraft", "automobile", "automotive", "race car" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 2. Mechanical & Robotics Engineering
|
||||||
|
new CareerField(
|
||||||
|
2,
|
||||||
|
"Mechanical & Robotics Engineering",
|
||||||
|
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
|
||||||
|
[ "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" ],
|
||||||
|
[ "mechanical", "robotics", "machine" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 3. Electrical & Electronics Engineering
|
||||||
|
new CareerField(
|
||||||
|
3,
|
||||||
|
"Electrical & Electronics Engineering",
|
||||||
|
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
|
||||||
|
[ "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" ],
|
||||||
|
[ "electrical", "electronic", "electrician" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 4. Civil & Structural Engineering
|
||||||
|
new CareerField(
|
||||||
|
4,
|
||||||
|
"Civil & Structural Engineering",
|
||||||
|
"Engineering fields focused on infrastructure, buildings, bridges, and construction project management.",
|
||||||
|
[ "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" ],
|
||||||
|
[ "civil", "construction", "structural", "contractor" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 5. Environmental & Energy Engineering
|
||||||
|
new CareerField(
|
||||||
|
5,
|
||||||
|
"Environmental & Energy Engineering",
|
||||||
|
"Engineering careers focused on sustainable energy solutions, environmental protection, and chemical processes.",
|
||||||
|
[ "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" ],
|
||||||
|
[ "chemical", "energy", "environmental", "solar" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 6. General Engineering & Quality
|
||||||
|
new CareerField(
|
||||||
|
6,
|
||||||
|
"General Engineering & Quality",
|
||||||
|
"Broad engineering roles including management, quality assurance, and standards compliance across various industries.",
|
||||||
|
[ "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" ],
|
||||||
|
[ "engineer", "quality", "standards" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 7. Architecture & Urban Planning
|
||||||
|
new CareerField(
|
||||||
|
7,
|
||||||
|
"Architecture & Urban Planning",
|
||||||
|
"Design and planning careers focused on buildings, spaces, and community development.",
|
||||||
|
[ "Architect", "Community planner", "Interior designer", "Urban and regional planner" ],
|
||||||
|
[ "architect", "planner", "interior design", "urban" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 8. Software Development
|
||||||
|
new CareerField(
|
||||||
|
8,
|
||||||
|
"Software Development",
|
||||||
|
"Careers in creating, designing, and developing computer software applications and systems.",
|
||||||
|
[ "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" ],
|
||||||
|
[ "programming", "programmer", "software", "developer" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 9. IT & Networking
|
||||||
|
new CareerField(
|
||||||
|
9,
|
||||||
|
"IT & Networking",
|
||||||
|
"Information technology careers involving computer systems, networks, technical support, and telecommunications.",
|
||||||
|
[ "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" ],
|
||||||
|
[ "network", "computer", "technical support", "telecommunications", "IT" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 10. Cybersecurity & Digital Forensics
|
||||||
|
new CareerField(
|
||||||
|
10,
|
||||||
|
"Cybersecurity & Digital Forensics",
|
||||||
|
"Security-focused careers protecting digital systems, investigating cybercrimes, and ensuring information security.",
|
||||||
|
[ "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" ],
|
||||||
|
[ "cyber", "security", "forensics", "cryptography", "vulnerability" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 11. Data Science & Analytics
|
||||||
|
new CareerField(
|
||||||
|
11,
|
||||||
|
"Data Science & Analytics",
|
||||||
|
"Careers analyzing data, applying mathematical and statistical methods to solve problems and make decisions.",
|
||||||
|
[ "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" ],
|
||||||
|
[ "data", "analyst", "actuary", "economist", "mathematician", "research" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 12. CAD, CNC & Manufacturing
|
||||||
|
new CareerField(
|
||||||
|
12,
|
||||||
|
"CAD, CNC & Manufacturing",
|
||||||
|
"Careers in computer-aided design, manufacturing processes, and production planning.",
|
||||||
|
[ "CAD professional", "CNC programmer", "Manufacturing", "Production planner" ],
|
||||||
|
[ "CAD", "CNC", "manufacturing", "production" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 13. Industrial & Product Design
|
||||||
|
new CareerField(
|
||||||
|
13,
|
||||||
|
"Industrial & Product Design",
|
||||||
|
"Design careers creating products, commercial goods, and industrial solutions with focus on form and function.",
|
||||||
|
[ "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" ],
|
||||||
|
[ "designer", "design", "industrial", "product", "appraiser" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 14. Visual Arts & Animation
|
||||||
|
new CareerField(
|
||||||
|
14,
|
||||||
|
"Visual Arts & Animation",
|
||||||
|
"Creative careers in visual design, illustration, animation, and digital art creation.",
|
||||||
|
[ "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" ],
|
||||||
|
[ "animator", "artist", "graphic", "illustrator", "multimedia" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 15. Game Design & Interactive Media
|
||||||
|
new CareerField(
|
||||||
|
15,
|
||||||
|
"Game Design & Interactive Media",
|
||||||
|
"Careers in video game design, development, testing, and professional gaming.",
|
||||||
|
[ "Game designer", "Game Play Tester", "Professional Gamer" ],
|
||||||
|
[ "game", "gamer", "gaming" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 16. Audio & Music Production
|
||||||
|
new CareerField(
|
||||||
|
16,
|
||||||
|
"Audio & Music Production",
|
||||||
|
"Careers in audio engineering, music composition, sound design, and broadcast technology.",
|
||||||
|
[ "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" ],
|
||||||
|
[ "audio", "music", "broadcast", "sound" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 17. Video & Film Production
|
||||||
|
new CareerField(
|
||||||
|
17,
|
||||||
|
"Video & Film Production",
|
||||||
|
"Careers in video production, filmmaking, directing, and television broadcasting.",
|
||||||
|
[ "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" ],
|
||||||
|
[ "video", "film", "director", "television", "broadcast", "videographer" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 18. Web & Digital Communications
|
||||||
|
new CareerField(
|
||||||
|
18,
|
||||||
|
"Web & Digital Communications",
|
||||||
|
"Careers in web design, digital communication, and instructional technology.",
|
||||||
|
[ "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" ],
|
||||||
|
[ "web", "website", "digital", "communications", "webmaster" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 19. Writing & Publishing
|
||||||
|
new CareerField(
|
||||||
|
19,
|
||||||
|
"Writing & Publishing",
|
||||||
|
"Careers in writing, editing, publishing, and content creation across various media formats.",
|
||||||
|
[ "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" ],
|
||||||
|
[ "writing", "writer", "editor", "publisher", "copy" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 20. Journalism & Public Relations
|
||||||
|
new CareerField(
|
||||||
|
20,
|
||||||
|
"Journalism & Public Relations",
|
||||||
|
"Careers in news reporting, photojournalism, public relations, and communications management.",
|
||||||
|
[ "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" ],
|
||||||
|
[ "journalism", "reporter", "photojournalist", "communications", "speaker" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 21. Forensics & Criminal Investigation
|
||||||
|
new CareerField(
|
||||||
|
21,
|
||||||
|
"Forensics & Criminal Investigation",
|
||||||
|
"Careers in criminal investigation, forensic science, and analyzing evidence for legal proceedings.",
|
||||||
|
[ "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" ],
|
||||||
|
[ "forensic", "detective", "investigator", "crime" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 22. Healthcare & Medical Technology
|
||||||
|
new CareerField(
|
||||||
|
22,
|
||||||
|
"Healthcare & Medical Technology",
|
||||||
|
"Medical and healthcare careers providing patient care, medical technology, and health services.",
|
||||||
|
[ "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" ],
|
||||||
|
[ "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 23. Science & Research
|
||||||
|
new CareerField(
|
||||||
|
23,
|
||||||
|
"Science & Research",
|
||||||
|
"Scientific research careers across biology, physics, meteorology, and other scientific disciplines.",
|
||||||
|
[ "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" ],
|
||||||
|
[ "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 24. Education & Training
|
||||||
|
new CareerField(
|
||||||
|
24,
|
||||||
|
"Education & Training",
|
||||||
|
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
|
||||||
|
[ "Educator", "Teacher/trainer", "Technology education instructor" ],
|
||||||
|
[ "educator", "teacher", "trainer", "education", "instructor" ]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 25. Business, Legal & Government
|
||||||
|
new CareerField(
|
||||||
|
25,
|
||||||
|
"Business, Legal & Government",
|
||||||
|
"Careers in business management, legal services, government, politics, and public policy.",
|
||||||
|
[ "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" ],
|
||||||
|
[ "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" ]
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
public static class CareerNormalizer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes career names from multiline text input.
|
||||||
|
/// Strips bullet points, trims whitespace, and returns distinct normalized names.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">Multiline text input containing career names</param>
|
||||||
|
/// <returns>Collection of normalized career names (trimmed, with bullets removed)</returns>
|
||||||
|
public static IEnumerable<string> NormalizeCareerNames(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return Enumerable.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return input
|
||||||
|
.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None)
|
||||||
|
.Select(line => NormalizeSingleCareerName(line))
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a single career name by stripping bullet points and trimming whitespace.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="careerName">The career name to normalize</param>
|
||||||
|
/// <returns>Normalized career name</returns>
|
||||||
|
private static string NormalizeSingleCareerName(string careerName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(careerName))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove common bullet point characters (•, -, *, etc.) and trim
|
||||||
|
var normalized = Regex.Replace(careerName.Trim(), @"^[\u2022\u2023\u25E6\u2043\u2219\-\*\•]\s*", string.Empty, RegexOptions.Compiled);
|
||||||
|
|
||||||
|
return normalized.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds or creates a career name for case-insensitive duplicate detection.
|
||||||
|
/// Returns the normalized (lowercase) version for comparison.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="careerName">The career name to normalize for comparison</param>
|
||||||
|
/// <returns>Lowercase normalized name for duplicate detection</returns>
|
||||||
|
public static string GetNormalizedKey(string careerName)
|
||||||
|
{
|
||||||
|
return NormalizeSingleCareerName(careerName).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for formatting individual student names with overlap and absent markers.
|
||||||
|
/// </summary>
|
||||||
|
public static class StudentNameFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Options for formatting student names.
|
||||||
|
/// </summary>
|
||||||
|
public record FormatOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the student is absent. If true, adds "(absent)" suffix. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAbsent { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the student has schedule overlaps. If true, adds "*" suffix. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasOverlap { get; init; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a single student name with overlap and absent markers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student to format.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>Formatted student name.</returns>
|
||||||
|
public static string FormatStudentName(Student student, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (student == null)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var name = student.FirstName;
|
||||||
|
|
||||||
|
// Add overlap marker
|
||||||
|
if (options.HasOverlap)
|
||||||
|
{
|
||||||
|
name += "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add absent marker
|
||||||
|
if (options.IsAbsent)
|
||||||
|
{
|
||||||
|
name += " (absent)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using FuzzySharp;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for matching team names from clipboard text using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamClipboardMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Matches team names from clipboard text against available teams using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clipboardText">The text content from the clipboard.</param>
|
||||||
|
/// <param name="availableTeams">The collection of available teams to match against.</param>
|
||||||
|
/// <param name="matchThreshold">The minimum fuzzy match score threshold (0-100). Default is 85.</param>
|
||||||
|
/// <returns>A list of teams that match the clipboard text, ordered by match quality.</returns>
|
||||||
|
public static List<Team> MatchTeamsFromClipboard(
|
||||||
|
string clipboardText,
|
||||||
|
IEnumerable<Team> availableTeams,
|
||||||
|
int matchThreshold = 85)
|
||||||
|
{
|
||||||
|
var matchedTeams = new List<Team>();
|
||||||
|
var teamsList = availableTeams.ToList();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clipboardText) || !teamsList.Any())
|
||||||
|
{
|
||||||
|
return matchedTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split clipboard text by newlines
|
||||||
|
var lines = clipboardText.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
// Extract team name portion (before " - " if present, as clipboard format includes student lists)
|
||||||
|
var teamName = ExtractTeamNameFromLine(line);
|
||||||
|
|
||||||
|
// Skip empty lines or lines that look like headers/metadata
|
||||||
|
if (ShouldSkipLine(teamName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best match using fuzzy matching
|
||||||
|
var matchedTeam = FindBestMatch(teamName, teamsList, matchThreshold);
|
||||||
|
|
||||||
|
if (matchedTeam != null && !matchedTeams.Any(t => t.Id == matchedTeam.Id))
|
||||||
|
{
|
||||||
|
matchedTeams.Add(matchedTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTeamNameFromLine(string line)
|
||||||
|
{
|
||||||
|
var dashIndex = line.IndexOf(" - ");
|
||||||
|
if (dashIndex > 0)
|
||||||
|
{
|
||||||
|
return line.Substring(0, dashIndex).Trim();
|
||||||
|
}
|
||||||
|
return line.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldSkipLine(string teamName)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(teamName) ||
|
||||||
|
teamName.StartsWith("--") ||
|
||||||
|
teamName.Equals("Unscheduled", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Team? FindBestMatch(string teamName, List<Team> availableTeams, int matchThreshold)
|
||||||
|
{
|
||||||
|
// Normalize both strings to lowercase for case-insensitive comparison
|
||||||
|
var normalizedTeamName = teamName.ToLowerInvariant();
|
||||||
|
|
||||||
|
var bestMatch = availableTeams
|
||||||
|
.Select(team => new
|
||||||
|
{
|
||||||
|
Team = team,
|
||||||
|
Score = Fuzz.Ratio(normalizedTeamName, team.ToString().ToLowerInvariant())
|
||||||
|
})
|
||||||
|
.Where(x => x.Score >= matchThreshold)
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return bestMatch?.Team;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for filtering teams based on various criteria.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamFilterExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds teams that are regional events to the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> AddRegionals(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
return allTeams.Where(e => e.Event.RegionalEvent).Concat(currentTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds teams with high level of effort (>= 3) to the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> AddHighLevelOfEffort(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
return allTeams.Where(e => e.Event.LevelOfEffort >= 3).Concat(currentTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes individual event teams from the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> RemoveIndividual(this IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
return teams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes teams with low level of effort (<= 1) from the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> RemoveLowLevelOfEffort(this IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
return teams.Where(t => t.Event.LevelOfEffort > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inverts the selection - returns all teams not in the current selection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> Invert(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
var currentTeamIds = currentTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
return allTeams.Where(t => !currentTeamIds.Contains(t.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for formatting student names for teams with various formatting options.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamStudentNameFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Options for formatting student names.
|
||||||
|
/// </summary>
|
||||||
|
public record FormatOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Style for indicating the team captain. Default is None.
|
||||||
|
/// </summary>
|
||||||
|
public CaptainIndicatorStyle CaptainIndicator { get; init; } = CaptainIndicatorStyle.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How to order the students. Default is None (preserve original order).
|
||||||
|
/// </summary>
|
||||||
|
public OrderingStyle Ordering { get; init; } = OrderingStyle.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to add "*" suffix for students with schedule overlaps. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool MarkOverlaps { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to add "(absent)" suffix for absent students. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool MarkAbsent { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Function to determine if a student has overlaps. Required if MarkOverlaps is true.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Student, bool>? HasOverlaps { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collection of absent students. Required if MarkAbsent is true.
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<Student>? AbsentStudents { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to only show captain indicator for team events (EventFormat.Team). Default is true.
|
||||||
|
/// </summary>
|
||||||
|
public bool OnlyTeamEventsForCaptain { get; init; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Style for indicating the team captain.
|
||||||
|
/// </summary>
|
||||||
|
public enum CaptainIndicatorStyle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No captain indicator.
|
||||||
|
/// </summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use asterisk (*) for captain.
|
||||||
|
/// </summary>
|
||||||
|
Star,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use "(Cpt)" for captain.
|
||||||
|
/// </summary>
|
||||||
|
Captain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Style for ordering students.
|
||||||
|
/// </summary>
|
||||||
|
public enum OrderingStyle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Preserve original order.
|
||||||
|
/// </summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captain first, then alphabetical by first name.
|
||||||
|
/// </summary>
|
||||||
|
CaptainFirst,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alphabetical by first name.
|
||||||
|
/// </summary>
|
||||||
|
Alphabetical,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// By grade + TSA year descending (highest first).
|
||||||
|
/// </summary>
|
||||||
|
GradeDescending
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a single student name with the specified options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student to format.</param>
|
||||||
|
/// <param name="team">The team the student belongs to.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>Formatted student name.</returns>
|
||||||
|
public static string FormatStudentName(Student student, Team team, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (student == null)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var name = student.FirstName;
|
||||||
|
|
||||||
|
// Add captain indicator (before overlap/absent markers)
|
||||||
|
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student))
|
||||||
|
{
|
||||||
|
var shouldShow = !options.OnlyTeamEventsForCaptain || (team.Event != null && team.Event.EventFormat == EventFormat.Team);
|
||||||
|
if (shouldShow)
|
||||||
|
{
|
||||||
|
name += options.CaptainIndicator switch
|
||||||
|
{
|
||||||
|
CaptainIndicatorStyle.Star => "*",
|
||||||
|
CaptainIndicatorStyle.Captain => "(Cpt)",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert collections/functions to booleans for StudentNameFormatter
|
||||||
|
var hasOverlap = options.MarkOverlaps && options.HasOverlaps != null && options.HasOverlaps(student);
|
||||||
|
var isAbsent = options.MarkAbsent && options.AbsentStudents != null && options.AbsentStudents.Contains(student);
|
||||||
|
|
||||||
|
// Use StudentNameFormatter for overlap/absent markers (appended after captain indicator)
|
||||||
|
var studentNameOptions = new StudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
HasOverlap = hasOverlap,
|
||||||
|
IsAbsent = isAbsent
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the suffix from StudentNameFormatter (overlap/absent markers)
|
||||||
|
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
|
||||||
|
var suffix = baseFormatted.Substring(student.FirstName.Length);
|
||||||
|
|
||||||
|
return name + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats all students for a team as a comma-separated string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="team">The team to format students for.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>Comma-separated string of formatted student names.</returns>
|
||||||
|
public static string FormatStudentList(Team team, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (team?.Students == null || !team.Students.Any())
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||||
|
|
||||||
|
return string.Join(", ", students.Select(s => FormatStudentName(s, team, options)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats all students for a team as a list of strings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="team">The team to format students for.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>List of formatted student names.</returns>
|
||||||
|
public static List<string> FormatStudentListAsList(Team team, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (team?.Students == null || !team.Students.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||||
|
|
||||||
|
return students.Select(s => FormatStudentName(s, team, options)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats all unique students across multiple teams for an event definition.
|
||||||
|
/// Returns a list of unique student names (by student ID).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="eventDefinition">The event definition.</param>
|
||||||
|
/// <param name="teams">The teams for this event.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>List of unique formatted student names.</returns>
|
||||||
|
public static List<string> FormatStudentListForEvent(EventDefinition eventDefinition, IEnumerable<Team> teams, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (eventDefinition == null || teams == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var teamsList = teams.ToList();
|
||||||
|
if (!teamsList.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// Get all unique students from all teams for this event
|
||||||
|
var allStudents = teamsList
|
||||||
|
.SelectMany(t => t.Students)
|
||||||
|
.DistinctBy(s => s.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!allStudents.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// Determine if each student is a captain in any team
|
||||||
|
var studentsWithCaptainInfo = allStudents.Select(s =>
|
||||||
|
{
|
||||||
|
var isCaptain = teamsList.Any(t => t.Captain != null && t.Captain.Equals(s));
|
||||||
|
// Find a team this student belongs to for formatting context
|
||||||
|
var studentTeam = teamsList.FirstOrDefault(t => t.Students.Contains(s));
|
||||||
|
return new { Student = s, IsCaptain = isCaptain, Team = studentTeam };
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Apply ordering
|
||||||
|
var orderedStudents = options.Ordering switch
|
||||||
|
{
|
||||||
|
OrderingStyle.CaptainFirst => studentsWithCaptainInfo
|
||||||
|
.OrderBy(x => !x.IsCaptain)
|
||||||
|
.ThenBy(x => x.Student.FirstName)
|
||||||
|
.Select(x => x.Student),
|
||||||
|
OrderingStyle.Alphabetical => studentsWithCaptainInfo
|
||||||
|
.OrderBy(x => x.Student.FirstName)
|
||||||
|
.Select(x => x.Student),
|
||||||
|
OrderingStyle.GradeDescending => studentsWithCaptainInfo
|
||||||
|
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
|
||||||
|
.Select(x => x.Student),
|
||||||
|
_ => studentsWithCaptainInfo.Select(x => x.Student)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format names - for event-level formatting, we need to handle captain indicator specially
|
||||||
|
// since a student might be captain in one team but not another
|
||||||
|
return orderedStudents.Select(s =>
|
||||||
|
{
|
||||||
|
var studentInfo = studentsWithCaptainInfo.First(x => x.Student.Equals(s));
|
||||||
|
// Use the student's team if available, otherwise create a minimal team for formatting context
|
||||||
|
var team = studentInfo.Team ?? new Team { Students = [s], Event = eventDefinition };
|
||||||
|
|
||||||
|
// Create options that handle captain indicator for event-level
|
||||||
|
var eventOptions = options with
|
||||||
|
{
|
||||||
|
CaptainIndicator = studentInfo.IsCaptain &&
|
||||||
|
(!options.OnlyTeamEventsForCaptain || eventDefinition.EventFormat == EventFormat.Team)
|
||||||
|
? options.CaptainIndicator
|
||||||
|
: CaptainIndicatorStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
return FormatStudentName(s, team, eventOptions);
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Student> ApplyOrdering(IEnumerable<Student> students, Team team, OrderingStyle ordering)
|
||||||
|
{
|
||||||
|
return ordering switch
|
||||||
|
{
|
||||||
|
OrderingStyle.CaptainFirst => students
|
||||||
|
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
|
||||||
|
.ThenBy(s => s.FirstName),
|
||||||
|
OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName),
|
||||||
|
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
|
||||||
|
_ => students
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,48 @@
|
|||||||
namespace Core.Utility;
|
using Core.Parsers;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
public static class TextUtil
|
public static class TextUtil
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sanitizes input by normalizing hyphens (en-dash, em-dash -> regular hyphen).
|
||||||
|
/// This allows parsers to assume normalized input.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The input string to sanitize.</param>
|
||||||
|
/// <returns>The sanitized string with normalized hyphens.</returns>
|
||||||
|
public static string SanitizeInput(string input)
|
||||||
|
{
|
||||||
|
input = input.Replace("–", "-"); // en-dash
|
||||||
|
input = input.Replace("—", "-"); // em-dash
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a date from month name, day of month, and year.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="month">The month name (e.g., "January", "February"). Case-insensitive.</param>
|
||||||
|
/// <param name="dayOfMonth">The day of the month as a string (e.g., "15", "3").</param>
|
||||||
|
/// <param name="year">The year (e.g., 2025).</param>
|
||||||
|
/// <returns>A <see cref="DateOnly"/> representing the parsed date.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when the month name is invalid.</exception>
|
||||||
|
/// <exception cref="FormatException">Thrown when the day of month cannot be parsed as an integer.</exception>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">Thrown when the resulting date is invalid (e.g., February 30).</exception>
|
||||||
|
public static DateOnly ParseDate(string month, string dayOfMonth, int year)
|
||||||
|
{
|
||||||
|
// Use normalized MonthNames array from grammar
|
||||||
|
var monthLower = month.ToLower();
|
||||||
|
var monthIndex = Array.FindIndex(EventOccurrenceGrammar.MonthNames,
|
||||||
|
m => m.ToLower() == monthLower);
|
||||||
|
|
||||||
|
if (monthIndex < 0)
|
||||||
|
throw new ArgumentException($"Invalid month: {month}", nameof(month));
|
||||||
|
|
||||||
|
// Month index is 0-based, month number is 1-based
|
||||||
|
int monthNum = monthIndex + 1;
|
||||||
|
var day = int.Parse(dayOfMonth);
|
||||||
|
return new DateOnly(year, monthNum, day);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the ordinal value of positive integers.
|
/// Get the ordinal value of positive integers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for all validation rules
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEntity">Type of entity being validated (Student, Team, StudentEventStatistics, etc.)</typeparam>
|
||||||
|
public interface IValidationRule<TEntity>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Execute the validation rule against an entity
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entity">Entity to validate</param>
|
||||||
|
/// <param name="config">Validation configuration</param>
|
||||||
|
/// <returns>Validation warning if rule is violated, null otherwise</returns>
|
||||||
|
ValidationWarning? Validate(TEntity entity, ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if this rule applies to the given context
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">Validation context</param>
|
||||||
|
/// <returns>True if the rule should be executed in this context</returns>
|
||||||
|
bool AppliesTo(ValidationContext context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for validation rules that check event counts against thresholds for student rankings
|
||||||
|
/// Similar to EventCountThresholdRuleBase but works with Student entities instead of StudentEventStatistics
|
||||||
|
/// </summary>
|
||||||
|
public abstract class EventCountThresholdRankingRuleBase : IValidationRule<Student>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get the actual count to validate
|
||||||
|
/// </summary>
|
||||||
|
protected abstract int GetCount(Student student);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the threshold value from configuration
|
||||||
|
/// </summary>
|
||||||
|
protected abstract int GetThreshold(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if the count violates the threshold
|
||||||
|
/// </summary>
|
||||||
|
protected abstract bool ViolatesThreshold(int count, int threshold);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the severity from configuration
|
||||||
|
/// </summary>
|
||||||
|
protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The validation warning code
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string Code { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the display message
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string GetMessage(int count, int threshold);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icon identifier for the warning (can be null)
|
||||||
|
/// </summary>
|
||||||
|
protected virtual string? IconIdentifier => null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build additional metadata beyond the standard fields
|
||||||
|
/// </summary>
|
||||||
|
protected virtual Dictionary<string, object> BuildAdditionalMetadata(Student student, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationWarning? Validate(Student entity, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
var count = GetCount(entity);
|
||||||
|
var threshold = GetThreshold(config);
|
||||||
|
|
||||||
|
if (!ViolatesThreshold(count, threshold))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var metadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "StudentId", entity.Id },
|
||||||
|
{ "StudentName", entity.FirstNameLastName }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add any additional metadata from derived class
|
||||||
|
foreach (var kvp in BuildAdditionalMetadata(entity, config))
|
||||||
|
{
|
||||||
|
metadata[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ValidationWarning
|
||||||
|
{
|
||||||
|
Code = Code,
|
||||||
|
Message = GetMessage(count, threshold),
|
||||||
|
Severity = GetSeverity(config),
|
||||||
|
Context = ValidationContext.StudentRanking,
|
||||||
|
IconIdentifier = IconIdentifier,
|
||||||
|
Metadata = metadata
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AppliesTo(ValidationContext context) =>
|
||||||
|
context == ValidationContext.StudentRanking ||
|
||||||
|
context == ValidationContext.StudentRegistration;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for validation rules that check event counts against thresholds
|
||||||
|
/// Eliminates duplication across TooManyEvents, TooFewEvents, and TooManyRegionalEvents rules
|
||||||
|
/// </summary>
|
||||||
|
public abstract class EventCountThresholdRuleBase : IValidationRule<StudentEventStatistics>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get the actual count to validate
|
||||||
|
/// </summary>
|
||||||
|
protected abstract int GetCount(StudentEventStatistics statistics);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the threshold value from configuration
|
||||||
|
/// </summary>
|
||||||
|
protected abstract int GetThreshold(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if the count violates the threshold
|
||||||
|
/// </summary>
|
||||||
|
protected abstract bool ViolatesThreshold(int count, int threshold);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the base severity from configuration (can be overridden for critical thresholds)
|
||||||
|
/// </summary>
|
||||||
|
protected abstract ValidationSeverity GetBaseSeverity(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optionally check for critical threshold that escalates to Error severity
|
||||||
|
/// Returns null if no critical threshold applies
|
||||||
|
/// </summary>
|
||||||
|
protected virtual int? GetCriticalThreshold(ValidationConfiguration config) => null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if count violates critical threshold (for escalation to Error)
|
||||||
|
/// </summary>
|
||||||
|
protected virtual bool ViolatesCriticalThreshold(int count, int criticalThreshold) => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The validation warning code
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string Code { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the display message
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string GetMessage(int count, int threshold);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icon identifier for the warning (can be null)
|
||||||
|
/// </summary>
|
||||||
|
protected virtual string? IconIdentifier => null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build additional metadata beyond the standard fields
|
||||||
|
/// </summary>
|
||||||
|
protected virtual Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationWarning? Validate(StudentEventStatistics entity, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
var count = GetCount(entity);
|
||||||
|
var threshold = GetThreshold(config);
|
||||||
|
|
||||||
|
if (!ViolatesThreshold(count, threshold))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Check for critical threshold escalation
|
||||||
|
var severity = GetBaseSeverity(config);
|
||||||
|
var criticalThreshold = GetCriticalThreshold(config);
|
||||||
|
if (criticalThreshold.HasValue && ViolatesCriticalThreshold(count, criticalThreshold.Value))
|
||||||
|
{
|
||||||
|
severity = ValidationSeverity.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "StudentId", entity.Student.Id },
|
||||||
|
{ "StudentName", entity.Student.FirstNameLastName },
|
||||||
|
{ "EventCount", entity.EventCount }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add any additional metadata from derived class
|
||||||
|
foreach (var kvp in BuildAdditionalMetadata(entity, config))
|
||||||
|
{
|
||||||
|
metadata[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ValidationWarning
|
||||||
|
{
|
||||||
|
Code = Code,
|
||||||
|
Message = GetMessage(count, threshold),
|
||||||
|
Severity = severity,
|
||||||
|
Context = ValidationContext.StudentAssignment,
|
||||||
|
IconIdentifier = IconIdentifier,
|
||||||
|
Metadata = metadata
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AppliesTo(ValidationContext context) =>
|
||||||
|
context == ValidationContext.StudentAssignment ||
|
||||||
|
context == ValidationContext.StudentRegistration;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for validation rules that check if a student has been assigned a required event type
|
||||||
|
/// Eliminates duplication across assignment validation rules
|
||||||
|
/// </summary>
|
||||||
|
public abstract class RequiredEventTypeAssignmentRuleBase : IValidationRule<StudentEventStatistics>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Check if this event type is required based on configuration
|
||||||
|
/// </summary>
|
||||||
|
protected abstract bool IsRequired(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if the student has been assigned this event type
|
||||||
|
/// </summary>
|
||||||
|
protected abstract bool HasEventType(StudentEventStatistics statistics);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the severity level for this rule from configuration
|
||||||
|
/// </summary>
|
||||||
|
protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The validation warning code (e.g., "NO_REGIONAL_EVENT_ASSIGNED")
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string Code { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display message for the warning
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string Message { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icon identifier for the warning (e.g., "RegionalEvent")
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string IconIdentifier { get; }
|
||||||
|
|
||||||
|
public ValidationWarning? Validate(StudentEventStatistics entity, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
if (!IsRequired(config))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (HasEventType(entity))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new ValidationWarning
|
||||||
|
{
|
||||||
|
Code = Code,
|
||||||
|
Message = Message,
|
||||||
|
Severity = GetSeverity(config),
|
||||||
|
Context = ValidationContext.StudentAssignment,
|
||||||
|
IconIdentifier = IconIdentifier,
|
||||||
|
Metadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "StudentId", entity.Student.Id },
|
||||||
|
{ "StudentName", entity.Student.FirstNameLastName },
|
||||||
|
{ "EventCount", entity.EventCount }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AppliesTo(ValidationContext context) =>
|
||||||
|
context == ValidationContext.StudentAssignment ||
|
||||||
|
context == ValidationContext.StudentRegistration;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for validation rules that check if a student has ranked a required event type
|
||||||
|
/// Eliminates duplication across NoRegionalEvent, NoOnSiteActivity, and NoIndividualEvent rules
|
||||||
|
/// </summary>
|
||||||
|
public abstract class RequiredEventTypeRuleBase : IValidationRule<Student>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Check if this event type is required based on configuration
|
||||||
|
/// </summary>
|
||||||
|
protected abstract bool IsRequired(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if the student has ranked this event type
|
||||||
|
/// </summary>
|
||||||
|
protected abstract bool HasEventType(Student student);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the severity level for this rule from configuration
|
||||||
|
/// </summary>
|
||||||
|
protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The validation warning code (e.g., "NO_REGIONAL_EVENT")
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string Code { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display message for the warning
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string Message { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icon identifier for the warning (e.g., "RegionalEvent")
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string IconIdentifier { get; }
|
||||||
|
|
||||||
|
public ValidationWarning? Validate(Student entity, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
if (!IsRequired(config))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (HasEventType(entity))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new ValidationWarning
|
||||||
|
{
|
||||||
|
Code = Code,
|
||||||
|
Message = Message,
|
||||||
|
Severity = GetSeverity(config),
|
||||||
|
Context = ValidationContext.StudentRanking,
|
||||||
|
IconIdentifier = IconIdentifier,
|
||||||
|
Metadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "StudentId", entity.Id },
|
||||||
|
{ "StudentName", entity.FirstNameLastName }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AppliesTo(ValidationContext context) =>
|
||||||
|
context == ValidationContext.StudentRanking ||
|
||||||
|
context == ValidationContext.StudentRegistration;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has been assigned at least one on-site activity
|
||||||
|
/// </summary>
|
||||||
|
public class NoOnSiteActivityAssignmentRule : RequiredEventTypeAssignmentRuleBase
|
||||||
|
{
|
||||||
|
protected override bool IsRequired(ValidationConfiguration config) => config.RequireOnSiteActivity;
|
||||||
|
|
||||||
|
protected override bool HasEventType(StudentEventStatistics statistics) => statistics.HasOnSiteActivity;
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoOnSiteActivitySeverity;
|
||||||
|
|
||||||
|
protected override string Code => "NO_ONSITE_ACTIVITY_ASSIGNED";
|
||||||
|
|
||||||
|
protected override string Message => "No On-Site Activity";
|
||||||
|
|
||||||
|
protected override string IconIdentifier => "OnSiteActivity";
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has been assigned at least one regional event
|
||||||
|
/// </summary>
|
||||||
|
public class NoRegionalEventAssignmentRule : RequiredEventTypeAssignmentRuleBase
|
||||||
|
{
|
||||||
|
protected override bool IsRequired(ValidationConfiguration config) => config.RequireRegionalEvent;
|
||||||
|
|
||||||
|
protected override bool HasEventType(StudentEventStatistics statistics) => statistics.HasRegionalEvent;
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoRegionalEventSeverity;
|
||||||
|
|
||||||
|
protected override string Code => "NO_REGIONAL_EVENT_ASSIGNED";
|
||||||
|
|
||||||
|
protected override string Message => "No Regional Event";
|
||||||
|
|
||||||
|
protected override string IconIdentifier => "RegionalEvent";
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has too few event assignments
|
||||||
|
/// </summary>
|
||||||
|
public class TooFewEventsRule : EventCountThresholdRuleBase
|
||||||
|
{
|
||||||
|
protected override int GetCount(StudentEventStatistics statistics) => statistics.EventCount;
|
||||||
|
|
||||||
|
protected override int GetThreshold(ValidationConfiguration config) => config.MinRecommendedEvents;
|
||||||
|
|
||||||
|
protected override bool ViolatesThreshold(int count, int threshold) => count < threshold;
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetBaseSeverity(ValidationConfiguration config) => config.EventCountSeverity;
|
||||||
|
|
||||||
|
protected override int? GetCriticalThreshold(ValidationConfiguration config) => config.MinCriticalEvents;
|
||||||
|
|
||||||
|
protected override bool ViolatesCriticalThreshold(int count, int criticalThreshold) => count < criticalThreshold;
|
||||||
|
|
||||||
|
protected override string Code => "TOO_FEW_EVENTS";
|
||||||
|
|
||||||
|
protected override string GetMessage(int count, int threshold) =>
|
||||||
|
$"Student has {count} events (min recommended: {threshold})";
|
||||||
|
|
||||||
|
protected override Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "MinRecommended", config.MinRecommendedEvents },
|
||||||
|
{ "MinCritical", config.MinCriticalEvents }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has too many event assignments
|
||||||
|
/// </summary>
|
||||||
|
public class TooManyEventsRule : EventCountThresholdRuleBase
|
||||||
|
{
|
||||||
|
protected override int GetCount(StudentEventStatistics statistics) => statistics.EventCount;
|
||||||
|
|
||||||
|
protected override int GetThreshold(ValidationConfiguration config) => config.MaxRecommendedEvents;
|
||||||
|
|
||||||
|
protected override bool ViolatesThreshold(int count, int threshold) => count > threshold;
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetBaseSeverity(ValidationConfiguration config) => config.EventCountSeverity;
|
||||||
|
|
||||||
|
protected override int? GetCriticalThreshold(ValidationConfiguration config) => config.MaxCriticalEvents;
|
||||||
|
|
||||||
|
protected override bool ViolatesCriticalThreshold(int count, int criticalThreshold) => count > criticalThreshold;
|
||||||
|
|
||||||
|
protected override string Code => "TOO_MANY_EVENTS";
|
||||||
|
|
||||||
|
protected override string GetMessage(int count, int threshold) =>
|
||||||
|
$"Student has {count} events (max recommended: {threshold})";
|
||||||
|
|
||||||
|
protected override Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "MaxRecommended", config.MaxRecommendedEvents },
|
||||||
|
{ "MaxCritical", config.MaxCriticalEvents }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has too many regional events in their assignments
|
||||||
|
/// </summary>
|
||||||
|
public class TooManyRegionalEventsAssignmentRule : EventCountThresholdRuleBase
|
||||||
|
{
|
||||||
|
protected override int GetCount(StudentEventStatistics statistics) =>
|
||||||
|
statistics.Events.Count(e => e.RegionalEvent);
|
||||||
|
|
||||||
|
protected override int GetThreshold(ValidationConfiguration config) => config.MaxRegionalEvents;
|
||||||
|
|
||||||
|
protected override bool ViolatesThreshold(int count, int threshold) => count > threshold;
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetBaseSeverity(ValidationConfiguration config) =>
|
||||||
|
config.TooManyRegionalEventsSeverity;
|
||||||
|
|
||||||
|
protected override string Code => "TOO_MANY_REGIONAL_EVENTS";
|
||||||
|
|
||||||
|
protected override string GetMessage(int count, int threshold) =>
|
||||||
|
$"Student has {count} regional events (max recommended: {threshold})";
|
||||||
|
|
||||||
|
protected override string? IconIdentifier => "RegionalEvent";
|
||||||
|
|
||||||
|
protected override Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "RegionalEventCount", GetCount(statistics) },
|
||||||
|
{ "MaxRegionalEvents", config.MaxRegionalEvents }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentRankingRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has ranked at least one individual event
|
||||||
|
/// </summary>
|
||||||
|
public class NoIndividualEventRule : RequiredEventTypeRuleBase
|
||||||
|
{
|
||||||
|
protected override bool IsRequired(ValidationConfiguration config) => config.RequireIndividualEvent;
|
||||||
|
|
||||||
|
protected override bool HasEventType(Student student) => student.RankedEvents.Any(e => e.EventFormat == EventFormat.Individual);
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoIndividualEventSeverity;
|
||||||
|
|
||||||
|
protected override string Code => "NO_INDIVIDUAL_EVENT";
|
||||||
|
|
||||||
|
protected override string Message => "No Individual Event";
|
||||||
|
|
||||||
|
protected override string IconIdentifier => "IndividualEvent";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentRankingRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has ranked at least one on-site activity
|
||||||
|
/// </summary>
|
||||||
|
public class NoOnSiteActivityRule : RequiredEventTypeRuleBase
|
||||||
|
{
|
||||||
|
protected override bool IsRequired(ValidationConfiguration config) => config.RequireOnSiteActivity;
|
||||||
|
|
||||||
|
protected override bool HasEventType(Student student) => student.RankedEvents.Any(e => e.OnSiteActivity);
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoOnSiteActivitySeverity;
|
||||||
|
|
||||||
|
protected override string Code => "NO_ONSITE_ACTIVITY";
|
||||||
|
|
||||||
|
protected override string Message => "No On-Site Activity";
|
||||||
|
|
||||||
|
protected override string IconIdentifier => "OnSiteActivity";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentRankingRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has ranked at least one regional event
|
||||||
|
/// </summary>
|
||||||
|
public class NoRegionalEventRule : RequiredEventTypeRuleBase
|
||||||
|
{
|
||||||
|
protected override bool IsRequired(ValidationConfiguration config) => config.RequireRegionalEvent;
|
||||||
|
|
||||||
|
protected override bool HasEventType(Student student) => student.RankedEvents.Any(e => e.RegionalEvent);
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoRegionalEventSeverity;
|
||||||
|
|
||||||
|
protected override string Code => "NO_REGIONAL_EVENT";
|
||||||
|
|
||||||
|
protected override string Message => "No Regional Event";
|
||||||
|
|
||||||
|
protected override string IconIdentifier => "RegionalEvent";
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.StudentRankingRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a student has too many regional events in their rankings
|
||||||
|
/// </summary>
|
||||||
|
public class TooManyRegionalEventsRule : EventCountThresholdRankingRuleBase
|
||||||
|
{
|
||||||
|
protected override int GetCount(Student student) =>
|
||||||
|
student.RankedEvents.Count(e => e.RegionalEvent);
|
||||||
|
|
||||||
|
protected override int GetThreshold(ValidationConfiguration config) => config.MaxRegionalEvents;
|
||||||
|
|
||||||
|
protected override bool ViolatesThreshold(int count, int threshold) => count > threshold;
|
||||||
|
|
||||||
|
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) =>
|
||||||
|
config.TooManyRegionalEventsSeverity;
|
||||||
|
|
||||||
|
protected override string Code => "TOO_MANY_REGIONAL_EVENTS";
|
||||||
|
|
||||||
|
protected override string GetMessage(int count, int threshold) =>
|
||||||
|
$"Student has {count} regional events (max recommended: {threshold})";
|
||||||
|
|
||||||
|
protected override string? IconIdentifier => "RegionalEvent";
|
||||||
|
|
||||||
|
protected override Dictionary<string, object> BuildAdditionalMetadata(Student student, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "RegionalEventCount", GetCount(student) },
|
||||||
|
{ "MaxRegionalEvents", config.MaxRegionalEvents }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.TeamRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a team-based event has an assigned captain
|
||||||
|
/// </summary>
|
||||||
|
public class MissingCaptainRule : IValidationRule<Team>
|
||||||
|
{
|
||||||
|
public ValidationWarning? Validate(Team entity, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
if (!config.RequireTeamCaptain)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Individual events don't need captains
|
||||||
|
if (entity.Event.EventFormat == EventFormat.Individual)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (entity.Captain != null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new ValidationWarning
|
||||||
|
{
|
||||||
|
Code = "MISSING_CAPTAIN",
|
||||||
|
Message = "Team has no captain assigned",
|
||||||
|
Severity = config.MissingCaptainSeverity,
|
||||||
|
Context = ValidationContext.Team,
|
||||||
|
IconIdentifier = "Captain",
|
||||||
|
Metadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "TeamId", entity.Id },
|
||||||
|
{ "EventName", entity.Event.Name },
|
||||||
|
{ "TeamSize", entity.Students.Count }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AppliesTo(ValidationContext context) =>
|
||||||
|
context == ValidationContext.Team;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.TeamRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a team has more members than the maximum allowed
|
||||||
|
/// </summary>
|
||||||
|
public class TeamSizeTooLargeRule : IValidationRule<Team>
|
||||||
|
{
|
||||||
|
public ValidationWarning? Validate(Team entity, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
// Individual events don't have team size requirements
|
||||||
|
if (entity.Event.EventFormat == EventFormat.Individual)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var actualSize = entity.Students.Count;
|
||||||
|
var maxSize = entity.Event.MaxTeamSize;
|
||||||
|
|
||||||
|
if (actualSize <= maxSize)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new ValidationWarning
|
||||||
|
{
|
||||||
|
Code = "TEAM_SIZE_TOO_LARGE",
|
||||||
|
Message = $"Team has {actualSize} members (max: {maxSize})",
|
||||||
|
Severity = ValidationSeverity.Error, // Always error - hard constraint
|
||||||
|
Context = ValidationContext.Team,
|
||||||
|
IconIdentifier = null,
|
||||||
|
Metadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "TeamId", entity.Id },
|
||||||
|
{ "EventName", entity.Event.Name },
|
||||||
|
{ "ActualSize", actualSize },
|
||||||
|
{ "MinSize", entity.Event.MinTeamSize },
|
||||||
|
{ "MaxSize", maxSize }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AppliesTo(ValidationContext context) =>
|
||||||
|
context == ValidationContext.Team;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Validation.Rules.TeamRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation rule that checks if a team has fewer members than the minimum required
|
||||||
|
/// </summary>
|
||||||
|
public class TeamSizeTooSmallRule : IValidationRule<Team>
|
||||||
|
{
|
||||||
|
public ValidationWarning? Validate(Team entity, ValidationConfiguration config)
|
||||||
|
{
|
||||||
|
// Individual events don't have team size requirements
|
||||||
|
if (entity.Event.EventFormat == EventFormat.Individual)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var actualSize = entity.Students.Count;
|
||||||
|
var minSize = entity.Event.MinTeamSize;
|
||||||
|
|
||||||
|
if (actualSize >= minSize)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new ValidationWarning
|
||||||
|
{
|
||||||
|
Code = "TEAM_SIZE_TOO_SMALL",
|
||||||
|
Message = $"Team has {actualSize} members (min: {minSize})",
|
||||||
|
Severity = config.TeamSizeSeverity,
|
||||||
|
Context = ValidationContext.Team,
|
||||||
|
IconIdentifier = null,
|
||||||
|
Metadata = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "TeamId", entity.Id },
|
||||||
|
{ "EventName", entity.Event.Name },
|
||||||
|
{ "ActualSize", actualSize },
|
||||||
|
{ "MinSize", minSize },
|
||||||
|
{ "MaxSize", entity.Event.MaxTeamSize }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AppliesTo(ValidationContext context) =>
|
||||||
|
context == ValidationContext.Team;
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for validation thresholds and rules
|
||||||
|
/// </summary>
|
||||||
|
public class ValidationConfiguration
|
||||||
|
{
|
||||||
|
// Event count thresholds
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum recommended number of events per student (Warning if below)
|
||||||
|
/// </summary>
|
||||||
|
public int MinRecommendedEvents { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum recommended number of events per student (Warning if above)
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRecommendedEvents { get; set; } = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum critical number of events per student (Error if below)
|
||||||
|
/// </summary>
|
||||||
|
public int MinCriticalEvents { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum critical number of events per student (Error if above)
|
||||||
|
/// </summary>
|
||||||
|
public int MaxCriticalEvents { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum recommended number of regional events per student (Warning if above)
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRegionalEvents { get; set; } = 3;
|
||||||
|
|
||||||
|
// Required event types
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to require students to have at least one regional event
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireRegionalEvent { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to require students to have at least one on-site activity
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireOnSiteActivity { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to require students to have at least one individual event
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireIndividualEvent { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to require team-based events to have an assigned captain
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireTeamCaptain { get; set; } = true;
|
||||||
|
|
||||||
|
// Severity levels for each rule type
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for "No Regional Event" warnings
|
||||||
|
/// </summary>
|
||||||
|
public ValidationSeverity NoRegionalEventSeverity { get; set; } = ValidationSeverity.Warning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for "No On-Site Activity" warnings
|
||||||
|
/// </summary>
|
||||||
|
public ValidationSeverity NoOnSiteActivitySeverity { get; set; } = ValidationSeverity.Warning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for "No Individual Event" warnings
|
||||||
|
/// </summary>
|
||||||
|
public ValidationSeverity NoIndividualEventSeverity { get; set; } = ValidationSeverity.Warning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for team size warnings
|
||||||
|
/// </summary>
|
||||||
|
public ValidationSeverity TeamSizeSeverity { get; set; } = ValidationSeverity.Warning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for event count warnings
|
||||||
|
/// </summary>
|
||||||
|
public ValidationSeverity EventCountSeverity { get; set; } = ValidationSeverity.Warning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for missing captain warnings
|
||||||
|
/// </summary>
|
||||||
|
public ValidationSeverity MissingCaptainSeverity { get; set; } = ValidationSeverity.Warning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for too many regional events warnings
|
||||||
|
/// </summary>
|
||||||
|
public ValidationSeverity TooManyRegionalEventsSeverity { get; set; } = ValidationSeverity.Warning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default configuration matching current app behavior
|
||||||
|
/// </summary>
|
||||||
|
public static ValidationConfiguration Default => new()
|
||||||
|
{
|
||||||
|
RequireRegionalEvent = true,
|
||||||
|
RequireOnSiteActivity = true,
|
||||||
|
RequireIndividualEvent = false,
|
||||||
|
MinRecommendedEvents = 2,
|
||||||
|
MaxRecommendedEvents = 4,
|
||||||
|
MinCriticalEvents = 1,
|
||||||
|
MaxCriticalEvents = 6,
|
||||||
|
MaxRegionalEvents = 3,
|
||||||
|
RequireTeamCaptain = true,
|
||||||
|
NoRegionalEventSeverity = ValidationSeverity.Warning,
|
||||||
|
NoOnSiteActivitySeverity = ValidationSeverity.Warning,
|
||||||
|
NoIndividualEventSeverity = ValidationSeverity.Warning,
|
||||||
|
TeamSizeSeverity = ValidationSeverity.Warning,
|
||||||
|
EventCountSeverity = ValidationSeverity.Warning,
|
||||||
|
MissingCaptainSeverity = ValidationSeverity.Warning,
|
||||||
|
TooManyRegionalEventsSeverity = ValidationSeverity.Warning
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create validation configuration from assignment parameters
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="parameters">Assignment parameters to convert</param>
|
||||||
|
/// <returns>Validation configuration matching the assignment parameters</returns>
|
||||||
|
public static ValidationConfiguration FromAssignmentParameters(AssignmentParameters parameters)
|
||||||
|
{
|
||||||
|
return new ValidationConfiguration
|
||||||
|
{
|
||||||
|
RequireRegionalEvent = parameters.RequireRegional,
|
||||||
|
RequireOnSiteActivity = parameters.RequireOnSite,
|
||||||
|
MinRecommendedEvents = parameters.EventsLowerBound,
|
||||||
|
MaxRecommendedEvents = parameters.EventsUpperBound,
|
||||||
|
MinCriticalEvents = 1,
|
||||||
|
MaxCriticalEvents = 6,
|
||||||
|
RequireTeamCaptain = true,
|
||||||
|
NoRegionalEventSeverity = ValidationSeverity.Warning,
|
||||||
|
NoOnSiteActivitySeverity = ValidationSeverity.Warning,
|
||||||
|
NoIndividualEventSeverity = ValidationSeverity.Warning,
|
||||||
|
TeamSizeSeverity = ValidationSeverity.Warning,
|
||||||
|
EventCountSeverity = ValidationSeverity.Warning,
|
||||||
|
MissingCaptainSeverity = ValidationSeverity.Warning
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserialize validation configuration from JSON string
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="json">JSON string containing configuration</param>
|
||||||
|
/// <returns>ValidationConfiguration instance or Default if deserialization fails</returns>
|
||||||
|
public static ValidationConfiguration FromJson(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
return JsonSerializer.Deserialize<ValidationConfiguration>(json, options) ?? Default;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load validation configuration from a JSON file
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to the JSON configuration file</param>
|
||||||
|
/// <returns>ValidationConfiguration instance or Default if file doesn't exist or loading fails</returns>
|
||||||
|
public static async Task<ValidationConfiguration> LoadFromFileAsync(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return Default;
|
||||||
|
|
||||||
|
var json = await File.ReadAllTextAsync(path);
|
||||||
|
return FromJson(json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save this validation configuration to a JSON file
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path where the JSON file should be saved</param>
|
||||||
|
public async Task SaveToFileAsync(string path)
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(this, options);
|
||||||
|
await File.WriteAllTextAsync(path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Context in which validation is being performed
|
||||||
|
/// </summary>
|
||||||
|
public enum ValidationContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validating a single student's event rankings
|
||||||
|
/// </summary>
|
||||||
|
StudentRanking = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validating a single team configuration
|
||||||
|
/// </summary>
|
||||||
|
Team = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validating a student's assigned events
|
||||||
|
/// </summary>
|
||||||
|
StudentAssignment = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validating student registration data
|
||||||
|
/// </summary>
|
||||||
|
StudentRegistration = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main service for executing validation rules and generating warnings
|
||||||
|
/// </summary>
|
||||||
|
public class ValidationService
|
||||||
|
{
|
||||||
|
private readonly ValidationConfiguration _config;
|
||||||
|
private readonly List<IValidationRule<Student>> _studentRules;
|
||||||
|
private readonly List<IValidationRule<Team>> _teamRules;
|
||||||
|
private readonly List<IValidationRule<StudentEventStatistics>> _statisticsRules;
|
||||||
|
|
||||||
|
// Lazy static singletons for rule definitions (instantiated once per application lifetime)
|
||||||
|
private static readonly Lazy<List<IValidationRule<Student>>> _studentRuleDefinitions =
|
||||||
|
new(() => DiscoverRules<Student>());
|
||||||
|
private static readonly Lazy<List<IValidationRule<Team>>> _teamRuleDefinitions =
|
||||||
|
new(() => DiscoverRules<Team>());
|
||||||
|
private static readonly Lazy<List<IValidationRule<StudentEventStatistics>>> _statisticsRuleDefinitions =
|
||||||
|
new(() => DiscoverRules<StudentEventStatistics>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new validation service with the specified configuration
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">Validation configuration (uses Default if null)</param>
|
||||||
|
public ValidationService(ValidationConfiguration? config = null)
|
||||||
|
{
|
||||||
|
_config = config ?? ValidationConfiguration.Default;
|
||||||
|
|
||||||
|
// Use the shared rule definitions (singleton pattern)
|
||||||
|
_studentRules = _studentRuleDefinitions.Value;
|
||||||
|
_teamRules = _teamRuleDefinitions.Value;
|
||||||
|
_statisticsRules = _statisticsRuleDefinitions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discover all validation rules for a given entity type using reflection
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEntity">The entity type to find rules for</typeparam>
|
||||||
|
/// <returns>List of discovered validation rules</returns>
|
||||||
|
private static List<IValidationRule<TEntity>> DiscoverRules<TEntity>()
|
||||||
|
{
|
||||||
|
var ruleType = typeof(IValidationRule<TEntity>);
|
||||||
|
|
||||||
|
return Assembly.GetExecutingAssembly()
|
||||||
|
.GetTypes()
|
||||||
|
.Where(t => t.IsClass && !t.IsAbstract && ruleType.IsAssignableFrom(t))
|
||||||
|
.Select(t => (IValidationRule<TEntity>)Activator.CreateInstance(t)!)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate a student's event rankings
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">Student to validate</param>
|
||||||
|
/// <param name="context">Validation context</param>
|
||||||
|
/// <returns>List of validation warnings</returns>
|
||||||
|
public List<ValidationWarning> ValidateStudentRankings(Student student, ValidationContext context)
|
||||||
|
{
|
||||||
|
return _studentRules
|
||||||
|
.Where(rule => rule.AppliesTo(context))
|
||||||
|
.Select(rule => rule.Validate(student, _config))
|
||||||
|
.Where(warning => warning != null)
|
||||||
|
.Cast<ValidationWarning>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate a team configuration
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="team">Team to validate</param>
|
||||||
|
/// <param name="context">Validation context (defaults to Team)</param>
|
||||||
|
/// <returns>List of validation warnings</returns>
|
||||||
|
public List<ValidationWarning> ValidateTeam(Team team, ValidationContext context = ValidationContext.Team)
|
||||||
|
{
|
||||||
|
return _teamRules
|
||||||
|
.Where(rule => rule.AppliesTo(context))
|
||||||
|
.Select(rule => rule.Validate(team, _config))
|
||||||
|
.Where(warning => warning != null)
|
||||||
|
.Cast<ValidationWarning>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate student event assignment statistics
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stats">Student statistics to validate</param>
|
||||||
|
/// <param name="context">Validation context</param>
|
||||||
|
/// <returns>List of validation warnings</returns>
|
||||||
|
public List<ValidationWarning> ValidateStudentStatistics(StudentEventStatistics stats, ValidationContext context)
|
||||||
|
{
|
||||||
|
return _statisticsRules
|
||||||
|
.Where(rule => rule.AppliesTo(context))
|
||||||
|
.Select(rule => rule.Validate(stats, _config))
|
||||||
|
.Where(warning => warning != null)
|
||||||
|
.Cast<ValidationWarning>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate all students in a collection
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="students">Students to validate</param>
|
||||||
|
/// <param name="context">Validation context</param>
|
||||||
|
/// <returns>Dictionary mapping each student to their warnings</returns>
|
||||||
|
public Dictionary<Student, List<ValidationWarning>> ValidateStudents(
|
||||||
|
IEnumerable<Student> students,
|
||||||
|
ValidationContext context)
|
||||||
|
{
|
||||||
|
return students.ToDictionary(
|
||||||
|
student => student,
|
||||||
|
student => ValidateStudentRankings(student, context)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate all teams in a collection
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">Teams to validate</param>
|
||||||
|
/// <returns>Dictionary mapping each team to their warnings</returns>
|
||||||
|
public Dictionary<Team, List<ValidationWarning>> ValidateTeams(IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
return teams.ToDictionary(
|
||||||
|
team => team,
|
||||||
|
team => ValidateTeam(team)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level for validation warnings
|
||||||
|
/// </summary>
|
||||||
|
public enum ValidationSeverity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Warning that should be addressed but doesn't prevent operation
|
||||||
|
/// </summary>
|
||||||
|
Warning = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error that indicates a critical issue
|
||||||
|
/// </summary>
|
||||||
|
Error = 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a validation warning or error for a student, team, or assignment
|
||||||
|
/// </summary>
|
||||||
|
public class ValidationWarning
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable warning message
|
||||||
|
/// </summary>
|
||||||
|
public required string Message { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique code identifying the validation rule (e.g., "NO_REGIONAL_EVENT")
|
||||||
|
/// </summary>
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level of the warning
|
||||||
|
/// </summary>
|
||||||
|
public required ValidationSeverity Severity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Context in which this warning applies
|
||||||
|
/// </summary>
|
||||||
|
public required ValidationContext Context { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icon identifier for UI display (maps to AppIcons constants in WebApp)
|
||||||
|
/// </summary>
|
||||||
|
public string? IconIdentifier { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Additional contextual information about the warning
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, object> Metadata { get; init; } = new();
|
||||||
|
}
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
# Docker Deployment Guide
|
||||||
|
|
||||||
|
## Authentication Configuration for Production
|
||||||
|
|
||||||
|
The application supports two methods for configuring authentication credentials in Docker:
|
||||||
|
|
||||||
|
### Option 1: Volume-Mounted JSON File (Recommended)
|
||||||
|
|
||||||
|
This approach allows you to edit credentials without rebuilding the container.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. **Generate Password Hashes** (on your development machine):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the app locally and navigate to:
|
||||||
|
https://localhost:<port>/dev/hash-password?password=YourPassword
|
||||||
|
```
|
||||||
|
2. **Create `auth-secrets.json`** on your Docker host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp auth-secrets.example.json auth-secrets.json
|
||||||
|
```
|
||||||
|
3. **Edit `auth-secrets.json`** and replace the placeholder hashes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Authentication": {
|
||||||
|
"Users": [
|
||||||
|
{
|
||||||
|
"Email": "admin@example.com",
|
||||||
|
"PasswordHash": "$2a$11$actual.hash.here",
|
||||||
|
"Role": "Administrator",
|
||||||
|
"DisplayName": "Administrator"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. **Mount the file in Docker Compose**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
|
||||||
|
```
|
||||||
|
5. **Update credentials**: Simply edit `auth-secrets.json` on the host and restart the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose restart webapp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Note**: Set proper file permissions on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 auth-secrets.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 2: Environment Variables
|
||||||
|
|
||||||
|
This approach is useful for container orchestration platforms (Kubernetes, Docker Swarm, etc.).
|
||||||
|
|
||||||
|
**Docker Compose Example**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- TSA_Authentication__Users__0__Email=admin@example.com
|
||||||
|
- TSA_Authentication__Users__0__PasswordHash=$2a$11$hash...
|
||||||
|
- TSA_Authentication__Users__0__Role=Administrator
|
||||||
|
- TSA_Authentication__Users__0__DisplayName=Administrator
|
||||||
|
- TSA_Authentication__Users__1__Email=advisor@example.com
|
||||||
|
- TSA_Authentication__Users__1__PasswordHash=$2a$11$hash...
|
||||||
|
- TSA_Authentication__Users__1__Role=Advisor
|
||||||
|
- TSA_Authentication__Users__1__DisplayName=Chapter Advisor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker Run Example**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
-e TSA_Authentication__Users__0__Email=admin@example.com \
|
||||||
|
-e TSA_Authentication__Users__0__PasswordHash='$2a$11$hash...' \
|
||||||
|
-e TSA_Authentication__Users__0__Role=Administrator \
|
||||||
|
-e TSA_Authentication__Users__0__DisplayName=Administrator \
|
||||||
|
tsa-chapter-organizer:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kubernetes Secret Example**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: tsa-auth-secrets
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
TSA_Authentication__Users__0__Email: "admin@example.com"
|
||||||
|
TSA_Authentication__Users__0__PasswordHash: "$2a$11$hash..."
|
||||||
|
TSA_Authentication__Users__0__Role: "Administrator"
|
||||||
|
TSA_Authentication__Users__0__DisplayName: "Administrator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Publish to Docker Registry (Recommended)
|
||||||
|
|
||||||
|
Use the provided PowerShell script to build and publish the Docker image:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\publish-docker.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-Tag "v1.0.0"` - Specify a custom tag (default: "latest")
|
||||||
|
- `-Registry "docker-registry.kolpacksoftware.com"` - Specify registry URL
|
||||||
|
- `-ImageName "tsa-chapter-organizer"` - Specify image name
|
||||||
|
- `-BuildConfiguration "Release"` - Specify build configuration
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```powershell
|
||||||
|
# Publish with default settings (latest tag)
|
||||||
|
.\publish-docker.ps1
|
||||||
|
|
||||||
|
# Publish with a version tag
|
||||||
|
.\publish-docker.ps1 -Tag "v1.0.0"
|
||||||
|
|
||||||
|
# Publish with custom registry
|
||||||
|
.\publish-docker.ps1 -Tag "latest" -Registry "my-registry.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the Docker Image (Manual)
|
||||||
|
|
||||||
|
If you prefer to build manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebApp
|
||||||
|
docker build -t tsa-chapter-organizer:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from the root directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f WebApp/Dockerfile -t tsa-chapter-organizer:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and customize the example
|
||||||
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
|
|
||||||
|
# Edit auth-secrets.json with your credentials
|
||||||
|
cp auth-secrets.example.json auth-secrets.json
|
||||||
|
# (Edit the file and replace hashes)
|
||||||
|
|
||||||
|
# Start the container
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f webapp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access the Application
|
||||||
|
|
||||||
|
- HTTP: `http://localhost:8080`
|
||||||
|
- HTTPS: `https://localhost:8081` (if configured)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Managing Users
|
||||||
|
|
||||||
|
### Adding a New User
|
||||||
|
|
||||||
|
**With Volume-Mounted File:**
|
||||||
|
|
||||||
|
1. Edit `auth-secrets.json` on the host
|
||||||
|
2. Add new user entry to the `Users` array
|
||||||
|
3. Restart the container: `docker-compose restart webapp`
|
||||||
|
|
||||||
|
**With Environment Variables:**
|
||||||
|
|
||||||
|
1. Add new environment variables (increment the index number)
|
||||||
|
2. Recreate the container: `docker-compose up -d`
|
||||||
|
|
||||||
|
### Changing a Password
|
||||||
|
|
||||||
|
1. Generate new hash using the dev endpoint (on local dev machine)
|
||||||
|
2. Update the `PasswordHash` value in your configuration
|
||||||
|
3. Restart/recreate the container
|
||||||
|
|
||||||
|
### Removing a User
|
||||||
|
|
||||||
|
1. Remove the user entry from your configuration
|
||||||
|
2. Restart/recreate the container
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **File Permissions**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 auth-secrets.json
|
||||||
|
chown root:root auth-secrets.json
|
||||||
|
```
|
||||||
|
2. **Never Commit Secrets**: Add to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
auth-secrets.json
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
3. **Use HTTPS in Production**: Configure SSL/TLS certificates
|
||||||
|
4. **Backup Credentials**: Store encrypted backups of `auth-secrets.json`
|
||||||
|
5. **Password Rotation**: Periodically regenerate password hashes
|
||||||
|
6. **Monitor Access**: Review application logs for failed login attempts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose logs webapp | grep "Failed login"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose logs webapp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can't Login
|
||||||
|
|
||||||
|
1. Verify `auth-secrets.json` is properly mounted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec tsa-app ls -la /app/secrets/
|
||||||
|
```
|
||||||
|
2. Check if the file is being loaded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose logs webapp | grep "secrets"
|
||||||
|
```
|
||||||
|
3. Verify JSON syntax:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat auth-secrets.json | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forgot Admin Password
|
||||||
|
|
||||||
|
1. Generate a new password hash locally
|
||||||
|
2. Update `auth-secrets.json` on the host
|
||||||
|
3. Restart the container
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Complete Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Generate password hashes locally
|
||||||
|
# Navigate to: https://localhost:5001/dev/hash-password?password=MySecurePass123
|
||||||
|
|
||||||
|
# 2. Create secrets file
|
||||||
|
cat > auth-secrets.json <<EOF
|
||||||
|
{
|
||||||
|
"Authentication": {
|
||||||
|
"Users": [
|
||||||
|
{
|
||||||
|
"Email": "admin@myschool.edu",
|
||||||
|
"PasswordHash": "$2a$11$paste_hash_here",
|
||||||
|
"Role": "Administrator",
|
||||||
|
"DisplayName": "TSA Admin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 3. Set permissions
|
||||||
|
chmod 600 auth-secrets.json
|
||||||
|
|
||||||
|
# 4. Create docker-compose.yml
|
||||||
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
|
|
||||||
|
# 5. Start the application
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 6. Check it's running
|
||||||
|
docker-compose ps
|
||||||
|
curl http://localhost:8080
|
||||||
|
|
||||||
|
# 7. Login
|
||||||
|
# Navigate to http://localhost:8080/login
|
||||||
|
# Use: admin@myschool.edu / MySecurePass123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment Checklist
|
||||||
|
|
||||||
|
- [ ] Generated secure password hashes
|
||||||
|
- [ ] Created `auth-secrets.json` with production credentials
|
||||||
|
- [ ] Set file permissions to 600
|
||||||
|
- [ ] Configured HTTPS/SSL certificates
|
||||||
|
- [ ] Updated `ASPNETCORE_URLS` for production domain
|
||||||
|
- [ ] Configured volume for database persistence
|
||||||
|
- [ ] Removed development endpoints (already done in code)
|
||||||
|
- [ ] Set up log monitoring
|
||||||
|
- [ ] Configured automatic backups
|
||||||
|
- [ ] Tested login with all user roles
|
||||||
|
- [ ] Tested rate limiting (5 failed attempts)
|
||||||
|
- [ ] Documented admin password securely
|
||||||
|
- [ ] Added `auth-secrets.json` to `.gitignore`
|
||||||
+6
-61
@@ -1,7 +1,6 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
|
|
||||||
namespace Data
|
namespace Data
|
||||||
{
|
{
|
||||||
@@ -11,6 +10,11 @@ namespace Data
|
|||||||
public DbSet<Student> Students { get; set; }
|
public DbSet<Student> Students { get; set; }
|
||||||
public DbSet<Team> Teams { get; set; }
|
public DbSet<Team> Teams { get; set; }
|
||||||
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
||||||
|
public DbSet<EventOccurrence> EventOccurrences { get; set; }
|
||||||
|
public DbSet<Career> Careers { get; set; }
|
||||||
|
public DbSet<Note> Notes { get; set; }
|
||||||
|
public DbSet<NoteHistory> NoteHistories { get; set; }
|
||||||
|
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
|
||||||
|
|
||||||
public AppDbContext()
|
public AppDbContext()
|
||||||
{
|
{
|
||||||
@@ -24,64 +28,5 @@ namespace Data
|
|||||||
{
|
{
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
}
|
}
|
||||||
|
|
||||||
//protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
|
||||||
//{
|
|
||||||
// var dbPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ChapterOrganizer.db");
|
|
||||||
// optionsBuilder.UseSqlite($"Data Source={dbPath}");
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class EventDefinitionConfiguration : IEntityTypeConfiguration<EventDefinition>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<EventDefinition> builder)
|
|
||||||
{
|
|
||||||
builder.HasKey(u => u.Id);
|
|
||||||
|
|
||||||
builder.HasIndex(u => u.Name);
|
|
||||||
builder.Property(u => u.Name).HasMaxLength(128);
|
|
||||||
|
|
||||||
//builder.HasMany(u => u.Roles)
|
|
||||||
// .WithOne()
|
|
||||||
// .HasForeignKey(r => r.Id)
|
|
||||||
// .OnDelete(DeleteBehavior.Restrict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class StudentConfiguration : IEntityTypeConfiguration<Student>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Student> builder)
|
|
||||||
{
|
|
||||||
builder.HasKey(u => u.Id);
|
|
||||||
//builder.Property(s => s.Grade);
|
|
||||||
|
|
||||||
builder
|
|
||||||
.HasMany(e => e.RankedEvents)
|
|
||||||
.WithMany()
|
|
||||||
.UsingEntity<StudentEventRanking>()
|
|
||||||
.HasOne<EventDefinition>(e => e.EventDefinition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TeamConfiguration : IEntityTypeConfiguration<Team>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Team> builder)
|
|
||||||
{
|
|
||||||
builder.HasKey(u => u.Id);
|
|
||||||
|
|
||||||
builder.HasMany(e => e.Students)
|
|
||||||
.WithMany(e => e.Teams);
|
|
||||||
|
|
||||||
builder.HasOne(e => e.Captain);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public class StudentEventRankingConfiguration : IEntityTypeConfiguration<StudentEventRanking>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<StudentEventRanking> builder)
|
|
||||||
{
|
|
||||||
//builder.HasKey(u => u.EventDefinitionId);
|
|
||||||
//builder.HasKey(u => u.StudentId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class CareerConfiguration : IEntityTypeConfiguration<Career>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Career> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(c => c.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(c => c.Name).IsUnique();
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(c => c.Name)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class EventDefinitionConfiguration : IEntityTypeConfiguration<EventDefinition>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<EventDefinition> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(e => e.Name).IsUnique();
|
||||||
|
builder.HasIndex(e => e.EventFormat);
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(e => e.Name)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128);
|
||||||
|
|
||||||
|
builder.Property(e => e.ShortName)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
builder.Property(e => e.Description)
|
||||||
|
.HasMaxLength(1000);
|
||||||
|
|
||||||
|
builder.Property(e => e.Theme)
|
||||||
|
.HasMaxLength(500);
|
||||||
|
|
||||||
|
builder.Property(e => e.Eligibility)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(e => e.SemifinalistActivity)
|
||||||
|
.HasMaxLength(500);
|
||||||
|
|
||||||
|
builder.Property(e => e.Documentation)
|
||||||
|
.HasMaxLength(500);
|
||||||
|
|
||||||
|
// Value conversions for enums
|
||||||
|
builder.Property(e => e.EventFormat)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
// Ignore RelatedCareersText (not mapped to database)
|
||||||
|
builder.Ignore(e => e.RelatedCareersText);
|
||||||
|
|
||||||
|
// Many-to-many relationship with Career
|
||||||
|
builder.HasMany(e => e.RelatedCareers)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity(j => j.ToTable("EventDefinitionCareers"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class EventOccurrenceConfiguration : IEntityTypeConfiguration<EventOccurrence>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<EventOccurrence> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(e => e.StartTime);
|
||||||
|
builder.HasIndex(e => e.EventDefinitionId);
|
||||||
|
builder.HasIndex(e => e.SpecialEventType);
|
||||||
|
|
||||||
|
// Foreign key relationship (optional)
|
||||||
|
builder.HasOne(e => e.EventDefinition)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.EventDefinitionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict); // Don't cascade delete if EventDefinition is deleted
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(e => e.Name)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(e => e.Time)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100);
|
||||||
|
|
||||||
|
builder.Property(e => e.Date)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100);
|
||||||
|
|
||||||
|
builder.Property(e => e.Location)
|
||||||
|
.HasMaxLength(500);
|
||||||
|
|
||||||
|
builder.Property(e => e.SpecialEventType)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
// Validation: Either EventDefinitionId OR SpecialEventType must be set (enforced at application level)
|
||||||
|
// EF Core doesn't support mutually exclusive constraints directly, so we'll handle this in validation attributes or service layer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class NoteConfiguration : IEntityTypeConfiguration<Note>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Note> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(n => n.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(n => n.Title);
|
||||||
|
builder.HasIndex(n => n.CreatedAt);
|
||||||
|
builder.HasIndex(n => n.IsPinned);
|
||||||
|
builder.HasIndex(n => n.IsDeleted);
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(n => n.Title)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(n => n.Content)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
builder.Property(n => n.CreatedBy)
|
||||||
|
.HasMaxLength(255);
|
||||||
|
|
||||||
|
builder.Property(n => n.LastModifiedBy)
|
||||||
|
.HasMaxLength(255);
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
builder.HasMany(n => n.NoteHistories)
|
||||||
|
.WithOne(h => h.Note)
|
||||||
|
.HasForeignKey(h => h.NoteId)
|
||||||
|
.IsRequired()
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class NoteHistoryConfiguration : IEntityTypeConfiguration<NoteHistory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<NoteHistory> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(h => h.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(h => h.NoteId);
|
||||||
|
builder.HasIndex(h => h.ModifiedAt);
|
||||||
|
builder.HasIndex(h => new { h.NoteId, h.ModifiedAt });
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(h => h.Title)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(h => h.Content)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
builder.Property(h => h.ModifiedBy)
|
||||||
|
.HasMaxLength(255);
|
||||||
|
|
||||||
|
builder.Property(h => h.ChangeType)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
// Relationship is configured in NoteConfiguration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class StudentConfiguration : IEntityTypeConfiguration<Student>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Student> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(s => s.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(s => new { s.FirstName, s.LastName });
|
||||||
|
builder.HasIndex(s => s.Email);
|
||||||
|
builder.HasIndex(s => s.Grade);
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(s => s.FirstName)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100);
|
||||||
|
|
||||||
|
builder.Property(s => s.LastName)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100);
|
||||||
|
|
||||||
|
builder.Property(s => s.Email)
|
||||||
|
.HasMaxLength(255);
|
||||||
|
|
||||||
|
builder.Property(s => s.PhoneNumber)
|
||||||
|
.HasMaxLength(20);
|
||||||
|
|
||||||
|
builder.Property(s => s.RegionalId)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
builder.Property(s => s.StateId)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
builder.Property(s => s.NationalId)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
// Value conversion for enum
|
||||||
|
builder.Property(s => s.OfficerRole)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
// Configure the collection navigation to the join entity
|
||||||
|
builder.HasMany(s => s.EventRankings)
|
||||||
|
.WithOne(r => r.Student)
|
||||||
|
.IsRequired()
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Many-to-many through StudentEventRanking
|
||||||
|
builder.HasMany(s => s.RankedEvents)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity<StudentEventRanking>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class StudentEventRankingConfiguration : IEntityTypeConfiguration<StudentEventRanking>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<StudentEventRanking> builder)
|
||||||
|
{
|
||||||
|
// Note: Relationships are configured in StudentConfiguration
|
||||||
|
// This configuration only defines keys, indexes, and constraints
|
||||||
|
|
||||||
|
// Composite key on shadow properties created by EF Core
|
||||||
|
builder.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
// Indexes on shadow properties
|
||||||
|
builder.HasIndex(r => r.Rank);
|
||||||
|
builder.HasIndex("StudentId");
|
||||||
|
builder.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(r => r.Rank)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
// Relationship to EventDefinition (Student relationship is in StudentConfiguration)
|
||||||
|
builder.HasOne(r => r.EventDefinition)
|
||||||
|
.WithMany()
|
||||||
|
.IsRequired()
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class TeamConfiguration : IEntityTypeConfiguration<Team>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Team> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(t => t.Id);
|
||||||
|
|
||||||
|
// Indexes on shadow properties created by EF Core
|
||||||
|
builder.HasIndex("EventId");
|
||||||
|
builder.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(t => t.Identifier)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
builder.HasOne(t => t.Event)
|
||||||
|
.WithMany()
|
||||||
|
.IsRequired()
|
||||||
|
.OnDelete(DeleteBehavior.Restrict); // Don't delete teams when event is deleted
|
||||||
|
|
||||||
|
builder.HasMany(t => t.Students)
|
||||||
|
.WithMany(s => s.Teams)
|
||||||
|
.UsingEntity(j => j.ToTable("TeamStudents")); // Explicit table name
|
||||||
|
|
||||||
|
builder.HasOne(t => t.Captain)
|
||||||
|
.WithMany()
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull); // Set to null if captain is deleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class TeamMeetingHistoryConfiguration : IEntityTypeConfiguration<TeamMeetingHistory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TeamMeetingHistory> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(tmh => tmh.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(tmh => tmh.MeetingDate);
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(tmh => tmh.MeetingDate)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.HasMany(tmh => tmh.Teams)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity(j => j.ToTable("TeamMeetingHistoryTeams"));
|
||||||
|
|
||||||
|
builder.HasMany(tmh => tmh.Students)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity(j => j.ToTable("TeamMeetingHistoryStudents"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20251204130505_ConfigurationRefactor")]
|
||||||
|
partial class ConfigurationRefactor
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ConfigurationRefactor : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_StudentTeam_Students_StudentsId",
|
||||||
|
table: "StudentTeam");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_StudentTeam_Teams_TeamsId",
|
||||||
|
table: "StudentTeam");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Teams_Events_EventId",
|
||||||
|
table: "Teams");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Teams_Students_CaptainId",
|
||||||
|
table: "Teams");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_StudentEventRanking",
|
||||||
|
table: "StudentEventRanking");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Events_Name",
|
||||||
|
table: "Events");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_StudentTeam",
|
||||||
|
table: "StudentTeam");
|
||||||
|
|
||||||
|
migrationBuilder.RenameTable(
|
||||||
|
name: "StudentTeam",
|
||||||
|
newName: "TeamStudents");
|
||||||
|
|
||||||
|
migrationBuilder.RenameIndex(
|
||||||
|
name: "IX_StudentTeam_TeamsId",
|
||||||
|
table: "TeamStudents",
|
||||||
|
newName: "IX_TeamStudents_TeamsId");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OfficerRole",
|
||||||
|
table: "Students",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 50,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "INTEGER",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "EventFormat",
|
||||||
|
table: "Events",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 50,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "INTEGER");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_StudentEventRanking",
|
||||||
|
table: "StudentEventRanking",
|
||||||
|
columns: new[] { "StudentId", "EventDefinitionId" });
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_TeamStudents",
|
||||||
|
table: "TeamStudents",
|
||||||
|
columns: new[] { "StudentsId", "TeamsId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Teams_EventId_Identifier",
|
||||||
|
table: "Teams",
|
||||||
|
columns: new[] { "EventId", "Identifier" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Students_Email",
|
||||||
|
table: "Students",
|
||||||
|
column: "Email");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Students_FirstName_LastName",
|
||||||
|
table: "Students",
|
||||||
|
columns: new[] { "FirstName", "LastName" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Students_Grade",
|
||||||
|
table: "Students",
|
||||||
|
column: "Grade");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_StudentEventRanking_EventDefinitionId",
|
||||||
|
table: "StudentEventRanking",
|
||||||
|
column: "EventDefinitionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_StudentEventRanking_Rank",
|
||||||
|
table: "StudentEventRanking",
|
||||||
|
column: "Rank");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Events_EventFormat",
|
||||||
|
table: "Events",
|
||||||
|
column: "EventFormat");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Events_Name",
|
||||||
|
table: "Events",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Teams_Events_EventId",
|
||||||
|
table: "Teams",
|
||||||
|
column: "EventId",
|
||||||
|
principalTable: "Events",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Teams_Students_CaptainId",
|
||||||
|
table: "Teams",
|
||||||
|
column: "CaptainId",
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_TeamStudents_Students_StudentsId",
|
||||||
|
table: "TeamStudents",
|
||||||
|
column: "StudentsId",
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_TeamStudents_Teams_TeamsId",
|
||||||
|
table: "TeamStudents",
|
||||||
|
column: "TeamsId",
|
||||||
|
principalTable: "Teams",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Teams_Events_EventId",
|
||||||
|
table: "Teams");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Teams_Students_CaptainId",
|
||||||
|
table: "Teams");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_TeamStudents_Students_StudentsId",
|
||||||
|
table: "TeamStudents");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_TeamStudents_Teams_TeamsId",
|
||||||
|
table: "TeamStudents");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Teams_EventId_Identifier",
|
||||||
|
table: "Teams");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Students_Email",
|
||||||
|
table: "Students");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Students_FirstName_LastName",
|
||||||
|
table: "Students");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Students_Grade",
|
||||||
|
table: "Students");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_StudentEventRanking",
|
||||||
|
table: "StudentEventRanking");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_StudentEventRanking_EventDefinitionId",
|
||||||
|
table: "StudentEventRanking");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_StudentEventRanking_Rank",
|
||||||
|
table: "StudentEventRanking");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Events_EventFormat",
|
||||||
|
table: "Events");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Events_Name",
|
||||||
|
table: "Events");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_TeamStudents",
|
||||||
|
table: "TeamStudents");
|
||||||
|
|
||||||
|
migrationBuilder.RenameTable(
|
||||||
|
name: "TeamStudents",
|
||||||
|
newName: "StudentTeam");
|
||||||
|
|
||||||
|
migrationBuilder.RenameIndex(
|
||||||
|
name: "IX_TeamStudents_TeamsId",
|
||||||
|
table: "StudentTeam",
|
||||||
|
newName: "IX_StudentTeam_TeamsId");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "OfficerRole",
|
||||||
|
table: "Students",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "TEXT",
|
||||||
|
oldMaxLength: 50,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "EventFormat",
|
||||||
|
table: "Events",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "TEXT",
|
||||||
|
oldMaxLength: 50);
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_StudentEventRanking",
|
||||||
|
table: "StudentEventRanking",
|
||||||
|
columns: new[] { "EventDefinitionId", "StudentId" });
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_StudentTeam",
|
||||||
|
table: "StudentTeam",
|
||||||
|
columns: new[] { "StudentsId", "TeamsId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Events_Name",
|
||||||
|
table: "Events",
|
||||||
|
column: "Name");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_StudentTeam_Students_StudentsId",
|
||||||
|
table: "StudentTeam",
|
||||||
|
column: "StudentsId",
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_StudentTeam_Teams_TeamsId",
|
||||||
|
table: "StudentTeam",
|
||||||
|
column: "TeamsId",
|
||||||
|
principalTable: "Teams",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Teams_Events_EventId",
|
||||||
|
table: "Teams",
|
||||||
|
column: "EventId",
|
||||||
|
principalTable: "Events",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Teams_Students_CaptainId",
|
||||||
|
table: "Teams",
|
||||||
|
column: "CaptainId",
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20251227205816_EventOccurrence")]
|
||||||
|
partial class EventOccurrence
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class EventOccurrence : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EventOccurrences",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
EventDefinitionId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
SpecialEventType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Time = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||||
|
Date = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||||
|
StartTime = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
EndTime = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
Location = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EventOccurrences", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EventOccurrences_Events_EventDefinitionId",
|
||||||
|
column: x => x.EventDefinitionId,
|
||||||
|
principalTable: "Events",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EventOccurrences_EventDefinitionId",
|
||||||
|
table: "EventOccurrences",
|
||||||
|
column: "EventDefinitionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EventOccurrences_SpecialEventType",
|
||||||
|
table: "EventOccurrences",
|
||||||
|
column: "SpecialEventType");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EventOccurrences_StartTime",
|
||||||
|
table: "EventOccurrences",
|
||||||
|
column: "StartTime");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EventOccurrences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20251228200039_RelatedCareers")]
|
||||||
|
partial class RelatedCareers
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelatedCareersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("EventDefinitionId", "RelatedCareersId");
|
||||||
|
|
||||||
|
b.HasIndex("RelatedCareersId");
|
||||||
|
|
||||||
|
b.ToTable("EventDefinitionCareers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Career", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Careers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Career", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RelatedCareersId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RelatedCareers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Careers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Careers", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EventDefinitionCareers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
EventDefinitionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
RelatedCareersId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EventDefinitionCareers", x => new { x.EventDefinitionId, x.RelatedCareersId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EventDefinitionCareers_Careers_RelatedCareersId",
|
||||||
|
column: x => x.RelatedCareersId,
|
||||||
|
principalTable: "Careers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EventDefinitionCareers_Events_EventDefinitionId",
|
||||||
|
column: x => x.EventDefinitionId,
|
||||||
|
principalTable: "Events",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Careers_Name",
|
||||||
|
table: "Careers",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EventDefinitionCareers_RelatedCareersId",
|
||||||
|
table: "EventDefinitionCareers",
|
||||||
|
column: "RelatedCareersId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EventDefinitionCareers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Careers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260116235231_AddNoteIsPinnedAndIsDeleted")]
|
||||||
|
partial class AddNoteIsPinnedAndIsDeleted
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelatedCareersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("EventDefinitionId", "RelatedCareersId");
|
||||||
|
|
||||||
|
b.HasIndex("RelatedCareersId");
|
||||||
|
|
||||||
|
b.ToTable("EventDefinitionCareers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Career", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Careers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
|
b.HasIndex("Title");
|
||||||
|
|
||||||
|
b.ToTable("Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChangeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ModifiedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("NoteId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModifiedAt");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId", "ModifiedAt");
|
||||||
|
|
||||||
|
b.ToTable("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Career", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RelatedCareersId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Note", "Note")
|
||||||
|
.WithMany("NoteHistories")
|
||||||
|
.HasForeignKey("NoteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNoteIsPinnedAndIsDeleted : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Content = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
|
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
|
IsPinned = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notes", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NoteHistories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
NoteId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Content = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
|
ModifiedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
ChangeType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NoteHistories", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_NoteHistories_Notes_NoteId",
|
||||||
|
column: x => x.NoteId,
|
||||||
|
principalTable: "Notes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NoteHistories_ModifiedAt",
|
||||||
|
table: "NoteHistories",
|
||||||
|
column: "ModifiedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NoteHistories_NoteId",
|
||||||
|
table: "NoteHistories",
|
||||||
|
column: "NoteId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NoteHistories_NoteId_ModifiedAt",
|
||||||
|
table: "NoteHistories",
|
||||||
|
columns: new[] { "NoteId", "ModifiedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_CreatedAt",
|
||||||
|
table: "Notes",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_IsDeleted",
|
||||||
|
table: "Notes",
|
||||||
|
column: "IsDeleted");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_IsPinned",
|
||||||
|
table: "Notes",
|
||||||
|
column: "IsPinned");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_Title",
|
||||||
|
table: "Notes",
|
||||||
|
column: "Title");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NoteHistories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260120024048_AddTeamMeetingHistory")]
|
||||||
|
partial class AddTeamMeetingHistory
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelatedCareersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("EventDefinitionId", "RelatedCareersId");
|
||||||
|
|
||||||
|
b.HasIndex("RelatedCareersId");
|
||||||
|
|
||||||
|
b.ToTable("EventDefinitionCareers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Career", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Careers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
|
b.HasIndex("Title");
|
||||||
|
|
||||||
|
b.ToTable("Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChangeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ModifiedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("NoteId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModifiedAt");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId", "ModifiedAt");
|
||||||
|
|
||||||
|
b.ToTable("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("MeetingDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MeetingDate");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("TeamMeetingHistoryId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryTeams", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Career", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RelatedCareersId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Note", "Note")
|
||||||
|
.WithMany("NoteHistories")
|
||||||
|
.HasForeignKey("NoteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTeamMeetingHistory : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
MeetingDate = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistories", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistoryStudents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
StudentsId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistoryStudents", x => new { x.StudentsId, x.TeamMeetingHistoryId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryStudents_Students_StudentsId",
|
||||||
|
column: x => x.StudentsId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryStudents_TeamMeetingHistories_TeamMeetingHistoryId",
|
||||||
|
column: x => x.TeamMeetingHistoryId,
|
||||||
|
principalTable: "TeamMeetingHistories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistoryTeams",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TeamsId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistoryTeams", x => new { x.TeamMeetingHistoryId, x.TeamsId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryTeams_TeamMeetingHistories_TeamMeetingHistoryId",
|
||||||
|
column: x => x.TeamMeetingHistoryId,
|
||||||
|
principalTable: "TeamMeetingHistories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryTeams_Teams_TeamsId",
|
||||||
|
column: x => x.TeamsId,
|
||||||
|
principalTable: "Teams",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistories_MeetingDate",
|
||||||
|
table: "TeamMeetingHistories",
|
||||||
|
column: "MeetingDate");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistoryStudents_TeamMeetingHistoryId",
|
||||||
|
table: "TeamMeetingHistoryStudents",
|
||||||
|
column: "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistoryTeams_TeamsId",
|
||||||
|
table: "TeamMeetingHistoryTeams",
|
||||||
|
column: "TeamsId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistoryStudents");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistoryTeams");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
using Data;
|
using Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -16,6 +17,40 @@ namespace Data.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelatedCareersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("EventDefinitionId", "RelatedCareersId");
|
||||||
|
|
||||||
|
b.HasIndex("RelatedCareersId");
|
||||||
|
|
||||||
|
b.ToTable("EventDefinitionCareers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Career", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Careers");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -29,20 +64,22 @@ namespace Data.Migrations
|
|||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Documentation")
|
b.Property<string>("Documentation")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Eligibility")
|
b.Property<string>("Eligibility")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("EventFormat")
|
b.Property<string>("EventFormat")
|
||||||
.HasColumnType("INTEGER");
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int?>("LevelOfEffort")
|
b.Property<int?>("LevelOfEffort")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
@@ -69,25 +106,164 @@ namespace Data.Migrations
|
|||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("SemifinalistActivity")
|
b.Property<string>("SemifinalistActivity")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ShortName")
|
b.Property<string>("ShortName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(40)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Theme")
|
b.Property<string>("Theme")
|
||||||
.HasMaxLength(4096)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Name");
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Events");
|
b.ToTable("Events");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
|
b.HasIndex("Title");
|
||||||
|
|
||||||
|
b.ToTable("Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChangeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ModifiedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("NoteId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModifiedAt");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId", "ModifiedAt");
|
||||||
|
|
||||||
|
b.ToTable("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.Student", b =>
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -95,11 +271,12 @@ namespace Data.Migrations
|
|||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("FirstName")
|
b.Property<string>("FirstName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("Grade")
|
b.Property<int>("Grade")
|
||||||
@@ -107,22 +284,27 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("LastName")
|
b.Property<string>("LastName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("NationalId")
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int?>("OfficerRole")
|
b.Property<string>("OfficerRole")
|
||||||
.HasColumnType("INTEGER");
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("PhoneNumber")
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("RegionalId")
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("StateId")
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("TsaYear")
|
b.Property<int>("TsaYear")
|
||||||
@@ -130,21 +312,31 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
b.ToTable("Students");
|
b.ToTable("Students");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("EventDefinitionId")
|
b.Property<int>("StudentId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("StudentId")
|
b.Property<int>("EventDefinitionId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("Rank")
|
b.Property<int>("Rank")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("EventDefinitionId", "StudentId");
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
b.HasIndex("StudentId");
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
@@ -164,7 +356,7 @@ namespace Data.Migrations
|
|||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Identifier")
|
b.Property<string>("Identifier")
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
@@ -173,9 +365,27 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("EventId");
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
b.ToTable("Teams");
|
b.ToTable("Teams");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("MeetingDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MeetingDate");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("StudentTeam", b =>
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("StudentsId")
|
b.Property<int>("StudentsId")
|
||||||
@@ -188,7 +398,73 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TeamsId");
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
b.ToTable("StudentTeam");
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("TeamMeetingHistoryId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryTeams", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Career", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RelatedCareersId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Note", "Note")
|
||||||
|
.WithMany("NoteHistories")
|
||||||
|
.HasForeignKey("NoteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Note");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
@@ -214,12 +490,13 @@ namespace Data.Migrations
|
|||||||
{
|
{
|
||||||
b.HasOne("Core.Entities.Student", "Captain")
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CaptainId");
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Core.Entities.EventDefinition", "Event")
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("EventId")
|
.HasForeignKey("EventId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Captain");
|
b.Navigation("Captain");
|
||||||
@@ -242,6 +519,41 @@ namespace Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.Student", b =>
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("EventRankings");
|
b.Navigation("EventRankings");
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
|
namespace Tests.Builders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fluent builder for creating AssignmentRequirement test entities.
|
||||||
|
/// </summary>
|
||||||
|
public class AssignmentRequirementBuilder
|
||||||
|
{
|
||||||
|
private EventDefinition? _eventDefinition = null;
|
||||||
|
private Student? _student = null;
|
||||||
|
private Requirement _requirement = Requirement.Include;
|
||||||
|
|
||||||
|
public AssignmentRequirementBuilder ForEvent(EventDefinition eventDef)
|
||||||
|
{
|
||||||
|
_eventDefinition = eventDef;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssignmentRequirementBuilder ForStudent(Student student)
|
||||||
|
{
|
||||||
|
_student = student;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssignmentRequirementBuilder AsInclude()
|
||||||
|
{
|
||||||
|
_requirement = Requirement.Include;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssignmentRequirementBuilder AsExclude()
|
||||||
|
{
|
||||||
|
_requirement = Requirement.Exclude;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssignmentRequirementBuilder WithRequirement(Requirement requirement)
|
||||||
|
{
|
||||||
|
_requirement = requirement;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssignmentRequirement Build()
|
||||||
|
{
|
||||||
|
if (_eventDefinition == null)
|
||||||
|
throw new InvalidOperationException("AssignmentRequirement must have an event. Call ForEvent() before Build().");
|
||||||
|
|
||||||
|
if (_student == null)
|
||||||
|
throw new InvalidOperationException("AssignmentRequirement must have a student. Call ForStudent() before Build().");
|
||||||
|
|
||||||
|
return new AssignmentRequirement(_eventDefinition, _student, _requirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static factory methods
|
||||||
|
public static AssignmentRequirementBuilder Default() => new AssignmentRequirementBuilder();
|
||||||
|
|
||||||
|
public static AssignmentRequirementBuilder Include(EventDefinition eventDef, Student student) =>
|
||||||
|
new AssignmentRequirementBuilder()
|
||||||
|
.ForEvent(eventDef)
|
||||||
|
.ForStudent(student)
|
||||||
|
.AsInclude();
|
||||||
|
|
||||||
|
public static AssignmentRequirementBuilder Exclude(EventDefinition eventDef, Student student) =>
|
||||||
|
new AssignmentRequirementBuilder()
|
||||||
|
.ForEvent(eventDef)
|
||||||
|
.ForStudent(student)
|
||||||
|
.AsExclude();
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Tests.Builders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper utilities for working with test data builders.
|
||||||
|
/// </summary>
|
||||||
|
public static class BuilderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resets all builder ID counters to 1.
|
||||||
|
/// Call this in test SetUp to ensure test isolation.
|
||||||
|
/// </summary>
|
||||||
|
public static void ResetAllBuilders()
|
||||||
|
{
|
||||||
|
EventDefinitionBuilder.ResetIdCounter();
|
||||||
|
StudentBuilder.ResetIdCounter();
|
||||||
|
TeamBuilder.ResetIdCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a student with event rankings for multiple events.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The student builder</param>
|
||||||
|
/// <param name="rankedEvents">Dictionary of events to their ranks (1-10)</param>
|
||||||
|
/// <returns>The builder for method chaining</returns>
|
||||||
|
public static StudentBuilder WithRankings(this StudentBuilder builder, Dictionary<EventDefinition, int> rankedEvents)
|
||||||
|
{
|
||||||
|
foreach (var (eventDef, rank) in rankedEvents)
|
||||||
|
{
|
||||||
|
builder.WithRanking(eventDef, rank);
|
||||||
|
}
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a team with a captain as the first student.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The team builder</param>
|
||||||
|
/// <param name="students">Students to add (first one becomes captain)</param>
|
||||||
|
/// <returns>The builder for method chaining</returns>
|
||||||
|
public static TeamBuilder WithStudentsAndCaptain(this TeamBuilder builder, params Student[] students)
|
||||||
|
{
|
||||||
|
if (students.Length == 0)
|
||||||
|
return builder;
|
||||||
|
|
||||||
|
builder.WithStudents(students);
|
||||||
|
builder.WithCaptain(students[0]);
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates multiple event definitions at once.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="names">Event names</param>
|
||||||
|
/// <returns>Array of individual event definitions</returns>
|
||||||
|
public static EventDefinition[] CreateIndividualEvents(params string[] names)
|
||||||
|
{
|
||||||
|
return names.Select(name => EventDefinitionBuilder.Individual(name).Build()).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates multiple students at once with sequential names.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="count">Number of students to create</param>
|
||||||
|
/// <param name="baseFirstName">Base first name (will be suffixed with numbers)</param>
|
||||||
|
/// <param name="baseLastName">Base last name</param>
|
||||||
|
/// <returns>Array of students</returns>
|
||||||
|
public static Student[] CreateStudents(int count, string baseFirstName = "Student", string baseLastName = "Test")
|
||||||
|
{
|
||||||
|
var students = new Student[count];
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
students[i] = StudentBuilder.Default()
|
||||||
|
.WithName($"{baseFirstName}{i + 1}", baseLastName)
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
return students;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Tests.Builders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fluent builder for creating EventDefinition test entities.
|
||||||
|
/// </summary>
|
||||||
|
public class EventDefinitionBuilder
|
||||||
|
{
|
||||||
|
private static int _idCounter = 1;
|
||||||
|
|
||||||
|
private int _id = _idCounter++;
|
||||||
|
private string _name = "Test Event";
|
||||||
|
private string _shortName = "Test";
|
||||||
|
private EventFormat _format = EventFormat.Individual;
|
||||||
|
private int _minTeamSize = 1;
|
||||||
|
private int _maxTeamSize = 1;
|
||||||
|
private string? _semifinalistActivity = null;
|
||||||
|
private bool _onSiteActivity = false;
|
||||||
|
private int _regionalCount = 0;
|
||||||
|
private int _stateCount = 2;
|
||||||
|
private bool _presubmission = false;
|
||||||
|
private string _eligibility = "All students";
|
||||||
|
private string? _theme = null;
|
||||||
|
private string? _description = null;
|
||||||
|
private int? _levelOfEffort = null;
|
||||||
|
private string? _documentation = null;
|
||||||
|
private string? _notes = null;
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithName(string name)
|
||||||
|
{
|
||||||
|
_name = name;
|
||||||
|
if (_shortName == "Test") _shortName = name; // Auto-sync short name
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithShortName(string shortName)
|
||||||
|
{
|
||||||
|
_shortName = shortName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder AsTeamEvent(int minSize, int maxSize)
|
||||||
|
{
|
||||||
|
_format = EventFormat.Team;
|
||||||
|
_minTeamSize = minSize;
|
||||||
|
_maxTeamSize = maxSize;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder AsIndividualEvent()
|
||||||
|
{
|
||||||
|
_format = EventFormat.Individual;
|
||||||
|
_minTeamSize = 1;
|
||||||
|
_maxTeamSize = 1;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithInterview()
|
||||||
|
{
|
||||||
|
_semifinalistActivity = "Interview";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithPresentation()
|
||||||
|
{
|
||||||
|
_semifinalistActivity = "Presentation";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithSemifinalistActivity(string activity)
|
||||||
|
{
|
||||||
|
_semifinalistActivity = activity;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder AsOnSite()
|
||||||
|
{
|
||||||
|
_onSiteActivity = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder AsRegionalEvent(int count = 3)
|
||||||
|
{
|
||||||
|
_regionalCount = count;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithStateCount(int count)
|
||||||
|
{
|
||||||
|
_stateCount = count;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithPresubmission()
|
||||||
|
{
|
||||||
|
_presubmission = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithEligibility(string eligibility)
|
||||||
|
{
|
||||||
|
_eligibility = eligibility;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithTheme(string theme)
|
||||||
|
{
|
||||||
|
_theme = theme;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithDescription(string description)
|
||||||
|
{
|
||||||
|
_description = description;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithLevelOfEffort(int level)
|
||||||
|
{
|
||||||
|
_levelOfEffort = level;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithDocumentation(string documentation)
|
||||||
|
{
|
||||||
|
_documentation = documentation;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinitionBuilder WithNotes(string notes)
|
||||||
|
{
|
||||||
|
_notes = notes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventDefinition Build()
|
||||||
|
{
|
||||||
|
return new EventDefinition
|
||||||
|
{
|
||||||
|
Id = _id,
|
||||||
|
Name = _name,
|
||||||
|
ShortName = _shortName,
|
||||||
|
EventFormat = _format,
|
||||||
|
MinTeamSize = _minTeamSize,
|
||||||
|
MaxTeamSize = _maxTeamSize,
|
||||||
|
SemifinalistActivity = _semifinalistActivity,
|
||||||
|
OnSiteActivity = _onSiteActivity,
|
||||||
|
ChapterEligibilityCountRegionals = _regionalCount,
|
||||||
|
ChapterEligibilityCountState = _stateCount,
|
||||||
|
Presubmission = _presubmission,
|
||||||
|
Eligibility = _eligibility,
|
||||||
|
Theme = _theme,
|
||||||
|
Description = _description,
|
||||||
|
LevelOfEffort = _levelOfEffort,
|
||||||
|
Documentation = _documentation,
|
||||||
|
Notes = _notes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static factory methods
|
||||||
|
public static EventDefinitionBuilder Default() => new EventDefinitionBuilder();
|
||||||
|
|
||||||
|
public static EventDefinitionBuilder Individual(string name) =>
|
||||||
|
new EventDefinitionBuilder()
|
||||||
|
.WithName(name)
|
||||||
|
.AsIndividualEvent();
|
||||||
|
|
||||||
|
public static EventDefinitionBuilder Team(string name, int minSize, int maxSize) =>
|
||||||
|
new EventDefinitionBuilder()
|
||||||
|
.WithName(name)
|
||||||
|
.AsTeamEvent(minSize, maxSize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the ID counter for test isolation. Call this in test SetUp.
|
||||||
|
/// </summary>
|
||||||
|
public static void ResetIdCounter() => _idCounter = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Tests.Builders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fluent builder for creating Student test entities.
|
||||||
|
/// </summary>
|
||||||
|
public class StudentBuilder
|
||||||
|
{
|
||||||
|
private static int _idCounter = 1;
|
||||||
|
|
||||||
|
private int _id = _idCounter++;
|
||||||
|
private string _firstName = "Test";
|
||||||
|
private string _lastName = "Student";
|
||||||
|
private int _grade = 9;
|
||||||
|
private string? _email = null;
|
||||||
|
private string? _phoneNumber = null;
|
||||||
|
private int _tsaYear = 1;
|
||||||
|
private string? _stateId = null;
|
||||||
|
private string? _regionalId = null;
|
||||||
|
private string? _nationalId = null;
|
||||||
|
private OfficerRole? _officerRole = null;
|
||||||
|
private List<(EventDefinition eventDef, int rank)> _rankings = new();
|
||||||
|
|
||||||
|
public StudentBuilder WithName(string firstName, string lastName)
|
||||||
|
{
|
||||||
|
_firstName = firstName;
|
||||||
|
_lastName = lastName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithFirstName(string firstName)
|
||||||
|
{
|
||||||
|
_firstName = firstName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithLastName(string lastName)
|
||||||
|
{
|
||||||
|
_lastName = lastName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithGrade(int grade)
|
||||||
|
{
|
||||||
|
_grade = grade;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithEmail(string email)
|
||||||
|
{
|
||||||
|
_email = email;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithPhone(string phoneNumber)
|
||||||
|
{
|
||||||
|
_phoneNumber = phoneNumber;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithTsaYear(int year)
|
||||||
|
{
|
||||||
|
_tsaYear = year;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithStateId(string stateId)
|
||||||
|
{
|
||||||
|
_stateId = stateId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithRegionalId(string regionalId)
|
||||||
|
{
|
||||||
|
_regionalId = regionalId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithNationalId(string nationalId)
|
||||||
|
{
|
||||||
|
_nationalId = nationalId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder AsOfficer(OfficerRole role)
|
||||||
|
{
|
||||||
|
_officerRole = role;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder AsPresident()
|
||||||
|
{
|
||||||
|
_officerRole = OfficerRole.President;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder AsVicePresident()
|
||||||
|
{
|
||||||
|
_officerRole = OfficerRole.VicePresident;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder AsSecretary()
|
||||||
|
{
|
||||||
|
_officerRole = OfficerRole.Secretary;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder AsTreasurer()
|
||||||
|
{
|
||||||
|
_officerRole = OfficerRole.Treasurer;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentBuilder WithRanking(EventDefinition eventDef, int rank)
|
||||||
|
{
|
||||||
|
_rankings.Add((eventDef, rank));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Student Build()
|
||||||
|
{
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
Id = _id,
|
||||||
|
FirstName = _firstName,
|
||||||
|
LastName = _lastName,
|
||||||
|
Grade = _grade,
|
||||||
|
Email = _email,
|
||||||
|
PhoneNumber = _phoneNumber,
|
||||||
|
TsaYear = _tsaYear,
|
||||||
|
StateId = _stateId,
|
||||||
|
RegionalId = _regionalId,
|
||||||
|
NationalId = _nationalId,
|
||||||
|
OfficerRole = _officerRole,
|
||||||
|
Teams = new List<Team>() // Initialize Teams collection
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up event rankings if any were specified
|
||||||
|
foreach (var (eventDef, rank) in _rankings)
|
||||||
|
{
|
||||||
|
var ranking = new StudentEventRanking
|
||||||
|
{
|
||||||
|
Student = student,
|
||||||
|
EventDefinition = eventDef,
|
||||||
|
Rank = rank
|
||||||
|
};
|
||||||
|
student.EventRankings.Add(ranking);
|
||||||
|
student.RankedEvents.Add(eventDef); // Also add to RankedEvents for validation rules
|
||||||
|
}
|
||||||
|
|
||||||
|
return student;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static factory methods
|
||||||
|
public static StudentBuilder Default() => new StudentBuilder();
|
||||||
|
|
||||||
|
public static StudentBuilder Create(string firstName, string lastName) =>
|
||||||
|
new StudentBuilder()
|
||||||
|
.WithName(firstName, lastName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the ID counter for test isolation. Call this in test SetUp.
|
||||||
|
/// </summary>
|
||||||
|
public static void ResetIdCounter() => _idCounter = 1;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user