Compare commits
27
Commits
ea21406309
...
826eac1372
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -23,3 +23,10 @@ _ReSharper*/
|
|||||||
|
|
||||||
DataBackup/
|
DataBackup/
|
||||||
*.db
|
*.db
|
||||||
|
/.claude/*
|
||||||
|
|
||||||
|
# Production secrets and configuration
|
||||||
|
auth-secrets.json
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.override.yml
|
||||||
|
/WebApp/logs/*
|
||||||
|
|||||||
+286
-233
@@ -1,150 +1,209 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
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
|
||||||
{
|
{
|
||||||
private readonly IList<EventDefinition> _events;
|
// 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<Student> _students;
|
private readonly IList<Student> _students;
|
||||||
private readonly AssignmentParameters _parameters;
|
private readonly AssignmentParameters _parameters;
|
||||||
private readonly int[] _allEvents;
|
private readonly double _maxSolveTimeSeconds;
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
_events = events;
|
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;
|
||||||
_students = students;
|
_students = students;
|
||||||
_parameters = parameters;
|
_parameters = parameters;
|
||||||
_allEvents = Enumerable.Range(0, _events.Count).ToArray();
|
_maxSolveTimeSeconds = maxSolveTimeSeconds;
|
||||||
|
_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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveEvents(IList<EventDefinition> events)
|
/// <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)
|
||||||
{
|
{
|
||||||
_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()
|
||||||
{
|
{
|
||||||
Debug.WriteLine(_parameters);
|
try
|
||||||
|
{
|
||||||
|
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}]");
|
||||||
|
|
||||||
AddAssignmentRequirements(model, x);
|
// Add all constraints
|
||||||
|
AddAssignmentRequirements(model, x);
|
||||||
|
var assignmentThresholdsList = AddEventAssignmentThresholds(model, x);
|
||||||
|
LimitStudentAssignment(model, x);
|
||||||
|
SetLevelOfEffort(model, x);
|
||||||
|
|
||||||
var assignmentThresholdsList = AddEventAssignmentThresholds(model, x);
|
if (_parameters.RequireOnSite)
|
||||||
|
RequireOnSiteActivity(model, x);
|
||||||
|
|
||||||
LimitStudentAssignment(model, x);
|
if (_parameters.RequireRegional)
|
||||||
|
RequireRegionalEvent(model, x);
|
||||||
|
|
||||||
// set the range for level of effort
|
AtMostOneIndividualEvent(model, x);
|
||||||
SetLevelOfEffort(model, x);
|
IndividualEventsMustBeRanked(model, x);
|
||||||
|
OptimizeStudentEventRankings(model, x);
|
||||||
// each student should be assigned at least one on site activity
|
|
||||||
if (_parameters.RequireOnSite)
|
|
||||||
RequireOnSiteActivity(model, x);
|
|
||||||
|
|
||||||
// students should have at least one regional event
|
|
||||||
if (_parameters.RequireRegional)
|
|
||||||
RequireRegionalEvent(model, x);
|
|
||||||
|
|
||||||
// students should have at maximum one individual event
|
// Configure and run solver
|
||||||
AtMostOneIndividualEvent(model, x);
|
Debug.WriteLine("Starting optimization");
|
||||||
|
var solver = new CpSolver();
|
||||||
|
solver.StringParameters = $"max_time_in_seconds:{_maxSolveTimeSeconds}";
|
||||||
|
var cpSolverStatus = await Task.Run(() => solver.Solve(model));
|
||||||
|
|
||||||
//EventHasInterestedStudent(model, x);
|
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
||||||
|
|
||||||
IndividualEventsMustBeRanked(model, x);
|
if (cpSolverStatus == CpSolverStatus.Infeasible)
|
||||||
|
{
|
||||||
|
Debug.WriteLine("Problem is infeasible - constraints cannot be satisfied");
|
||||||
|
}
|
||||||
|
|
||||||
OptimizeStudentEventRankings(model, x);
|
var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus);
|
||||||
|
|
||||||
|
var eventAssignmentSolution =
|
||||||
|
new EventAssignmentSolution
|
||||||
|
(
|
||||||
|
eventAssignmentsList.ToArray(),
|
||||||
|
cpSolverStatus.ToString(),
|
||||||
|
assignmentThresholdsList
|
||||||
|
);
|
||||||
|
|
||||||
Debug.WriteLine("Starting optimization");
|
return eventAssignmentSolution;
|
||||||
var solver = new CpSolver();
|
}
|
||||||
var cpSolverStatus = await Task.Run(() => solver.Solve(model));
|
catch (Exception ex)
|
||||||
|
{
|
||||||
// print solver status
|
Debug.WriteLine($"Error solving event assignment: {ex}");
|
||||||
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
throw;
|
||||||
|
}
|
||||||
var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus);
|
|
||||||
|
|
||||||
var eventAssignmentSolution =
|
|
||||||
new EventAssignmentSolution
|
|
||||||
(
|
|
||||||
eventAssignmentsList.ToArray(),
|
|
||||||
cpSolverStatus.ToString(),
|
|
||||||
assignmentThresholdsList
|
|
||||||
);
|
|
||||||
|
|
||||||
return eventAssignmentSolution;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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))
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var eventAssignments =
|
var eventAssignments =
|
||||||
from e in _allEvents
|
from e in _allEvents
|
||||||
let students =
|
let students =
|
||||||
from s in _allStudents
|
from s in _allStudents
|
||||||
where solver.BooleanValue(x[e, s])
|
where solver.BooleanValue(x[e, s])
|
||||||
select _students[s]
|
select _students[s]
|
||||||
where students.Any()
|
where students.Any()
|
||||||
select new Team
|
select new Team
|
||||||
{
|
{
|
||||||
Identifier = _events[e].Name,
|
Identifier = _events[e].Name,
|
||||||
Event = _events[e],
|
Event = _events[e],
|
||||||
Students = students.ToList()
|
Students = students.ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
var eventPickCoefficients = GetEventPickCoefficients(_events, _students[s].EventRankings);
|
var eventPickCoefficients = GetEventPickCoefficients(_events, _students[s].EventRankings);
|
||||||
@@ -157,58 +216,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)
|
||||||
|
{
|
||||||
|
var buffer = new List<ILiteral>();
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
var prohibitVar = new List<IntVar>(1);
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
var student = _students[s];
|
var student = _students[s];
|
||||||
@@ -216,15 +281,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)
|
||||||
|
{
|
||||||
|
prohibitVar.Clear();
|
||||||
|
prohibitVar.Add(x[e, s]);
|
||||||
model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0);
|
model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0);
|
||||||
prohibitVar.Clear();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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 +310,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 +322,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)
|
||||||
{
|
{
|
||||||
|
var studentCapacity = new List<IntVar>();
|
||||||
|
|
||||||
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,162 +339,146 @@ 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));
|
||||||
|
|
||||||
var teamDivs = eventPickCounts / (evtMinTeamSize * 1.25);
|
|
||||||
if (_includedEvents.Contains(evt))
|
|
||||||
teamDivs = 1;
|
|
||||||
|
|
||||||
//var teamsCount = (int)Math.Ceiling(teamDivs);
|
|
||||||
var teamCount = (int)Math.Round(teamDivs);
|
|
||||||
if (teamCount > evt.ChapterEligibilityCountState)
|
|
||||||
teamCount = evt.ChapterEligibilityCountState;
|
|
||||||
|
|
||||||
// limit to one team for group events
|
|
||||||
if (_parameters.LimitTeamsToOne
|
|
||||||
&& evt.EventFormat is EventFormat.Team
|
|
||||||
&& teamCount > 1
|
|
||||||
&& !_twoTeams.Contains(evt)
|
|
||||||
)
|
|
||||||
teamCount = 1;
|
|
||||||
|
|
||||||
if (_twoTeams.Contains(evt))
|
|
||||||
teamCount = 2;
|
|
||||||
|
|
||||||
if (evt.Name == "Tech Bowl")
|
|
||||||
teamCount = 1;
|
|
||||||
|
|
||||||
var eventCapacity = new List<IntVar>();
|
|
||||||
foreach (var s in _allStudents)
|
|
||||||
{
|
|
||||||
eventCapacity.Add(x[e, s]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_droppedEvents != null && _droppedEvents.Contains(evt))
|
|
||||||
teamCount = 0;
|
|
||||||
|
|
||||||
if (evt.EventFormat == EventFormat.Individual)
|
|
||||||
evtMinTeamSize = 0;
|
|
||||||
|
|
||||||
var lb = evtMinTeamSize * teamCount;
|
|
||||||
var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit * teamCount);
|
|
||||||
|
|
||||||
assignmentThresholdsList.Add(
|
|
||||||
new EventAssignmentThresholds
|
|
||||||
{
|
|
||||||
Event = evt,
|
|
||||||
TeamCount = teamCount,
|
|
||||||
LowerBound = evtMinTeamSize,
|
|
||||||
UpperBound = evtMaxTeamSize,
|
|
||||||
StudentRankingCount = eventPickCounts
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub);
|
|
||||||
|
|
||||||
Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}");
|
Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}");
|
||||||
|
|
||||||
model.Minimize(LinearExpr.Sum(eventCapacity));
|
|
||||||
eventCapacity.Clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignmentThresholdsList;
|
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);
|
||||||
|
|
||||||
|
if (_includedEvents.Contains(evt))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
var teamCount = (int)Math.Round(teamDivs);
|
||||||
|
if (teamCount > evt.ChapterEligibilityCountState)
|
||||||
|
teamCount = evt.ChapterEligibilityCountState;
|
||||||
|
|
||||||
|
// Limit to one team for group events
|
||||||
|
if (_parameters.LimitTeamsToOne
|
||||||
|
&& evt.EventFormat is EventFormat.Team
|
||||||
|
&& teamCount > 1
|
||||||
|
&& !_twoTeams.Contains(evt))
|
||||||
|
teamCount = 1;
|
||||||
|
|
||||||
|
if (_twoTeams.Contains(evt))
|
||||||
|
teamCount = 2;
|
||||||
|
|
||||||
|
if (_droppedEvents.Contains(evt))
|
||||||
|
teamCount = 0;
|
||||||
|
|
||||||
|
return teamCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
evtMinTeamSize = 0;
|
||||||
|
|
||||||
|
var lb = evtMinTeamSize * teamCount;
|
||||||
|
var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit * teamCount);
|
||||||
|
|
||||||
|
return (lb, ub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds capacity constraint for a specific event.
|
||||||
|
/// </summary>
|
||||||
|
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
||||||
|
{
|
||||||
|
var eventCapacity = new List<IntVar>();
|
||||||
|
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,
|
||||||
|
TeamCount = teamCount,
|
||||||
|
LowerBound = evt.MinTeamSize,
|
||||||
|
UpperBound = evt.MaxTeamSize,
|
||||||
|
StudentRankingCount = _eventPickCounts[eventIndex]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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))
|
var prohibitVar = new List<IntVar>(1);
|
||||||
|
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
|
: StudentEventRanking.MaxRank - eventRank.Rank; // Inverse ranking
|
||||||
// TODO: MaxRank can be calculated
|
|
||||||
: 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)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the teams scheduled in this time slot.
|
||||||
|
/// </summary>
|
||||||
|
public Team[] Teams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the students who are not scheduled in any team during this time slot.
|
||||||
|
/// </summary>
|
||||||
|
public Student[] UnscheduledStudents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the students who have overlapping team meetings in this time slot.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<(Student student, IEnumerable<Team> teams)> StudentOverlaps;
|
||||||
|
|
||||||
|
/// <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,110 @@ 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
|
||||||
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] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 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 +115,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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,50 +2,88 @@
|
|||||||
|
|
||||||
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
|
return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
||||||
student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -62,4 +62,17 @@ public class Team
|
|||||||
{
|
{
|
||||||
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string StudentsFirstNames
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return
|
||||||
|
string.Join(", ",
|
||||||
|
Students.Select(e =>
|
||||||
|
e.FirstName
|
||||||
|
+ (Captain != null && (Captain.Equals(e)) ? "(Cpt)" : ""))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
### Build the Docker Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebApp
|
||||||
|
docker build -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`
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -24,64 +23,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,47 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,20 +29,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,21 +71,24 @@ 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");
|
||||||
});
|
});
|
||||||
@@ -95,11 +100,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 +113,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 +141,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 +185,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,6 +194,8 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("EventId");
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
b.ToTable("Teams");
|
b.ToTable("Teams");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +211,7 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TeamsId");
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
b.ToTable("StudentTeam");
|
b.ToTable("TeamStudents", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
@@ -214,12 +237,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");
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
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,166 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Tests.Builders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fluent builder for creating StudentEventRanking test entities.
|
||||||
|
/// </summary>
|
||||||
|
public class StudentEventRankingBuilder
|
||||||
|
{
|
||||||
|
private Student? _student = null;
|
||||||
|
private EventDefinition? _eventDefinition = null;
|
||||||
|
private int _rank = 1;
|
||||||
|
|
||||||
|
public StudentEventRankingBuilder ForStudent(Student student)
|
||||||
|
{
|
||||||
|
_student = student;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentEventRankingBuilder ForEvent(EventDefinition eventDef)
|
||||||
|
{
|
||||||
|
_eventDefinition = eventDef;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentEventRankingBuilder WithRank(int rank)
|
||||||
|
{
|
||||||
|
if (rank < 1 || rank > StudentEventRanking.MaxRank)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(rank),
|
||||||
|
$"Rank must be between 1 and {StudentEventRanking.MaxRank}");
|
||||||
|
|
||||||
|
_rank = rank;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentEventRanking Build()
|
||||||
|
{
|
||||||
|
if (_student == null)
|
||||||
|
throw new InvalidOperationException("StudentEventRanking must have a student. Call ForStudent() before Build().");
|
||||||
|
|
||||||
|
if (_eventDefinition == null)
|
||||||
|
throw new InvalidOperationException("StudentEventRanking must have an event. Call ForEvent() before Build().");
|
||||||
|
|
||||||
|
var ranking = new StudentEventRanking
|
||||||
|
{
|
||||||
|
Student = _student,
|
||||||
|
EventDefinition = _eventDefinition,
|
||||||
|
Rank = _rank
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up bidirectional relationship: add to student's EventRankings if not already there
|
||||||
|
if (!_student.EventRankings.Any(er => er.EventDefinition == _eventDefinition))
|
||||||
|
{
|
||||||
|
_student.EventRankings.Add(ranking);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranking;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static factory methods
|
||||||
|
public static StudentEventRankingBuilder Default() => new StudentEventRankingBuilder();
|
||||||
|
|
||||||
|
public static StudentEventRankingBuilder Create(Student student, EventDefinition eventDef) =>
|
||||||
|
new StudentEventRankingBuilder().ForStudent(student).ForEvent(eventDef);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Tests.Builders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fluent builder for creating Team test entities.
|
||||||
|
/// </summary>
|
||||||
|
public class TeamBuilder
|
||||||
|
{
|
||||||
|
private static int _idCounter = 1;
|
||||||
|
|
||||||
|
private int _id = _idCounter++;
|
||||||
|
private EventDefinition? _event = null;
|
||||||
|
private List<Student> _students = new();
|
||||||
|
private Student? _captain = null;
|
||||||
|
private string? _identifier = null;
|
||||||
|
|
||||||
|
public TeamBuilder ForEvent(EventDefinition eventDef)
|
||||||
|
{
|
||||||
|
_event = eventDef;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TeamBuilder WithStudent(Student student)
|
||||||
|
{
|
||||||
|
if (!_students.Contains(student))
|
||||||
|
_students.Add(student);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TeamBuilder WithStudents(params Student[] students)
|
||||||
|
{
|
||||||
|
foreach (var student in students)
|
||||||
|
{
|
||||||
|
if (!_students.Contains(student))
|
||||||
|
_students.Add(student);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TeamBuilder WithStudents(IEnumerable<Student> students)
|
||||||
|
{
|
||||||
|
foreach (var student in students)
|
||||||
|
{
|
||||||
|
if (!_students.Contains(student))
|
||||||
|
_students.Add(student);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TeamBuilder WithCaptain(Student captain)
|
||||||
|
{
|
||||||
|
_captain = captain;
|
||||||
|
// Ensure captain is in the students list
|
||||||
|
if (!_students.Contains(captain))
|
||||||
|
_students.Add(captain);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TeamBuilder WithIdentifier(string identifier)
|
||||||
|
{
|
||||||
|
_identifier = identifier;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Team Build()
|
||||||
|
{
|
||||||
|
if (_event == null)
|
||||||
|
throw new InvalidOperationException("Team must have an event. Call ForEvent() before Build().");
|
||||||
|
|
||||||
|
var team = new Team
|
||||||
|
{
|
||||||
|
Id = _id,
|
||||||
|
Event = _event,
|
||||||
|
Students = _students.ToList(),
|
||||||
|
Captain = _captain,
|
||||||
|
Identifier = _identifier
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up bidirectional relationship: add team to each student's Teams collection
|
||||||
|
foreach (var student in _students)
|
||||||
|
{
|
||||||
|
if (!student.Teams.Contains(team))
|
||||||
|
student.Teams.Add(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static factory methods
|
||||||
|
public static TeamBuilder Default() => new TeamBuilder();
|
||||||
|
|
||||||
|
public static TeamBuilder Create(EventDefinition eventDef) =>
|
||||||
|
new TeamBuilder().ForEvent(eventDef);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the ID counter for test isolation. Call this in test SetUp.
|
||||||
|
/// </summary>
|
||||||
|
public static void ResetIdCounter() => _idCounter = 1;
|
||||||
|
}
|
||||||
@@ -1,19 +1,58 @@
|
|||||||
using Core.Calculation;
|
using Core.Calculation;
|
||||||
using Tests.Parsers;
|
using Core.Entities;
|
||||||
|
using Tests.Builders;
|
||||||
|
using Tests.Fixtures;
|
||||||
|
|
||||||
namespace Tests.Calculation;
|
namespace Tests.Calculation;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class DataProcessingTests
|
public class DataProcessingTests
|
||||||
{
|
{
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GetEventStudentRankingsTest()
|
public void GetEventStudentRankingsTest()
|
||||||
{
|
{
|
||||||
var events = TestEntityHandler.GetEvents();
|
// Create test data with explicit rankings
|
||||||
var students = TestEntityHandler.GetStudents(events);
|
var flight = EventDefinitionBuilder.Individual("Flight").Build();
|
||||||
var rankings = TestEntityHandler.GetStudentEventRankings(students, events);
|
var coding = EventDefinitionBuilder.Individual("Coding").Build();
|
||||||
|
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).Build();
|
||||||
|
|
||||||
foreach (var ranking in rankings)
|
var alice = StudentBuilder.Default()
|
||||||
|
.WithName("Alice", "Anderson")
|
||||||
|
.WithGrade(9)
|
||||||
|
.WithRanking(flight, rank: 1)
|
||||||
|
.WithRanking(coding, rank: 2)
|
||||||
|
.WithRanking(robotics, rank: 3)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var bob = StudentBuilder.Default()
|
||||||
|
.WithName("Bob", "Brown")
|
||||||
|
.WithGrade(10)
|
||||||
|
.WithRanking(coding, rank: 1)
|
||||||
|
.WithRanking(flight, rank: 3)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Verify rankings are accessible
|
||||||
|
Assert.That(alice.EventRankings, Has.Count.EqualTo(3));
|
||||||
|
Assert.That(bob.EventRankings, Has.Count.EqualTo(2));
|
||||||
|
|
||||||
|
// Verify rankings are correctly associated
|
||||||
|
var aliceFlightRanking = alice.EventRankings.First(r => r.EventDefinition == flight);
|
||||||
|
Assert.That(aliceFlightRanking.Rank, Is.EqualTo(1));
|
||||||
|
Assert.That(aliceFlightRanking.Student, Is.EqualTo(alice));
|
||||||
|
|
||||||
|
// Print rankings for verification (matching original test output)
|
||||||
|
var allRankings = new[] { alice, bob }
|
||||||
|
.SelectMany(s => s.EventRankings)
|
||||||
|
.OrderBy(r => r.EventDefinition.Name)
|
||||||
|
.ThenBy(r => r.Student.FirstName);
|
||||||
|
|
||||||
|
foreach (var ranking in allRankings)
|
||||||
{
|
{
|
||||||
Console.WriteLine(ranking.EventDefinition.Name);
|
Console.WriteLine(ranking.EventDefinition.Name);
|
||||||
Console.WriteLine($"{ranking.Student.FirstName}: {ranking.Rank}");
|
Console.WriteLine($"{ranking.Student.FirstName}: {ranking.Rank}");
|
||||||
|
|||||||
@@ -1,24 +1,175 @@
|
|||||||
using Core.Calculation;
|
using Core.Calculation;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using Core.Parsers;
|
using Tests.Builders;
|
||||||
using Tests.Parsers;
|
using Tests.Fixtures;
|
||||||
using EventAssignment = Core.Calculation.EventAssignment;
|
using EventAssignment = Core.Calculation.EventAssignment;
|
||||||
|
|
||||||
namespace Tests.Calculation;
|
namespace Tests.Calculation;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class EventAssignmentTests
|
public class EventAssignmentTests
|
||||||
{
|
{
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void SolutionTest()
|
public async Task SolutionTest()
|
||||||
{
|
{
|
||||||
var events = TestEntityHandler.GetEvents();
|
// Create custom test data with sufficient rankings for EventAssignment
|
||||||
var students = TestEntityHandler.GetStudents(events);
|
// Need enough events and rankings to satisfy the default parameters:
|
||||||
|
// - 2-4 events per student
|
||||||
|
// - 6-8 effort points per student
|
||||||
|
// - At least 1 regional event
|
||||||
|
// - At least 1 on-site activity
|
||||||
|
|
||||||
var eventAssignment = new EventAssignment(events, students, new AssignmentParameters());
|
var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build();
|
||||||
var solution = eventAssignment.Solve().Result;
|
var coding = EventDefinitionBuilder.Individual("Coding").AsRegionalEvent().Build();
|
||||||
|
var speech = EventDefinitionBuilder.Individual("Prepared Speech").AsOnSite().Build();
|
||||||
|
var photo = EventDefinitionBuilder.Individual("Digital Photography").Build();
|
||||||
|
var essays = EventDefinitionBuilder.Individual("Essays on Technology").Build();
|
||||||
|
|
||||||
//var teamWriter = new TeamWriter(solution.Teams, @"c:\temp\teams.csv");
|
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).AsRegionalEvent().AsOnSite().Build();
|
||||||
//teamWriter.Write();
|
var biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).AsRegionalEvent().Build();
|
||||||
|
var webDesign = EventDefinitionBuilder.Team("Website Design", 3, 6).Build();
|
||||||
|
var videoGame = EventDefinitionBuilder.Team("Video Game Design", 2, 6).Build();
|
||||||
|
var engineering = EventDefinitionBuilder.Team("Engineering Design", 3, 6).AsOnSite().Build();
|
||||||
|
|
||||||
|
var events = new[] { flight, coding, speech, photo, essays, robotics, biotech, webDesign, videoGame, engineering };
|
||||||
|
|
||||||
|
// Create students with diverse rankings (each ranks 7-8 events)
|
||||||
|
var alice = StudentBuilder.Default()
|
||||||
|
.WithName("Alice", "A").WithGrade(9)
|
||||||
|
.WithRanking(flight, 1).WithRanking(robotics, 2).WithRanking(biotech, 3)
|
||||||
|
.WithRanking(speech, 4).WithRanking(webDesign, 5).WithRanking(coding, 6)
|
||||||
|
.WithRanking(videoGame, 7)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var bob = StudentBuilder.Default()
|
||||||
|
.WithName("Bob", "B").WithGrade(10)
|
||||||
|
.WithRanking(coding, 1).WithRanking(engineering, 2).WithRanking(robotics, 3)
|
||||||
|
.WithRanking(speech, 4).WithRanking(biotech, 5).WithRanking(photo, 6)
|
||||||
|
.WithRanking(videoGame, 7)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var carol = StudentBuilder.Default()
|
||||||
|
.WithName("Carol", "C").WithGrade(11)
|
||||||
|
.WithRanking(photo, 1).WithRanking(webDesign, 2).WithRanking(engineering, 3)
|
||||||
|
.WithRanking(speech, 4).WithRanking(biotech, 5).WithRanking(flight, 6)
|
||||||
|
.WithRanking(coding, 7)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var david = StudentBuilder.Default()
|
||||||
|
.WithName("David", "D").WithGrade(9)
|
||||||
|
.WithRanking(essays, 1).WithRanking(robotics, 2).WithRanking(videoGame, 3)
|
||||||
|
.WithRanking(engineering, 4).WithRanking(speech, 5).WithRanking(coding, 6)
|
||||||
|
.WithRanking(biotech, 7)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var eve = StudentBuilder.Default()
|
||||||
|
.WithName("Eve", "E").WithGrade(10)
|
||||||
|
.WithRanking(flight, 1).WithRanking(webDesign, 2).WithRanking(engineering, 3)
|
||||||
|
.WithRanking(robotics, 4).WithRanking(photo, 5).WithRanking(coding, 6)
|
||||||
|
.WithRanking(speech, 7)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var frank = StudentBuilder.Default()
|
||||||
|
.WithName("Frank", "F").WithGrade(11)
|
||||||
|
.WithRanking(coding, 1).WithRanking(biotech, 2).WithRanking(videoGame, 3)
|
||||||
|
.WithRanking(speech, 4).WithRanking(webDesign, 5).WithRanking(flight, 6)
|
||||||
|
.WithRanking(robotics, 7)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var students = new[] { alice, bob, carol, david, eve, frank };
|
||||||
|
|
||||||
|
// Use relaxed parameters for test
|
||||||
|
var parameters = new AssignmentParameters(
|
||||||
|
effortLowerBound: 4,
|
||||||
|
effortUpperBound: 7,
|
||||||
|
eventsLowerBound: 2,
|
||||||
|
eventsUpperBound: 4,
|
||||||
|
requireRegional: false, // Relax for test simplicity
|
||||||
|
requireOnSite: false // Relax for test simplicity
|
||||||
|
);
|
||||||
|
|
||||||
|
var eventAssignment = new EventAssignment(events, students, parameters);
|
||||||
|
var solution = await eventAssignment.Solve();
|
||||||
|
|
||||||
|
// Verify solution
|
||||||
|
Assert.That(solution.Teams, Is.Not.Null);
|
||||||
|
Assert.That(solution.Status, Is.EqualTo("Optimal").Or.EqualTo("Feasible"),
|
||||||
|
$"Solution should be Optimal or Feasible, but was {solution.Status}");
|
||||||
|
|
||||||
|
// Print solution summary for verification
|
||||||
|
Console.WriteLine($"Solution Status: {solution.Status}");
|
||||||
|
Console.WriteLine($"Teams Created: {solution.Teams.Length}");
|
||||||
|
Console.WriteLine($"Students Assigned: {solution.Teams.SelectMany(t => t.Students).Distinct().Count()}");
|
||||||
|
|
||||||
|
// Verify each team has valid structure
|
||||||
|
foreach (var team in solution.Teams)
|
||||||
|
{
|
||||||
|
Assert.That(team.Event, Is.Not.Null, $"Team should have an event");
|
||||||
|
Assert.That(team.Students, Is.Not.Empty, $"Team for {team.Event.Name} should have students");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SolutionTest_WithMinimalData()
|
||||||
|
{
|
||||||
|
// Test with minimal custom data to verify basic functionality
|
||||||
|
var flight = EventDefinitionBuilder.Individual("Flight").Build();
|
||||||
|
var coding = EventDefinitionBuilder.Individual("Coding").Build();
|
||||||
|
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).AsRegionalEvent().Build();
|
||||||
|
var biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).AsRegionalEvent().Build();
|
||||||
|
|
||||||
|
var events = new[] { flight, coding, robotics, biotech };
|
||||||
|
|
||||||
|
var alice = StudentBuilder.Default()
|
||||||
|
.WithName("Alice", "A")
|
||||||
|
.WithGrade(9)
|
||||||
|
.WithRanking(flight, 1)
|
||||||
|
.WithRanking(robotics, 2)
|
||||||
|
.WithRanking(biotech, 3)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var bob = StudentBuilder.Default()
|
||||||
|
.WithName("Bob", "B")
|
||||||
|
.WithGrade(10)
|
||||||
|
.WithRanking(coding, 1)
|
||||||
|
.WithRanking(robotics, 2)
|
||||||
|
.WithRanking(biotech, 3)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var carol = StudentBuilder.Default()
|
||||||
|
.WithName("Carol", "C")
|
||||||
|
.WithGrade(11)
|
||||||
|
.WithRanking(flight, 2)
|
||||||
|
.WithRanking(biotech, 1)
|
||||||
|
.WithRanking(robotics, 3)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var students = new[] { alice, bob, carol };
|
||||||
|
|
||||||
|
// Run the event assignment algorithm with minimal data
|
||||||
|
var parameters = new AssignmentParameters(
|
||||||
|
effortLowerBound: 2,
|
||||||
|
effortUpperBound: 4,
|
||||||
|
eventsLowerBound: 2,
|
||||||
|
eventsUpperBound: 3,
|
||||||
|
requireRegional: false,
|
||||||
|
requireOnSite: false
|
||||||
|
);
|
||||||
|
|
||||||
|
var eventAssignment = new EventAssignment(events, students, parameters);
|
||||||
|
var solution = await eventAssignment.Solve();
|
||||||
|
|
||||||
|
// Verify solution
|
||||||
|
Assert.That(solution.Status, Is.EqualTo("Optimal").Or.EqualTo("Feasible"));
|
||||||
|
Assert.That(solution.Teams, Is.Not.Empty, "Should create some teams");
|
||||||
|
|
||||||
|
Console.WriteLine($"Minimal Test - Status: {solution.Status}");
|
||||||
|
Console.WriteLine($"Teams: {solution.Teams.Length}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
using Core.Calculation;
|
using Core.Calculation;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using Tests.Parsers;
|
using Tests.Builders;
|
||||||
|
using Tests.Fixtures;
|
||||||
|
|
||||||
namespace Tests.Calculation;
|
namespace Tests.Calculation;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class TeamSchedulerTest
|
public class TeamSchedulerTest
|
||||||
{
|
{
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Prototype_Test()
|
public void Prototype_Test()
|
||||||
{
|
{
|
||||||
@@ -17,73 +24,83 @@ public class TeamSchedulerTest
|
|||||||
[Test]
|
[Test]
|
||||||
public void SolutionTest()
|
public void SolutionTest()
|
||||||
{
|
{
|
||||||
var events = TestEntityHandler.GetEvents();
|
// Create test data with regional team events for TeamScheduler
|
||||||
var students = TestEntityHandler.GetStudents(events);
|
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).AsRegionalEvent().Build();
|
||||||
|
var biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).AsRegionalEvent().Build();
|
||||||
var allTeams = TestEntityHandler.GetTeams(events, students);
|
var webDesign = EventDefinitionBuilder.Team("Website Design", 3, 6).AsRegionalEvent().Build();
|
||||||
var teams = allTeams;
|
var videoGame = EventDefinitionBuilder.Team("Video Game Design", 2, 6).AsRegionalEvent().Build();
|
||||||
|
var engineering = EventDefinitionBuilder.Team("Engineering Design", 3, 6).AsRegionalEvent().Build();
|
||||||
|
var cybersecurity = EventDefinitionBuilder.Team("Cybersecurity", 2, 6).AsRegionalEvent().Build();
|
||||||
|
|
||||||
teams =
|
var events = new[] { robotics, biotech, webDesign, videoGame, engineering, cybersecurity };
|
||||||
(from e in events
|
|
||||||
from t in teams
|
|
||||||
where t.Event == e
|
|
||||||
//&& t.Students.Count > 1
|
|
||||||
&& (e.EventFormat == EventFormat.Team && e.RegionalEvent)
|
|
||||||
select t).ToArray();
|
|
||||||
teams =
|
|
||||||
teams.Where(t => !t.Event.Name.Contains("Tech Bowl")).ToArray();
|
|
||||||
|
|
||||||
//var eventAssignment = new EventAssignment(events, students);
|
// Create students
|
||||||
//var teams = eventAssignment.Solve();
|
var alice = StudentBuilder.Default().WithName("Alice", "A").WithGrade(9).WithTsaYear(1).Build();
|
||||||
|
var bob = StudentBuilder.Default().WithName("Bob", "B").WithGrade(10).WithTsaYear(2).Build();
|
||||||
|
var carol = StudentBuilder.Default().WithName("Carol", "C").WithGrade(11).WithTsaYear(3).Build();
|
||||||
|
var david = StudentBuilder.Default().WithName("David", "D").WithGrade(9).WithTsaYear(1).Build();
|
||||||
|
var eve = StudentBuilder.Default().WithName("Eve", "E").WithGrade(10).WithTsaYear(2).Build();
|
||||||
|
var frank = StudentBuilder.Default().WithName("Frank", "F").WithGrade(11).WithTsaYear(3).Build();
|
||||||
|
var grace = StudentBuilder.Default().WithName("Grace", "G").WithGrade(9).WithTsaYear(1).Build();
|
||||||
|
var henry = StudentBuilder.Default().WithName("Henry", "H").WithGrade(10).WithTsaYear(2).Build();
|
||||||
|
|
||||||
TeamSchedulerSolution solution;
|
var students = new[] { alice, bob, carol, david, eve, frank, grace, henry };
|
||||||
if (true)
|
|
||||||
|
// Create teams with student assignments
|
||||||
|
var allTeams = new[]
|
||||||
{
|
{
|
||||||
var teamScheduler = TeamScheduler.CreateInstance(teams, 3);
|
TeamBuilder.Default().ForEvent(robotics).WithStudentsAndCaptain(alice, bob, carol).WithIdentifier("Robotics A").Build(),
|
||||||
solution = teamScheduler.Solve();
|
TeamBuilder.Default().ForEvent(biotech).WithStudentsAndCaptain(david, eve, frank).WithIdentifier("Biotech A").Build(),
|
||||||
}
|
TeamBuilder.Default().ForEvent(webDesign).WithStudentsAndCaptain(grace, henry, alice).WithIdentifier("WebDesign A").Build(),
|
||||||
else
|
TeamBuilder.Default().ForEvent(videoGame).WithStudentsAndCaptain(bob, david).WithIdentifier("VideoGame A").Build(),
|
||||||
{
|
TeamBuilder.Default().ForEvent(engineering).WithStudentsAndCaptain(carol, eve, frank).WithIdentifier("Engineering A").Build(),
|
||||||
var teamScheduler = new TeamScheduler_DecisionTree(teams, 3);
|
TeamBuilder.Default().ForEvent(cybersecurity).WithStudentsAndCaptain(grace, henry).WithIdentifier("Cybersecurity A").Build()
|
||||||
solution = teamScheduler.Solve();
|
};
|
||||||
}
|
|
||||||
|
|
||||||
//solution = new UnassignedStudentScheduler(allTeams, solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.BiggestGroup);
|
// Filter to only regional team events (matching original test logic)
|
||||||
//solution = new UnassignedStudentScheduler(allTeams, solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.IndividualEvents);
|
var teams = allTeams
|
||||||
|
.Where(t => t.Event.EventFormat == EventFormat.Team && t.Event.RegionalEvent)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Verify we have teams to schedule
|
||||||
|
Assert.That(teams, Is.Not.Empty, "Should have teams to schedule");
|
||||||
|
|
||||||
|
// Run TeamScheduler with 3 time slots
|
||||||
|
var teamScheduler = new TeamScheduler(teams, 3, students);
|
||||||
|
var solution = teamScheduler.Solve();
|
||||||
|
|
||||||
|
// Verify solution
|
||||||
|
Assert.That(solution, Is.Not.Null);
|
||||||
|
Assert.That(solution.TimeSlots, Is.Not.Null);
|
||||||
|
Assert.That(solution.TimeSlots.Length, Is.EqualTo(3), "Should have 3 time slots");
|
||||||
|
|
||||||
|
// Print solution (matching original test output format)
|
||||||
var i = 1;
|
var i = 1;
|
||||||
foreach (var slot in solution.TimeSlots)
|
foreach (var slot in solution.TimeSlots)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Time slot {i++}");
|
Console.WriteLine($"Time slot {i++}");
|
||||||
foreach (var team in slot.OrderBy(s => s.Event.Name))
|
foreach (var team in slot.Teams.OrderBy(s => s.Event.Name))
|
||||||
{
|
{
|
||||||
var names = string.Join(", ", team.Students.OrderByDescending(s => s.Grade + s.TsaYear).Select(s => s.FirstName));
|
var names = string.Join(", ", team.Students.OrderByDescending(s => s.Grade + s.TsaYear).Select(s => s.FirstName));
|
||||||
Console.WriteLine($"\t{team.Event.Name}");
|
Console.WriteLine($"\t{team.Event.Name}");
|
||||||
Console.WriteLine($"\t\t{names}");
|
Console.WriteLine($"\t\t{names}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var overlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(slot).ToList();
|
var overlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(slot.Teams).ToList();
|
||||||
|
|
||||||
if (overlaps.Any())
|
if (overlaps.Any())
|
||||||
{
|
{
|
||||||
Console.WriteLine("\toverlaps");
|
Console.WriteLine("\toverlaps");
|
||||||
foreach (var overlap in overlaps)
|
foreach (var overlap in overlaps)
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"\t\t{overlap.Item1.Name} : {string.Join(", ", overlap.Item2.Select(t => t.Event.Name))}");
|
$"\t\t{overlap.student.Name} : {string.Join(", ", overlap.teams.Select(t => t.Event.Name))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var unassigned = UnassignedStudentScheduler.UnassignedStudents(students, slot).ToList();
|
var unassigned = UnassignedStudentScheduler.UnassignedStudents(students, slot.Teams).ToList();
|
||||||
|
|
||||||
if (unassigned.Any())
|
if (unassigned.Any())
|
||||||
Console.WriteLine("\tunassigned");
|
Console.WriteLine("\tunassigned");
|
||||||
Console.WriteLine($"\t\t{string.Join(", ", unassigned.Select(s => s.FirstName))}");
|
Console.WriteLine($"\t\t{string.Join(", ", unassigned.Select(s => s.FirstName))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
//var allScheduledTeams = timeSlots.SelectMany(list => list).GroupBy(t => t.Name).SelectMany(g => g).Distinct();
|
|
||||||
//foreach (var allScheduledTeam in allScheduledTeams.OrderBy(a => a.Name))
|
|
||||||
//{
|
|
||||||
// Console.WriteLine($"{allScheduledTeam.Name}");
|
|
||||||
// Console.WriteLine($"\t{string.Join(", ", allScheduledTeam.Students.Select(s => s.FirstName))}");
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Tests.Builders;
|
||||||
|
|
||||||
|
namespace Tests.Fixtures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-configured test data fixtures for different testing scenarios.
|
||||||
|
/// </summary>
|
||||||
|
public static class TestDataFixtures
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Small dataset for simple unit tests (5 events, 6 students, minimal teams).
|
||||||
|
/// </summary>
|
||||||
|
public static class Small
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a small set of test data: 5 events, 6 students, and 3 teams.
|
||||||
|
/// Suitable for simple unit tests that don't require realistic volume.
|
||||||
|
/// </summary>
|
||||||
|
public static (EventDefinition[] Events, Student[] Students, Team[] Teams) Full()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
|
||||||
|
var events = new[]
|
||||||
|
{
|
||||||
|
EventDefinitionBuilder.Individual("Flight").Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Coding").Build(),
|
||||||
|
EventDefinitionBuilder.Team("Biotechnology", 2, 6).WithInterview().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Robotics", 2, 5).AsOnSite().Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Essays on Technology").Build()
|
||||||
|
};
|
||||||
|
|
||||||
|
var students = new[]
|
||||||
|
{
|
||||||
|
StudentBuilder.Default().WithName("Alice", "Anderson").WithGrade(9).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Bob", "Brown").WithGrade(10).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Carol", "Clark").WithGrade(11).Build(),
|
||||||
|
StudentBuilder.Default().WithName("David", "Davis").WithGrade(9).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Eve", "Evans").WithGrade(10).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Frank", "Foster").WithGrade(11).Build()
|
||||||
|
};
|
||||||
|
|
||||||
|
var teams = new[]
|
||||||
|
{
|
||||||
|
TeamBuilder.Default().ForEvent(events[2]) // Biotechnology
|
||||||
|
.WithStudentsAndCaptain(students[0], students[1])
|
||||||
|
.Build(),
|
||||||
|
TeamBuilder.Default().ForEvent(events[3]) // Robotics
|
||||||
|
.WithStudentsAndCaptain(students[2], students[3], students[4])
|
||||||
|
.Build(),
|
||||||
|
TeamBuilder.Default().ForEvent(events[0]) // Flight (individual)
|
||||||
|
.WithStudent(students[5])
|
||||||
|
.Build()
|
||||||
|
};
|
||||||
|
|
||||||
|
return (events, students, teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns just events from the small fixture.
|
||||||
|
/// </summary>
|
||||||
|
public static EventDefinition[] Events()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
return Full().Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns just students from the small fixture.
|
||||||
|
/// </summary>
|
||||||
|
public static Student[] Students()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
return Full().Students;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Large dataset for algorithm tests requiring realistic volume (25 events, 18 students, 15+ teams).
|
||||||
|
/// </summary>
|
||||||
|
public static class Large
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a realistic large set of test data: 25 events, 18 students, and 15+ teams.
|
||||||
|
/// Includes real TSA event names and realistic team configurations.
|
||||||
|
/// Suitable for testing algorithms like EventAssignment and TeamScheduler.
|
||||||
|
/// </summary>
|
||||||
|
public static (EventDefinition[] Events, Student[] Students, Team[] Teams) Full()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
|
||||||
|
// Real TSA events with realistic configurations
|
||||||
|
var events = new[]
|
||||||
|
{
|
||||||
|
// Individual events (some are regional)
|
||||||
|
EventDefinitionBuilder.Individual("Architectural Design").WithStateCount(3).AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Biotechnology Design").WithInterview().AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Coding").WithStateCount(3).Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Digital Photography").AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Essays on Technology").Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Extemporaneous Speech").AsOnSite().Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Flight").WithStateCount(3).AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Prepared Speech").AsOnSite().Build(),
|
||||||
|
EventDefinitionBuilder.Individual("Scientific Visualization").Build(),
|
||||||
|
EventDefinitionBuilder.Individual("System Control Technology").Build(),
|
||||||
|
|
||||||
|
// Team events (2-6 members, many are regional)
|
||||||
|
EventDefinitionBuilder.Team("Animatronics", 2, 6).WithInterview().AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Chapter Team", 6, 6).WithPresentation().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Cybersecurity", 2, 6).AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Debate", 2, 2).AsOnSite().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Dragster", 1, 3).AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Engineering Design", 3, 6).WithInterview().AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Fashion Design", 2, 6).WithInterview().AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Forensic Technology", 2, 6).Build(),
|
||||||
|
EventDefinitionBuilder.Team("Future Technology Teacher", 2, 6).WithPresentation().AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Junior Solar Sprint", 2, 3).AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Music Production", 2, 6).WithInterview().AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("On Demand Video", 2, 6).Build(),
|
||||||
|
EventDefinitionBuilder.Team("Promotional Marketing", 1, 6).WithPresentation().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Software Development", 2, 6).WithInterview().AsRegionalEvent().Build(),
|
||||||
|
EventDefinitionBuilder.Team("Video Game Design", 2, 6).WithInterview().AsRegionalEvent().Build()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 18 students with varied grades and rankings
|
||||||
|
var students = new[]
|
||||||
|
{
|
||||||
|
StudentBuilder.Default().WithName("Alice", "Anderson").WithGrade(9).WithTsaYear(1).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Bob", "Brown").WithGrade(10).WithTsaYear(2).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Carol", "Clark").WithGrade(11).WithTsaYear(3).Build(),
|
||||||
|
StudentBuilder.Default().WithName("David", "Davis").WithGrade(9).WithTsaYear(1).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Eve", "Evans").WithGrade(10).WithTsaYear(2).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Frank", "Foster").WithGrade(11).WithTsaYear(3).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Grace", "Garcia").WithGrade(9).WithTsaYear(1).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Henry", "Harris").WithGrade(10).WithTsaYear(2).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Ivy", "Irwin").WithGrade(11).WithTsaYear(3).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Jack", "Johnson").WithGrade(9).WithTsaYear(1).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Kelly", "King").WithGrade(10).WithTsaYear(2).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Leo", "Lopez").WithGrade(11).WithTsaYear(3).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Maria", "Martinez").WithGrade(9).WithTsaYear(1).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Noah", "Nelson").WithGrade(10).WithTsaYear(2).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Olivia", "O'Brien").WithGrade(11).WithTsaYear(3).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Paul", "Patel").WithGrade(9).WithTsaYear(1).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Quinn", "Quinn").WithGrade(10).WithTsaYear(2).Build(),
|
||||||
|
StudentBuilder.Default().WithName("Rachel", "Robinson").WithGrade(11).WithTsaYear(3).Build()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add event rankings for students (realistic preferences)
|
||||||
|
AddRankingsToStudent(students[0], events, new[] { 0, 2, 6, 10, 12 }); // Individual + teams
|
||||||
|
AddRankingsToStudent(students[1], events, new[] { 1, 3, 11, 13, 16 });
|
||||||
|
AddRankingsToStudent(students[2], events, new[] { 4, 5, 14, 17, 20 });
|
||||||
|
AddRankingsToStudent(students[3], events, new[] { 6, 7, 15, 18, 21 });
|
||||||
|
AddRankingsToStudent(students[4], events, new[] { 8, 9, 16, 19, 22 });
|
||||||
|
AddRankingsToStudent(students[5], events, new[] { 0, 2, 12, 20, 23 });
|
||||||
|
AddRankingsToStudent(students[6], events, new[] { 1, 3, 10, 13, 24 });
|
||||||
|
AddRankingsToStudent(students[7], events, new[] { 4, 5, 11, 14, 15 });
|
||||||
|
AddRankingsToStudent(students[8], events, new[] { 6, 7, 16, 17, 18 });
|
||||||
|
AddRankingsToStudent(students[9], events, new[] { 8, 9, 19, 21, 22 });
|
||||||
|
AddRankingsToStudent(students[10], events, new[] { 0, 10, 12, 14, 23 });
|
||||||
|
AddRankingsToStudent(students[11], events, new[] { 1, 11, 13, 15, 24 });
|
||||||
|
AddRankingsToStudent(students[12], events, new[] { 2, 12, 16, 18, 20 });
|
||||||
|
AddRankingsToStudent(students[13], events, new[] { 3, 13, 17, 19, 21 });
|
||||||
|
AddRankingsToStudent(students[14], events, new[] { 4, 14, 18, 20, 22 });
|
||||||
|
AddRankingsToStudent(students[15], events, new[] { 5, 15, 19, 21, 23 });
|
||||||
|
AddRankingsToStudent(students[16], events, new[] { 6, 16, 20, 22, 24 });
|
||||||
|
AddRankingsToStudent(students[17], events, new[] { 7, 10, 11, 12, 13 });
|
||||||
|
|
||||||
|
// Create representative teams (some will be created by algorithm tests)
|
||||||
|
var teams = new[]
|
||||||
|
{
|
||||||
|
TeamBuilder.Default().ForEvent(events[10]) // Animatronics
|
||||||
|
.WithStudentsAndCaptain(students[0], students[1], students[2])
|
||||||
|
.WithIdentifier("Team A")
|
||||||
|
.Build(),
|
||||||
|
TeamBuilder.Default().ForEvent(events[11]) // Chapter Team
|
||||||
|
.WithStudentsAndCaptain(students[3], students[4], students[5], students[6], students[7], students[8])
|
||||||
|
.WithIdentifier("Chapter")
|
||||||
|
.Build(),
|
||||||
|
TeamBuilder.Default().ForEvent(events[12]) // Cybersecurity
|
||||||
|
.WithStudentsAndCaptain(students[9], students[10])
|
||||||
|
.WithIdentifier("Team C")
|
||||||
|
.Build(),
|
||||||
|
TeamBuilder.Default().ForEvent(events[13]) // Debate
|
||||||
|
.WithStudentsAndCaptain(students[11], students[12])
|
||||||
|
.WithIdentifier("Team D")
|
||||||
|
.Build(),
|
||||||
|
TeamBuilder.Default().ForEvent(events[16]) // Fashion Design
|
||||||
|
.WithStudentsAndCaptain(students[13], students[14], students[15])
|
||||||
|
.WithIdentifier("Team F")
|
||||||
|
.Build(),
|
||||||
|
TeamBuilder.Default().ForEvent(events[24]) // Video Game Design
|
||||||
|
.WithStudentsAndCaptain(students[16], students[17])
|
||||||
|
.WithIdentifier("Team V")
|
||||||
|
.Build()
|
||||||
|
};
|
||||||
|
|
||||||
|
return (events, students, teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to add event rankings to a student.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddRankingsToStudent(Student student, EventDefinition[] events, int[] eventIndices)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < eventIndices.Length; i++)
|
||||||
|
{
|
||||||
|
var eventIndex = eventIndices[i];
|
||||||
|
if (eventIndex >= 0 && eventIndex < events.Length)
|
||||||
|
{
|
||||||
|
StudentEventRankingBuilder.Default()
|
||||||
|
.ForStudent(student)
|
||||||
|
.ForEvent(events[eventIndex])
|
||||||
|
.WithRank(i + 1)
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns just events from the large fixture.
|
||||||
|
/// </summary>
|
||||||
|
public static EventDefinition[] Events()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
return Full().Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns just students from the large fixture.
|
||||||
|
/// </summary>
|
||||||
|
public static Student[] Students()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
return Full().Students;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom helpers for creating specific numbers of minimal test entities.
|
||||||
|
/// </summary>
|
||||||
|
public static class Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates N simple individual events with generic names.
|
||||||
|
/// </summary>
|
||||||
|
public static EventDefinition[] MinimalEvents(int count)
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
var events = new EventDefinition[count];
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
events[i] = EventDefinitionBuilder.Individual($"Event{i + 1}").Build();
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates N simple students with sequential names.
|
||||||
|
/// </summary>
|
||||||
|
public static Student[] MinimalStudents(int count)
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
return BuilderExtensions.CreateStudents(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates N simple teams for a given event.
|
||||||
|
/// </summary>
|
||||||
|
public static Team[] MinimalTeams(EventDefinition eventDef, int teamCount, int studentsPerTeam)
|
||||||
|
{
|
||||||
|
var allStudents = MinimalStudents(teamCount * studentsPerTeam);
|
||||||
|
var teams = new Team[teamCount];
|
||||||
|
|
||||||
|
for (int i = 0; i < teamCount; i++)
|
||||||
|
{
|
||||||
|
var teamStudents = allStudents.Skip(i * studentsPerTeam).Take(studentsPerTeam).ToArray();
|
||||||
|
teams[i] = TeamBuilder.Default().ForEvent(eventDef)
|
||||||
|
.WithStudents(teamStudents)
|
||||||
|
.WithCaptain(teamStudents[0])
|
||||||
|
.WithIdentifier($"Team{i + 1}")
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return teams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
var allMeetingTeams = schedule.SelectMany(t => t).Distinct();
|
var allMeetingTeams = schedule.SelectMany(t => t).Distinct();
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="bluewhite p-5">
|
<div class="bluewhite p-5">
|
||||||
@{
|
@{
|
||||||
List<Tuple<Student, IEnumerable<Team>>> overlaps;
|
List<(Student student, IEnumerable<Team> teams)> overlaps;
|
||||||
}
|
}
|
||||||
@foreach (var timeslot in schedule)
|
@foreach (var timeslot in schedule)
|
||||||
{
|
{
|
||||||
overlaps = Team.GetStudentTeamOverlaps(timeslot).ToList();
|
overlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(timeslot).ToList();
|
||||||
var partialTeams = timeslot.Where(t => t is PartialTeam && t.EventDefinition.EventFormat is not EventFormat.Individual);
|
var partialTeams = timeslot.Where(t => t is PartialTeam && t.EventDefinition.EventFormat is not EventFormat.Individual);
|
||||||
var fullTeams = timeslot.Where(t => !partialTeams.Contains(t));
|
var fullTeams = timeslot.Where(t => !partialTeams.Contains(t));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@using Core.Entities
|
@using Core.Entities
|
||||||
@model Tuple<Core.Entities.Team, List<Tuple<Student, IEnumerable<Team>>>, string[]?>
|
@model Tuple<Core.Entities.Team, List<(Student student, IEnumerable<Team> teams)>, string[]?>
|
||||||
|
|
||||||
@{
|
@{
|
||||||
var team = Model.Item1;
|
var team = Model.Item1;
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
{
|
{
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
@if (overlaps.Any(t => t.Item1 == student))
|
@if (overlaps.Any(t => t.student == student))
|
||||||
{
|
{
|
||||||
<span style="color: #F66">@student.FirstName</span>
|
<span style="color: #F66">@student.FirstName</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
|
namespace WebApp.Authentication
|
||||||
|
{
|
||||||
|
public class AuthController : Controller
|
||||||
|
{
|
||||||
|
private readonly AuthenticationService _authService;
|
||||||
|
private readonly LoginRateLimitService _rateLimitService;
|
||||||
|
private readonly ILogger<AuthController> _logger;
|
||||||
|
|
||||||
|
public AuthController(
|
||||||
|
AuthenticationService authService,
|
||||||
|
LoginRateLimitService rateLimitService,
|
||||||
|
ILogger<AuthController> logger)
|
||||||
|
{
|
||||||
|
_authService = authService;
|
||||||
|
_rateLimitService = rateLimitService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> CookieLogin(
|
||||||
|
[FromForm] string email,
|
||||||
|
[FromForm] string password,
|
||||||
|
[FromForm] bool rememberMe = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get client IP
|
||||||
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
|
||||||
|
// Check rate limiting
|
||||||
|
if (_rateLimitService.IsLockedOut(ipAddress))
|
||||||
|
{
|
||||||
|
var remaining = _rateLimitService.GetRemainingLockoutTime(ipAddress);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Login attempt from locked out IP: {IpAddress}. Remaining: {Remaining}",
|
||||||
|
ipAddress, remaining);
|
||||||
|
|
||||||
|
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
|
||||||
|
return Redirect($"/login?error={errorMsg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate credentials
|
||||||
|
var result = _authService.ValidateCredentials(email, password);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
// Record failed attempt
|
||||||
|
_rateLimitService.RecordFailedAttempt(ipAddress);
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Failed login attempt for {Email} from {IpAddress}",
|
||||||
|
email, ipAddress);
|
||||||
|
|
||||||
|
return Redirect("/login?error=Invalid%20email%20or%20password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - clear rate limit tracking
|
||||||
|
_rateLimitService.RecordSuccessfulLogin(ipAddress);
|
||||||
|
|
||||||
|
// Create claims
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, result.DisplayName!),
|
||||||
|
new Claim(ClaimTypes.Email, result.Email!),
|
||||||
|
new Claim(ClaimTypes.Role, result.Role!)
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims, "Auth");
|
||||||
|
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
// Configure auth properties
|
||||||
|
var authProperties = new AuthenticationProperties
|
||||||
|
{
|
||||||
|
IsPersistent = rememberMe,
|
||||||
|
ExpiresUtc = rememberMe
|
||||||
|
? DateTimeOffset.UtcNow.AddDays(30)
|
||||||
|
: DateTimeOffset.UtcNow.AddMinutes(20)
|
||||||
|
};
|
||||||
|
|
||||||
|
await HttpContext.SignInAsync("Auth", claimsPrincipal, authProperties);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Successful login for {Email} ({Role}) from {IpAddress}",
|
||||||
|
result.Email, result.Role, ipAddress);
|
||||||
|
|
||||||
|
return Redirect("/");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during login process");
|
||||||
|
TempData["LoginError"] = "An error occurred. Please try again.";
|
||||||
|
return Redirect("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> CookieLogout()
|
||||||
|
{
|
||||||
|
var userEmail = User.FindFirst(ClaimTypes.Email)?.Value;
|
||||||
|
|
||||||
|
await HttpContext.SignOutAsync("Auth");
|
||||||
|
|
||||||
|
_logger.LogInformation("User {Email} logged out", userEmail);
|
||||||
|
|
||||||
|
return Redirect("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace WebApp.Authentication;
|
||||||
|
|
||||||
|
public static class AuthRoles
|
||||||
|
{
|
||||||
|
public const string Administrator = "Administrator";
|
||||||
|
public const string Advisor = "Advisor";
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace WebApp.Authentication;
|
||||||
|
|
||||||
|
public class AuthenticationService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<AuthenticationService> _logger;
|
||||||
|
|
||||||
|
public AuthenticationService(IConfiguration configuration, ILogger<AuthenticationService> logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationResult ValidateCredentials(string email, string password)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Bind User Secrets to AuthenticationSettings
|
||||||
|
var authSettings = new AuthenticationSettings();
|
||||||
|
_configuration.GetSection("Authentication").Bind(authSettings);
|
||||||
|
|
||||||
|
if (authSettings.Users == null || !authSettings.Users.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No users configured in authentication settings");
|
||||||
|
return AuthenticationResult.Failed("Authentication system not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user by email (case-insensitive)
|
||||||
|
var user = authSettings.Users
|
||||||
|
.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("User not found: {Email}", email);
|
||||||
|
return AuthenticationResult.Failed("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password using BCrypt
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalid password for user: {Email}", email);
|
||||||
|
return AuthenticationResult.Failed("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
_logger.LogDebug("Successful credential validation for {Email}", email);
|
||||||
|
return AuthenticationResult.Success(user.Email, user.DisplayName, user.Role);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during credential validation");
|
||||||
|
return AuthenticationResult.Failed("An error occurred during authentication");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthenticationResult
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public static AuthenticationResult Success(string email, string displayName, string role)
|
||||||
|
=> new() { IsSuccess = true, Email = email, DisplayName = displayName, Role = role };
|
||||||
|
|
||||||
|
public static AuthenticationResult Failed(string error)
|
||||||
|
=> new() { IsSuccess = false, ErrorMessage = error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace WebApp.Authentication;
|
||||||
|
|
||||||
|
public class AuthenticationSettings
|
||||||
|
{
|
||||||
|
public List<UserCredential> Users { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace WebApp.Authentication;
|
||||||
|
|
||||||
|
public class LoginRateLimitService : IHostedService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, LoginAttemptTracker> _attempts = new();
|
||||||
|
private readonly ILogger<LoginRateLimitService> _logger;
|
||||||
|
private Timer? _cleanupTimer;
|
||||||
|
private const int MaxAttempts = 5;
|
||||||
|
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
|
||||||
|
private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
public class LoginAttemptTracker
|
||||||
|
{
|
||||||
|
public int FailedAttempts { get; set; }
|
||||||
|
public DateTime? LockoutUntil { get; set; }
|
||||||
|
public DateTime LastAttemptTime { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginRateLimitService(ILogger<LoginRateLimitService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLockedOut(string ipAddress)
|
||||||
|
{
|
||||||
|
if (!_attempts.TryGetValue(ipAddress, out var tracker))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (tracker.LockoutUntil.HasValue && tracker.LockoutUntil > DateTime.UtcNow)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Lockout expired, reset
|
||||||
|
if (tracker.LockoutUntil.HasValue)
|
||||||
|
{
|
||||||
|
tracker.FailedAttempts = 0;
|
||||||
|
tracker.LockoutUntil = null;
|
||||||
|
_logger.LogInformation("Lockout expired for IP: {IpAddress}", ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordFailedAttempt(string ipAddress)
|
||||||
|
{
|
||||||
|
var tracker = _attempts.GetOrAdd(ipAddress, _ => new LoginAttemptTracker());
|
||||||
|
|
||||||
|
tracker.FailedAttempts++;
|
||||||
|
tracker.LastAttemptTime = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (tracker.FailedAttempts >= MaxAttempts)
|
||||||
|
{
|
||||||
|
tracker.LockoutUntil = DateTime.UtcNow.Add(LockoutDuration);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"IP address locked out due to {Attempts} failed login attempts: {IpAddress}. Lockout until: {LockoutUntil}",
|
||||||
|
tracker.FailedAttempts, ipAddress, tracker.LockoutUntil);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Failed login attempt {Attempt}/{MaxAttempts} for IP: {IpAddress}",
|
||||||
|
tracker.FailedAttempts, MaxAttempts, ipAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordSuccessfulLogin(string ipAddress)
|
||||||
|
{
|
||||||
|
if (_attempts.TryRemove(ipAddress, out _))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Cleared rate limit tracking for IP: {IpAddress}", ipAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan? GetRemainingLockoutTime(string ipAddress)
|
||||||
|
{
|
||||||
|
if (!_attempts.TryGetValue(ipAddress, out var tracker) ||
|
||||||
|
!tracker.LockoutUntil.HasValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var remaining = tracker.LockoutUntil.Value - DateTime.UtcNow;
|
||||||
|
return remaining > TimeSpan.Zero ? remaining : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background cleanup to prevent memory leaks
|
||||||
|
private void CleanupExpiredEntries(object? state)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cutoffTime = DateTime.UtcNow.AddHours(-24);
|
||||||
|
var expiredKeys = _attempts
|
||||||
|
.Where(kvp => kvp.Value.LastAttemptTime < cutoffTime &&
|
||||||
|
(!kvp.Value.LockoutUntil.HasValue || kvp.Value.LockoutUntil < DateTime.UtcNow))
|
||||||
|
.Select(kvp => kvp.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var key in expiredKeys)
|
||||||
|
{
|
||||||
|
if (_attempts.TryRemove(key, out _))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Removed expired rate limit entry for IP: {IpAddress}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredKeys.Any())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Cleaned up {Count} expired rate limit entries", expiredKeys.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during rate limit cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IHostedService implementation
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Login rate limiting service started");
|
||||||
|
_cleanupTimer = new Timer(CleanupExpiredEntries, null, CleanupInterval, CleanupInterval);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Login rate limiting service stopped");
|
||||||
|
_cleanupTimer?.Change(Timeout.Infinite, 0);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cleanupTimer?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace WebApp.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for generating BCrypt password hashes for use in User Secrets configuration.
|
||||||
|
/// </summary>
|
||||||
|
public static class PasswordHashGenerator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a BCrypt hash for the given password with work factor 11.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="password">The password to hash</param>
|
||||||
|
/// <returns>BCrypt hash string suitable for storing in User Secrets</returns>
|
||||||
|
public static string GenerateHash(string password)
|
||||||
|
{
|
||||||
|
return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates hashes for multiple passwords at once.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="passwords">Dictionary of label/password pairs</param>
|
||||||
|
/// <returns>Dictionary of label/hash pairs</returns>
|
||||||
|
public static Dictionary<string, string> GenerateHashes(Dictionary<string, string> passwords)
|
||||||
|
{
|
||||||
|
var results = new Dictionary<string, string>();
|
||||||
|
foreach (var kvp in passwords)
|
||||||
|
{
|
||||||
|
results[kvp.Key] = GenerateHash(kvp.Value);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace WebApp.Authentication;
|
||||||
|
|
||||||
|
public class UserCredential
|
||||||
|
{
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ namespace WebApp
|
|||||||
{
|
{
|
||||||
private int[]? _scheduledTeams;
|
private int[]? _scheduledTeams;
|
||||||
|
|
||||||
public int[] UserId
|
public int[] ScheduledTeams
|
||||||
{
|
{
|
||||||
get => _scheduledTeams ?? [];
|
get => _scheduledTeams ?? [];
|
||||||
set
|
set
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="InteractiveServer" />
|
<AppErrorBoundary>
|
||||||
|
<Routes @rendermode="InteractiveServer" />
|
||||||
|
</AppErrorBoundary>
|
||||||
|
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
@page "/login"
|
||||||
|
@layout EmptyLayout
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using WebApp.Authentication
|
||||||
|
@using Microsoft.AspNetCore.Antiforgery
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using MudInputType = MudBlazor.InputType
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject LoginRateLimitService RateLimitService
|
||||||
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
|
@inject IAntiforgery Antiforgery
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
<div class="d-flex justify-center align-center" style="min-height: 100vh;">
|
||||||
|
<MudPaper Elevation="3" Class="pa-8" MaxWidth="400px" Style="width: 100%;">
|
||||||
|
<MudText Typo="Typo.h4" Align="Align.Center" GutterBottom="true">
|
||||||
|
TSA Chapter Organizer
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.h6" Align="Align.Center" GutterBottom="true" Class="mb-6">
|
||||||
|
Sign In
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mb-4">@_errorMessage</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form id="loginForm" method="post" action="/Auth/CookieLogin" @onkeydown="HandleKeyDown">
|
||||||
|
<input type="hidden" name="__RequestVerificationToken" value="@_antiforgeryToken" />
|
||||||
|
<input type="hidden" id="emailInput" name="email" value="" />
|
||||||
|
<input type="hidden" id="passwordInput" name="password" value="" />
|
||||||
|
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="_loginModel.Email"
|
||||||
|
Label="Email"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
InputType="MudInputType.Email"
|
||||||
|
Required="true"
|
||||||
|
Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="_loginModel.Password"
|
||||||
|
Label="Password"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
InputType="@_passwordInput"
|
||||||
|
Required="true"
|
||||||
|
Class="mb-4"
|
||||||
|
Adornment="Adornment.End"
|
||||||
|
AdornmentIcon="@_passwordInputIcon"
|
||||||
|
OnAdornmentClick="TogglePasswordVisibility" />
|
||||||
|
|
||||||
|
<div class="d-flex justify-space-between align-center mb-4">
|
||||||
|
<MudCheckBox @bind-Value="_loginModel.RememberMe"
|
||||||
|
Label="Remember me?"
|
||||||
|
Color="Color.Primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudButton ButtonType="ButtonType.Button"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Large"
|
||||||
|
FullWidth="true"
|
||||||
|
OnClick="HandleFormSubmit">
|
||||||
|
<span>Sign In</span>
|
||||||
|
</MudButton>
|
||||||
|
</form>
|
||||||
|
</MudPaper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private LoginModel _loginModel = new();
|
||||||
|
private string? _errorMessage;
|
||||||
|
private bool _passwordVisibility;
|
||||||
|
private MudInputType _passwordInput = MudInputType.Password;
|
||||||
|
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
|
||||||
|
private string _antiforgeryToken = string.Empty;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
// Generate antiforgery token
|
||||||
|
var httpContext = HttpContextAccessor.HttpContext;
|
||||||
|
if (httpContext != null)
|
||||||
|
{
|
||||||
|
var tokenSet = Antiforgery.GetAndStoreTokens(httpContext);
|
||||||
|
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error message from query parameter (set by controller on failed login)
|
||||||
|
var uri = new Uri(Navigation.Uri);
|
||||||
|
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
if (queryParams.TryGetValue("error", out var errorValue))
|
||||||
|
{
|
||||||
|
_errorMessage = errorValue.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoginModel
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "Email is required")]
|
||||||
|
[EmailAddress(ErrorMessage = "Invalid email format")]
|
||||||
|
[MaxLength(100, ErrorMessage = "Email must be less than 100 characters")]
|
||||||
|
[RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", ErrorMessage = "Please enter a valid email address")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Password is required")]
|
||||||
|
[MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||||
|
[MaxLength(100, ErrorMessage = "Password must be less than 100 characters")]
|
||||||
|
[DataType(DataType.Password)]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TogglePasswordVisibility()
|
||||||
|
{
|
||||||
|
_passwordVisibility = !_passwordVisibility;
|
||||||
|
_passwordInputIcon = _passwordVisibility
|
||||||
|
? Icons.Material.Filled.Visibility
|
||||||
|
: Icons.Material.Filled.VisibilityOff;
|
||||||
|
_passwordInput = _passwordVisibility
|
||||||
|
? MudInputType.Text
|
||||||
|
: MudInputType.Password;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter")
|
||||||
|
{
|
||||||
|
// Blur the active element to ensure MudTextField bindings update
|
||||||
|
await JS.InvokeVoidAsync("eval", "document.activeElement.blur()");
|
||||||
|
|
||||||
|
// Small delay to allow bindings to process
|
||||||
|
await Task.Delay(50);
|
||||||
|
|
||||||
|
// Now submit the form
|
||||||
|
await HandleFormSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleFormSubmit()
|
||||||
|
{
|
||||||
|
// Update hidden inputs with current model values, then submit the form
|
||||||
|
await JS.InvokeVoidAsync("eval", $@"
|
||||||
|
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
||||||
|
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
||||||
|
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
||||||
|
document.getElementById('loginForm').submit();
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -1,4 +1,5 @@
|
|||||||
@page "/events/create"
|
@page "/events/create"
|
||||||
|
@attribute [Authorize]
|
||||||
@inject AppDbContext context
|
@inject AppDbContext context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
@@ -10,24 +11,23 @@
|
|||||||
|
|
||||||
|
|
||||||
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
|
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
|
||||||
|
<AntiforgeryToken />
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="7">
|
<MudItem xs="12" sm="7">
|
||||||
<MudPaper Class="pa-4">
|
<MudPaper Class="pa-4">
|
||||||
<MudTextField T="string" Label="Event Name" @bind-Value="EventDefinition.Name" For="@(() => EventDefinition.Name)"></MudTextField>
|
<MudTextField T="string" Label="Event Name" @bind-Value="EventDefinition.Name" For="@(() => EventDefinition.Name)"></MudTextField>
|
||||||
<MudTextField T="string" Label="Short Name" @bind-Value="EventDefinition.ShortName" For="@(() => EventDefinition.ShortName)"></MudTextField>
|
<MudTextField T="string" Label="Short Name" @bind-Value="EventDefinition.ShortName" For="@(() => EventDefinition.ShortName)"></MudTextField>
|
||||||
|
|
||||||
<label for="@EventDefinition.EventFormat" class="form-label">Format:</label>
|
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-2">Format</MudText>
|
||||||
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat">
|
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat" For="@(() => EventDefinition.EventFormat)">
|
||||||
@* <MudRadio T="EventFormat" Value="EventFormat.Team">Team</MudRadio>
|
|
||||||
<MudRadio T="EventFormat" Value="EventFormat.Individual">Individual</MudRadio> *@
|
|
||||||
@foreach (EventFormat format in Enum.GetValues(typeof(EventFormat)))
|
@foreach (EventFormat format in Enum.GetValues(typeof(EventFormat)))
|
||||||
{
|
{
|
||||||
<MudRadio T="EventFormat" value="@format">@(format.ToString())</MudRadio>
|
<MudRadio T="EventFormat" Value="@format">@format.ToString()</MudRadio>
|
||||||
}
|
}
|
||||||
</MudRadioGroup>
|
</MudRadioGroup>
|
||||||
<ValidationMessage For="() => EventDefinition.EventFormat" class="text-danger" />
|
|
||||||
<MudTextField T="string" Label="Theme" AutoGrow="true" @bind-Value="EventDefinition.Description" For="@(() => EventDefinition.Description)"></MudTextField>
|
<MudTextField T="string" Label="Description" AutoGrow="true" @bind-Value="EventDefinition.Description" For="@(() => EventDefinition.Description)" Class="mt-4"></MudTextField>
|
||||||
<MudTextField T="string" Label="Theme" AutoGrow="true" @bind-Value="EventDefinition.Theme" For="@(() => EventDefinition.Theme)"></MudTextField>
|
<MudTextField T="string" Label="Theme" AutoGrow="true" @bind-Value="EventDefinition.Theme" For="@(() => EventDefinition.Theme)"></MudTextField>
|
||||||
<MudTextField T="string" Label="Documentation" @bind-Value="EventDefinition.Documentation" For="@(() => EventDefinition.Documentation)"></MudTextField>
|
<MudTextField T="string" Label="Documentation" @bind-Value="EventDefinition.Documentation" For="@(() => EventDefinition.Documentation)"></MudTextField>
|
||||||
<MudNumericField T="int?" Label="Level of Effort" @bind-Value="EventDefinition.LevelOfEffort" For="@(() => EventDefinition.LevelOfEffort)"></MudNumericField>
|
<MudNumericField T="int?" Label="Level of Effort" @bind-Value="EventDefinition.LevelOfEffort" For="@(() => EventDefinition.LevelOfEffort)"></MudNumericField>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
@page "/events/details"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject AppDbContext context
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<PageTitle>Event Details - TSA Chapter Organizer</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h3">Details</MudText>
|
||||||
|
<MudText Typo="Typo.h4">Event Definition</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
@if (eventdefinition is null)
|
||||||
|
{
|
||||||
|
<MudText><em>Loading...</em></MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Name</MudText>
|
||||||
|
<MudText>@eventdefinition.Name</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Short Name</MudText>
|
||||||
|
<MudText>@eventdefinition.ShortName</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Event Format</MudText>
|
||||||
|
<MudText>@eventdefinition.EventFormat</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Min Team Size</MudText>
|
||||||
|
<MudText>@eventdefinition.MinTeamSize</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Max Team Size</MudText>
|
||||||
|
<MudText>@eventdefinition.MaxTeamSize</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Chapter Eligibility Count (State)</MudText>
|
||||||
|
<MudText>@eventdefinition.ChapterEligibilityCountState</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Regional Event</MudText>
|
||||||
|
<MudText>@eventdefinition.RegionalEvent</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Presubmission Required</MudText>
|
||||||
|
<MudText>@eventdefinition.Presubmission</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Level of Effort</MudText>
|
||||||
|
<MudText>@eventdefinition.LevelOfEffort</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2">Semifinalist Activity</MudText>
|
||||||
|
<MudText>@eventdefinition.SemifinalistActivity</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2">Notes</MudText>
|
||||||
|
<MudText>@eventdefinition.Notes</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2">Documentation</MudText>
|
||||||
|
<MudText>@eventdefinition.Documentation</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2">Eligibility</MudText>
|
||||||
|
<MudText>@eventdefinition.Eligibility</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2">Theme</MudText>
|
||||||
|
<MudText>@eventdefinition.Theme</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2">Description</MudText>
|
||||||
|
<MudText>@eventdefinition.Description</MudText>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}")" Variant="Variant.Filled" Color="Color.Primary">Edit</MudButton>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.ArrowBack" Href="/events" Variant="Variant.Text">Back to List</MudButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private EventDefinition? eventdefinition;
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery]
|
||||||
|
private int Id { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
eventdefinition = await context.Events.FirstOrDefaultAsync(m => m.Id == Id);
|
||||||
|
|
||||||
|
if (eventdefinition is null)
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("notfound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -1,4 +1,5 @@
|
|||||||
@page "/events/edit"
|
@page "/events/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@inject AppDbContext context
|
@inject AppDbContext context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
|
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
|
||||||
|
<AntiforgeryToken />
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="7">
|
<MudItem xs="12" sm="7">
|
||||||
@@ -18,17 +20,15 @@
|
|||||||
<MudTextField T="string" Label="Event Name" @bind-Value="EventDefinition.Name" For="@(() => EventDefinition.Name)"></MudTextField>
|
<MudTextField T="string" Label="Event Name" @bind-Value="EventDefinition.Name" For="@(() => EventDefinition.Name)"></MudTextField>
|
||||||
<MudTextField T="string" Label="Short Name" @bind-Value="EventDefinition.ShortName" For="@(() => EventDefinition.ShortName)"></MudTextField>
|
<MudTextField T="string" Label="Short Name" @bind-Value="EventDefinition.ShortName" For="@(() => EventDefinition.ShortName)"></MudTextField>
|
||||||
|
|
||||||
<label for="@EventDefinition.EventFormat" class="form-label">Format:</label>
|
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-2">Format</MudText>
|
||||||
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat">
|
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat" For="@(() => EventDefinition.EventFormat)">
|
||||||
@* <MudRadio T="EventFormat" Value="EventFormat.Team">Team</MudRadio>
|
|
||||||
<MudRadio T="EventFormat" Value="EventFormat.Individual">Individual</MudRadio> *@
|
|
||||||
@foreach (EventFormat format in Enum.GetValues(typeof(EventFormat)))
|
@foreach (EventFormat format in Enum.GetValues(typeof(EventFormat)))
|
||||||
{
|
{
|
||||||
<MudRadio T="EventFormat" value="@format">@(format.ToString())</MudRadio>
|
<MudRadio T="EventFormat" Value="@format">@format.ToString()</MudRadio>
|
||||||
}
|
}
|
||||||
</MudRadioGroup>
|
</MudRadioGroup>
|
||||||
<ValidationMessage For="() => EventDefinition.EventFormat" class="text-danger" />
|
|
||||||
<MudTextField T="string" Label="Theme" AutoGrow="true" @bind-Value="EventDefinition.Description" For="@(() => EventDefinition.Description)"></MudTextField>
|
<MudTextField T="string" Label="Description" AutoGrow="true" @bind-Value="EventDefinition.Description" For="@(() => EventDefinition.Description)" Class="mt-4"></MudTextField>
|
||||||
<MudTextField T="string" Label="Theme" AutoGrow="true" @bind-Value="EventDefinition.Theme" For="@(() => EventDefinition.Theme)"></MudTextField>
|
<MudTextField T="string" Label="Theme" AutoGrow="true" @bind-Value="EventDefinition.Theme" For="@(() => EventDefinition.Theme)"></MudTextField>
|
||||||
<MudTextField T="string" Label="Documentation" @bind-Value="EventDefinition.Documentation" For="@(() => EventDefinition.Documentation)"></MudTextField>
|
<MudTextField T="string" Label="Documentation" @bind-Value="EventDefinition.Documentation" For="@(() => EventDefinition.Documentation)"></MudTextField>
|
||||||
<MudNumericField T="int?" Label="Level of Effort" @bind-Value="EventDefinition.LevelOfEffort" For="@(() => EventDefinition.LevelOfEffort)"></MudNumericField>
|
<MudNumericField T="int?" Label="Level of Effort" @bind-Value="EventDefinition.LevelOfEffort" For="@(() => EventDefinition.LevelOfEffort)"></MudNumericField>
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
@page "/events"
|
@page "/events"
|
||||||
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@@ -83,6 +84,6 @@
|
|||||||
|
|
||||||
//_isRowBlocked = false;
|
//_isRowBlocked = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
_dataGrid.ReloadServerData();
|
await _dataGrid.ReloadServerData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@attribute [Authorize]
|
||||||
@page "/events/printout"
|
@page "/events/printout"
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
@page "/meeting-schedule"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using System.Text
|
||||||
|
@using Core.Calculation
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject AppDbContext Context
|
||||||
|
@inject ClipboardService ClipboardService
|
||||||
|
|
||||||
|
<PageTitle>@Configuration["ChapterSettings:Shortname"] TSA Schedule @Configuration["ChapterSettings:CompetitionYear"]</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h3">@Configuration["ChapterSettings:Shortname"] TSA Schedule @Configuration["ChapterSettings:CompetitionYear"]</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-5">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="7" sm="8" lg="9">
|
||||||
|
<MudText Typo="Typo.h4">Time Slots</MudText>
|
||||||
|
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="6" sm="3" lg="2">
|
||||||
|
<MudNumericField @bind-Value="_parameters.TimeSlots"
|
||||||
|
Label="Time Slots" Min="1" Max="4">
|
||||||
|
</MudNumericField>
|
||||||
|
</MudItem>
|
||||||
|
<MudFlexBreak/>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
|
||||||
|
<MudTooltip Text="Copy to Clipboard">
|
||||||
|
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudTable T="TeamScheduleTimeSlot" ServerData="SolveSchedule" @ref="_solutionData">
|
||||||
|
<HeaderContent>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" lg="6">
|
||||||
|
<ScheduledTeamsList TimeSlotName="@context.Name"
|
||||||
|
Teams="@context.Teams"
|
||||||
|
ScheduledTeams="@_scheduledTeams"
|
||||||
|
AbsentStudents="@_absentStudents"
|
||||||
|
StudentHasOverlaps="@context.StudentHasOverlaps"
|
||||||
|
OnToggleTeam="@ToggleRequiredTeam" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" lg="6">
|
||||||
|
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
||||||
|
AbsentStudents="@_absentStudents"
|
||||||
|
ScheduledTeams="@_scheduledTeams"
|
||||||
|
PossibleAdditions="@_possibleAdditions"
|
||||||
|
UnassignedTeams="@_solution.StudentUnassignedTeams"
|
||||||
|
OnToggleTeam="@ToggleRequiredTeam" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="5" sm="4" lg="3">
|
||||||
|
<MudStack>
|
||||||
|
<StudentTextBoxSelector Students="@_students"
|
||||||
|
@bind-SelectedStudents="_absentStudents"
|
||||||
|
Title="Absent Students"
|
||||||
|
Label="Search for absent students"
|
||||||
|
ShowFullName="true"/>
|
||||||
|
<MudDivider Class="my-4"/>
|
||||||
|
<TeamToggleSelector Teams="@_teams"
|
||||||
|
@bind-SelectedTeams="_scheduledTeams"
|
||||||
|
Title="Scheduled Teams"
|
||||||
|
ShowEventAttributes="true" />
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private Team[]? _teams;
|
||||||
|
private Student[]? _students;
|
||||||
|
MudTable<TeamScheduleTimeSlot> _solutionData;
|
||||||
|
private TeamSchedulerSolution _solution;
|
||||||
|
private TeamSchedulerOptions _parameters;
|
||||||
|
bool _isSolving;
|
||||||
|
private IEnumerable<Team> _scheduledTeams = [];
|
||||||
|
private IEnumerable<Student> _absentStudents = [];
|
||||||
|
private IEnumerable<Team> _possibleAdditions = [];
|
||||||
|
|
||||||
|
private void AddRegionals()
|
||||||
|
{
|
||||||
|
_scheduledTeams
|
||||||
|
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddHighLevelOfEffort()
|
||||||
|
{
|
||||||
|
_scheduledTeams
|
||||||
|
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveIndividual()
|
||||||
|
{
|
||||||
|
_scheduledTeams
|
||||||
|
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveLowLevelOfEffort()
|
||||||
|
{
|
||||||
|
_scheduledTeams
|
||||||
|
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Invert()
|
||||||
|
{
|
||||||
|
var rt = _scheduledTeams.ToArray();
|
||||||
|
_scheduledTeams
|
||||||
|
= _teams.Where(t => !rt.Contains(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reset()
|
||||||
|
{
|
||||||
|
_scheduledTeams = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleRequiredTeam(Team unassignedTeam)
|
||||||
|
{
|
||||||
|
if (_scheduledTeams.Contains(unassignedTeam))
|
||||||
|
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_scheduledTeams = _scheduledTeams.Concat(new[] { unassignedTeam });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_parameters =
|
||||||
|
new TeamSchedulerOptions(
|
||||||
|
2,
|
||||||
|
mustIncludeEvents:
|
||||||
|
[
|
||||||
|
// "Medical Technology", "Electrical Applications" , "RegionalTeam",
|
||||||
|
// ,"Dragster", "Flight"
|
||||||
|
],
|
||||||
|
extended:
|
||||||
|
[
|
||||||
|
// "Invention", "Construction Challenge", "Mechanical", "Mass", "Micro"
|
||||||
|
//"STEM"
|
||||||
|
//"Community", "Vlogging"// "Microcontroller"
|
||||||
|
],
|
||||||
|
omittedEvents:
|
||||||
|
[
|
||||||
|
// "Vlogging", "Junior", "Community Service Video", "Digital Photography",
|
||||||
|
// "STEM"
|
||||||
|
|
||||||
|
//"Leadership",// "Electrical", //"Construction"
|
||||||
|
// "Forensic",
|
||||||
|
//"CAD"
|
||||||
|
//"I&I Team 1", "I&I Team 2"//, "Website Design",
|
||||||
|
],
|
||||||
|
absentStudents:
|
||||||
|
[
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
_teams
|
||||||
|
= await Context.Teams
|
||||||
|
.Include(e => e.Event)
|
||||||
|
.Include(e => e.Students)
|
||||||
|
.OrderBy(e => e.Event.Name)
|
||||||
|
.ThenBy(e => e.Identifier)
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
_students =
|
||||||
|
await Context.Students
|
||||||
|
.Include(e => e.Teams)
|
||||||
|
.ThenInclude(e => e.Captain)
|
||||||
|
.Include(e => e.EventRankings)
|
||||||
|
.ThenInclude(e => e.EventDefinition)
|
||||||
|
.OrderBy(e => e.FirstName).ToArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
|
||||||
|
{
|
||||||
|
_isSolving = true;
|
||||||
|
|
||||||
|
// Check if there are any teams to schedule
|
||||||
|
if (!_scheduledTeams.Any())
|
||||||
|
{
|
||||||
|
_isSolving = false;
|
||||||
|
_solution = new TeamSchedulerSolution([], [], "No teams selected");
|
||||||
|
return new TableData<TeamScheduleTimeSlot> { Items = [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out absent students
|
||||||
|
var availableStudents = _students.Where(s => !_absentStudents.Contains(s)).ToArray();
|
||||||
|
|
||||||
|
// Check if there are any available students
|
||||||
|
if (availableStudents.Length == 0)
|
||||||
|
{
|
||||||
|
_isSolving = false;
|
||||||
|
_solution = new TeamSchedulerSolution([], [], "No available students");
|
||||||
|
return new TableData<TeamScheduleTimeSlot> { Items = [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parameters with absent student names
|
||||||
|
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
|
||||||
|
|
||||||
|
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents);
|
||||||
|
_solution = teamScheduler.Solve();
|
||||||
|
|
||||||
|
// Try recommendation strategies in priority order
|
||||||
|
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
||||||
|
var strategies = new[]
|
||||||
|
{
|
||||||
|
UnassignedScheduleStrategy.LevelOfEffort,
|
||||||
|
UnassignedScheduleStrategy.BiggestGroup,
|
||||||
|
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
||||||
|
UnassignedScheduleStrategy.IndividualEvents
|
||||||
|
};
|
||||||
|
|
||||||
|
_possibleAdditions = strategies
|
||||||
|
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
||||||
|
.FirstOrDefault(result => result.Any()) ?? [];
|
||||||
|
|
||||||
|
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
||||||
|
|
||||||
|
_isSolving = false;
|
||||||
|
return new TableData<TeamScheduleTimeSlot> { Items = _solution.TimeSlots };
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Solve()
|
||||||
|
{
|
||||||
|
_solutionData.ReloadServerData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task CopyToClipboard()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var timeslot in _solution.TimeSlots)
|
||||||
|
{
|
||||||
|
AppendScheduledTeams(sb, timeslot);
|
||||||
|
AppendUnscheduledStudents(sb, timeslot);
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ClipboardService.WriteTextAsync(sb.ToString());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Console.WriteLine("Cannot write text to clipboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
||||||
|
{
|
||||||
|
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
||||||
|
{
|
||||||
|
var teamName = scheduledTeam.ToString();
|
||||||
|
|
||||||
|
if (scheduledTeam.Event.EventFormat is EventFormat.Individual)
|
||||||
|
{
|
||||||
|
sb.Append(teamName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var studentsList = FormatStudentList(scheduledTeam, timeslot);
|
||||||
|
sb.Append($"{teamName} - {studentsList}");
|
||||||
|
}
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot)
|
||||||
|
{
|
||||||
|
return string.Join(", ",
|
||||||
|
team.Students
|
||||||
|
.OrderBy(e => e == team.Captain)
|
||||||
|
.ThenBy(e => e.FirstName)
|
||||||
|
.Select(e => FormatStudentName(e, timeslot)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
|
||||||
|
{
|
||||||
|
var name = student.FirstName;
|
||||||
|
if (timeslot.StudentHasOverlaps(student))
|
||||||
|
name += "*";
|
||||||
|
if (_absentStudents.Contains(student))
|
||||||
|
name += " (absent)";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendUnscheduledStudents(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
||||||
|
{
|
||||||
|
if (!timeslot.UnscheduledStudents.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
sb.Append("--Unscheduled");
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
|
||||||
|
foreach (var student in timeslot.UnscheduledStudents)
|
||||||
|
{
|
||||||
|
var studentName = student.FirstName;
|
||||||
|
if (_absentStudents.Contains(student))
|
||||||
|
studentName += " (absent)";
|
||||||
|
|
||||||
|
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
||||||
|
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
||||||
|
|
||||||
|
sb.Append($"{studentName} - {teamsList}");
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@using Core.Calculation
|
||||||
|
|
||||||
|
<MudStack>
|
||||||
|
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
|
||||||
|
@foreach (var team in Teams.OrderBy(e => e.ToString()))
|
||||||
|
{
|
||||||
|
var removed = !ScheduledTeams.Contains(team);
|
||||||
|
|
||||||
|
<MudLink Typo="Typo.body1"
|
||||||
|
Class="d-flex align-center"
|
||||||
|
Color="Color.Default"
|
||||||
|
OnClick="@(() => OnToggleTeam.InvokeAsync(team))">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
||||||
|
Size="Size.Small"
|
||||||
|
Class="@(removed ? "" : "d-none")">
|
||||||
|
</MudIcon>
|
||||||
|
@team -
|
||||||
|
@foreach (var student in team.Students)
|
||||||
|
{
|
||||||
|
var overlap = StudentHasOverlaps(student);
|
||||||
|
var isAbsent = AbsentStudents.Contains(student);
|
||||||
|
var color = overlap ? Color.Warning : Color.Default;
|
||||||
|
var suffix = GetStudentSuffix(overlap, isAbsent);
|
||||||
|
|
||||||
|
if (student != team.Students.First())
|
||||||
|
{
|
||||||
|
<MudText>, </MudText>
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.body2" Color="@color">
|
||||||
|
@student.FirstName@suffix
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string TimeSlotName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> Teams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> AbsentStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Team> OnToggleTeam { get; set; }
|
||||||
|
|
||||||
|
private string GetStudentSuffix(bool overlap, bool isAbsent)
|
||||||
|
{
|
||||||
|
var suffix = overlap ? "*" : "";
|
||||||
|
suffix += isAbsent ? " (absent)" : "";
|
||||||
|
return suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@using Core.Calculation
|
||||||
|
|
||||||
|
@if (UnscheduledStudents.Any())
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body1" HtmlTag="strong">Unscheduled</MudText>
|
||||||
|
<MudStack>
|
||||||
|
@foreach (var student in UnscheduledStudents)
|
||||||
|
{
|
||||||
|
var isAbsent = AbsentStudents.Contains(student);
|
||||||
|
<MudItem>
|
||||||
|
<MudText Typo="Typo.body1" HtmlTag="i">
|
||||||
|
@student.FirstName@(isAbsent ? " (absent)" : "")
|
||||||
|
</MudText>
|
||||||
|
@foreach (var unassignedTeam in UnassignedTeams(student))
|
||||||
|
{
|
||||||
|
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
||||||
|
var isScheduled = ScheduledTeams.Contains(unassignedTeam);
|
||||||
|
var color = isPossibleAddition ? Color.Success : Color.Default;
|
||||||
|
|
||||||
|
if (unassignedTeam != UnassignedTeams(student).First())
|
||||||
|
{
|
||||||
|
<span>, </span>
|
||||||
|
}
|
||||||
|
<MudLink Typo="Typo.body2"
|
||||||
|
Color="@color"
|
||||||
|
OnClick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Check"
|
||||||
|
Size="Size.Small"
|
||||||
|
Class="@(isScheduled ? "" : "d-none")">
|
||||||
|
</MudIcon>
|
||||||
|
@unassignedTeam
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> UnscheduledStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> AbsentStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> PossibleAdditions { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<Student, IEnumerable<Team>> UnassignedTeams { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Team> OnToggleTeam { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
@if (Title != null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h4">@Title</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudAutocomplete T="Student"
|
||||||
|
Label="@Label"
|
||||||
|
@bind-Value="_currentStudent"
|
||||||
|
SearchFunc="@SearchStudents"
|
||||||
|
ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.FirstName)"
|
||||||
|
Immediate="true"
|
||||||
|
ResetValueOnEmptyText="true"
|
||||||
|
CoerceText="false"
|
||||||
|
CoerceValue="false"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
Clearable="true">
|
||||||
|
<ItemTemplate Context="student">
|
||||||
|
@if (ShowFullName)
|
||||||
|
{
|
||||||
|
@student.FirstNameLastName
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@student.FirstName
|
||||||
|
}
|
||||||
|
@if (ShowGrade)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary"> - Grade @student.Grade</MudText>
|
||||||
|
}
|
||||||
|
</ItemTemplate>
|
||||||
|
</MudAutocomplete>
|
||||||
|
|
||||||
|
@if (SelectedStudents.Any())
|
||||||
|
{
|
||||||
|
<MudChipSet T="Student" AllClosable="true" Class="mt-2">
|
||||||
|
@foreach (var student in SelectedStudents.OrderBy(s => s.FirstName))
|
||||||
|
{
|
||||||
|
<MudChip T="Student"
|
||||||
|
Value="@student"
|
||||||
|
Text="@(ShowFullName ? student.FirstNameLastName : student.FirstName)"
|
||||||
|
OnClose="@(() => RemoveStudent(student))" />
|
||||||
|
}
|
||||||
|
</MudChipSet>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> Students { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> SelectedStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<IEnumerable<Student>> SelectedStudentsChanged { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string Label { get; set; } = "Search Students";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool ShowFullName { get; set; } = true;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool ShowGrade { get; set; } = false;
|
||||||
|
|
||||||
|
private Student? _currentStudent
|
||||||
|
{
|
||||||
|
get => _currentStudentValue;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_currentStudentValue = value;
|
||||||
|
if (value != null && !SelectedStudents.Contains(value))
|
||||||
|
{
|
||||||
|
var updatedList = SelectedStudents.Append(value).ToList();
|
||||||
|
SelectedStudentsChanged.InvokeAsync(updatedList);
|
||||||
|
_currentStudentValue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Student? _currentStudentValue;
|
||||||
|
|
||||||
|
private Task<IEnumerable<Student>> SearchStudents(string? searchText, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(searchText))
|
||||||
|
return Task.FromResult<IEnumerable<Student>>(Students.Where(s => !SelectedStudents.Contains(s)));
|
||||||
|
|
||||||
|
var search = searchText.ToLower();
|
||||||
|
return Task.FromResult<IEnumerable<Student>>(Students
|
||||||
|
.Where(s => !SelectedStudents.Contains(s))
|
||||||
|
.Where(s => s.FirstName.ToLower().Contains(search) ||
|
||||||
|
s.LastName.ToLower().Contains(search))
|
||||||
|
.OrderBy(s => s.FirstName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveStudent(Student student)
|
||||||
|
{
|
||||||
|
var updatedList = SelectedStudents.Where(s => s.Id != student.Id).ToList();
|
||||||
|
SelectedStudentsChanged.InvokeAsync(updatedList);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@if (Title != null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h4">@Title</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudToggleGroup T="Student"
|
||||||
|
SelectionMode="SelectionMode.MultiSelection"
|
||||||
|
Values="@SelectedStudents"
|
||||||
|
ValuesChanged="@OnSelectedStudentsChanged"
|
||||||
|
Vertical="true"
|
||||||
|
CheckMark>
|
||||||
|
@foreach (var student in Students.OrderBy(e => e.FirstName))
|
||||||
|
{
|
||||||
|
<MudToggleItem Value="@student" Style="font-size: .75rem;">
|
||||||
|
@if (ShowFullName)
|
||||||
|
{
|
||||||
|
@student.FirstNameLastName
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@student.FirstName
|
||||||
|
}
|
||||||
|
</MudToggleItem>
|
||||||
|
}
|
||||||
|
</MudToggleGroup>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> Students { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> SelectedStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<IEnumerable<Student>> SelectedStudentsChanged { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool ShowFullName { get; set; } = true;
|
||||||
|
|
||||||
|
private async Task OnSelectedStudentsChanged(IEnumerable<Student> value)
|
||||||
|
{
|
||||||
|
SelectedStudents = value;
|
||||||
|
await SelectedStudentsChanged.InvokeAsync(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -1,4 +1,5 @@
|
|||||||
@page "/students/create"
|
@page "/students/create"
|
||||||
|
@attribute [Authorize]
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<EditForm Model="Student" OnValidSubmit="OnValidSubmit" Enhance>
|
<EditForm Model="Student" OnValidSubmit="OnValidSubmit" Enhance>
|
||||||
|
<AntiforgeryToken />
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="7">
|
<MudItem xs="12" sm="7">
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
@page "/students/details"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using Core.Entities
|
||||||
|
@using Data
|
||||||
|
@inject AppDbContext context
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<PageTitle>Student Details - TSA Chapter Organizer</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h3">Details</MudText>
|
||||||
|
<MudText Typo="Typo.h4">Student</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
@if (student is null)
|
||||||
|
{
|
||||||
|
<MudText><em>Loading...</em></MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">First Name</MudText>
|
||||||
|
<MudText>@student.FirstName</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Last Name</MudText>
|
||||||
|
<MudText>@student.LastName</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Grade</MudText>
|
||||||
|
<MudText>@student.Grade</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Email</MudText>
|
||||||
|
<MudText>@student.Email</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Phone Number</MudText>
|
||||||
|
<MudText>@student.PhoneNumber</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">TSA Year</MudText>
|
||||||
|
<MudText>@student.TsaYear</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">State ID</MudText>
|
||||||
|
<MudText>@student.StateId</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Regional ID</MudText>
|
||||||
|
<MudText>@student.RegionalId</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">National ID</MudText>
|
||||||
|
<MudText>@student.NationalId</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudText Typo="Typo.subtitle2">Officer Role</MudText>
|
||||||
|
<MudText>@student.OfficerRole</MudText>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}")" Variant="Variant.Filled" Color="Color.Primary">Edit</MudButton>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.ArrowBack" Href="/students" Variant="Variant.Text">Back to List</MudButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private Student? student;
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery]
|
||||||
|
private int Id { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
student = await context.Students.FirstOrDefaultAsync(m => m.Id == Id);
|
||||||
|
|
||||||
|
if (student is null)
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("notfound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -1,4 +1,5 @@
|
|||||||
@page "/students/edit"
|
@page "/students/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@@ -19,6 +20,7 @@ else
|
|||||||
/* https://www.mudblazor.com/components/form */
|
/* https://www.mudblazor.com/components/form */
|
||||||
/* https://medium.com/@husainalbar/applying-mudblazor-for-crud-operations-in-our-blazor-project-a343037a52ef */
|
/* https://medium.com/@husainalbar/applying-mudblazor-for-crud-operations-in-our-blazor-project-a343037a52ef */
|
||||||
<EditForm method="post" Model="Student" OnValidSubmit="UpdateStudent" FormName="edit" Enhance>
|
<EditForm method="post" Model="Student" OnValidSubmit="UpdateStudent" FormName="edit" Enhance>
|
||||||
|
<AntiforgeryToken />
|
||||||
<DataAnnotationsValidator/>
|
<DataAnnotationsValidator/>
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="7">
|
<MudItem xs="12" sm="7">
|
||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@page "/students/event-ranking"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@page "/students/event-ranking"
|
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
+3
-2
@@ -1,7 +1,8 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@page "/students/event-ranking-edit/{StudentId:int}"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using BlazorSortableList
|
@using BlazorSortableList
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@page "/students/event-ranking-edit/{StudentId:int}"
|
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
@page "/students"
|
@page "/students"
|
||||||
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@@ -82,6 +83,6 @@
|
|||||||
|
|
||||||
//_isRowBlocked = false;
|
//_isRowBlocked = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
_dataGrid.ReloadServerData();
|
await _dataGrid.ReloadServerData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
@page "/teams/assignment"
|
@page "/teams/assignment"
|
||||||
|
@attribute [Authorize]
|
||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@@ -204,14 +205,14 @@
|
|||||||
}
|
}
|
||||||
@if (isIncluded)
|
@if (isIncluded)
|
||||||
{
|
{
|
||||||
<MudTooltip Title="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
|
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
|
||||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTooltip Title="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
|
<MudTooltip Text="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
|
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
|
||||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
@@ -219,14 +220,14 @@
|
|||||||
|
|
||||||
@if (isExcluded)
|
@if (isExcluded)
|
||||||
{
|
{
|
||||||
<MudTooltip Title="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
|
<MudTooltip Text="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
|
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
|
||||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTooltip Title="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
|
<MudTooltip Text="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
|
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
|
||||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@if (Title != null)
|
||||||
|
{
|
||||||
|
<MudText Typo="@TitleTypo">@Title</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudToggleGroup T="Student"
|
||||||
|
SelectionMode="SelectionMode.ToggleSelection"
|
||||||
|
Value="@SelectedCaptain"
|
||||||
|
ValueChanged="@OnSelectedCaptainChanged"
|
||||||
|
CheckMark>
|
||||||
|
@foreach (var student in Students.OrderBy(e => e.FirstName))
|
||||||
|
{
|
||||||
|
<MudToggleItem Value="@student" Text="@student.Name" />
|
||||||
|
}
|
||||||
|
</MudToggleGroup>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> Students { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Student? SelectedCaptain { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Student?> SelectedCaptainChanged { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Title { get; set; } = "Captain";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Typo TitleTypo { get; set; } = Typo.body1;
|
||||||
|
|
||||||
|
private async Task OnSelectedCaptainChanged(Student? value)
|
||||||
|
{
|
||||||
|
SelectedCaptain = value;
|
||||||
|
await SelectedCaptainChanged.InvokeAsync(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@if (Title != null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h4">@Title</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudToggleGroup T="Team"
|
||||||
|
SelectionMode="SelectionMode.MultiSelection"
|
||||||
|
Values="@SelectedTeams"
|
||||||
|
ValuesChanged="@OnSelectedTeamsChanged"
|
||||||
|
Vertical="true"
|
||||||
|
CheckMark>
|
||||||
|
@foreach (var team in Teams.OrderBy(e => e.Event.Name))
|
||||||
|
{
|
||||||
|
<MudToggleItem Value="@team" Style="font-size: .75rem;">
|
||||||
|
<MudTooltip Text="@team.StudentsFirstNames">
|
||||||
|
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||||
|
<MudText Class="ellipsis">@team.ToString()</MudText>
|
||||||
|
@if (ShowEventAttributes)
|
||||||
|
{
|
||||||
|
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudToggleItem>
|
||||||
|
}
|
||||||
|
</MudToggleGroup>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> Teams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> SelectedTeams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<IEnumerable<Team>> SelectedTeamsChanged { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool ShowEventAttributes { get; set; } = true;
|
||||||
|
|
||||||
|
private async Task OnSelectedTeamsChanged(IEnumerable<Team> value)
|
||||||
|
{
|
||||||
|
SelectedTeams = value;
|
||||||
|
await SelectedTeamsChanged.InvokeAsync(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
@page "/teams/create"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject AppDbContext Context
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<PageTitle>Create Team - TSA Chapter Organizer</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h3">Create</MudText>
|
||||||
|
<MudText Typo="Typo.h4">Team</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
<EditForm method="post" Model="Team" OnValidSubmit="AddTeam" FormName="create" Enhance>
|
||||||
|
<AntiforgeryToken />
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="7">
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudSelect T="EventDefinition" Value="@Team.Event" ValueChanged="OnEventChanged" Label="Event">
|
||||||
|
|
||||||
|
@foreach (var evt in _events)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="EventDefinition" Value="@(evt)"></MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (_existingTeams?.Count == 1)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="my-2">
|
||||||
|
A team for @Team.Event.Name already exists. This will be team 2, and the existing team will become team 1.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else if (_existingTeams?.Count >= 2)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="my-2">
|
||||||
|
Two teams for @Team.Event.Name already exist. Cannot create a third team.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
|
||||||
|
<StudentToggleSelector Students="@_students"
|
||||||
|
@bind-SelectedStudents="_selectedStudents"
|
||||||
|
Title="Students"
|
||||||
|
ShowFullName="true" />
|
||||||
|
|
||||||
|
<MudStack Class="mt-4">
|
||||||
|
<TeamCaptainSelector Students="@_selectedStudents"
|
||||||
|
@bind-SelectedCaptain="Team.Captain"
|
||||||
|
Title="Captain" />
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mt-3">@_errorMessage</MudAlert>
|
||||||
|
}
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.ArrowBack" Href="students">Back</MudButton>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Add" OnClick="AddTeam">Add</MudButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[SupplyParameterFromForm]
|
||||||
|
private Team Team { get; set; } = new();
|
||||||
|
|
||||||
|
private List<EventDefinition>? _events;
|
||||||
|
private List<Student> _students = [];
|
||||||
|
private IEnumerable<Student> _selectedStudents = [];
|
||||||
|
private string? _errorMessage;
|
||||||
|
private List<Team>? _existingTeams;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_events =
|
||||||
|
await Context.Events
|
||||||
|
.OrderBy(e => e.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
_students = await Context.Students.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEventChanged(EventDefinition selectedEvent)
|
||||||
|
{
|
||||||
|
Team.Event = selectedEvent;
|
||||||
|
|
||||||
|
// Clear any previous error messages
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
// Get all existing teams for this event
|
||||||
|
_existingTeams = await Context.Teams
|
||||||
|
.Include(t => t.Event)
|
||||||
|
.Where(t => t.Event.Id == selectedEvent.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddTeam()
|
||||||
|
{
|
||||||
|
// Clear previous error message
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
// Get current count of teams for this event
|
||||||
|
var existingTeamCount = await Context.Teams
|
||||||
|
.CountAsync(t => t.Event.Id == Team.Event.Id);
|
||||||
|
|
||||||
|
// Prohibit creation of third team
|
||||||
|
if (existingTeamCount >= 2)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Cannot create a third team for {Team.Event.Name}. Maximum of 2 teams allowed.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle automatic numbering based on event format
|
||||||
|
if (Team.Event.EventFormat == EventFormat.Individual && _selectedStudents.Count() == 1)
|
||||||
|
{
|
||||||
|
// For individual events, use student's first name as identifier
|
||||||
|
var student = _selectedStudents.First();
|
||||||
|
Team.Identifier = student.FirstName;
|
||||||
|
Team.Captain = student;
|
||||||
|
}
|
||||||
|
else if (existingTeamCount == 1)
|
||||||
|
{
|
||||||
|
// This is the second team - assign numbers
|
||||||
|
var existingTeam = await Context.Teams
|
||||||
|
.FirstOrDefaultAsync(t => t.Event.Id == Team.Event.Id);
|
||||||
|
|
||||||
|
if (existingTeam != null)
|
||||||
|
{
|
||||||
|
// Update existing team to number 1
|
||||||
|
existingTeam.Identifier = "1";
|
||||||
|
Context.Teams.Update(existingTeam);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new team to number 2
|
||||||
|
Team.Identifier = "2";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// This is the first team - no number
|
||||||
|
Team.Identifier = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected students to the team
|
||||||
|
foreach (var student in _selectedStudents)
|
||||||
|
{
|
||||||
|
Team.Students.Add(student);
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.Teams.Add(Team);
|
||||||
|
|
||||||
|
await Context.SaveChangesAsync();
|
||||||
|
NavigationManager.NavigateTo("/teams");
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-27
@@ -1,4 +1,5 @@
|
|||||||
@page "/teams/edit"
|
@page "/teams/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@@ -15,32 +16,20 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<EditForm method="post" Model="Team" OnValidSubmit="UpdateTeam" FormName="edit" Enhance>
|
<EditForm method="post" Model="Team" OnValidSubmit="UpdateTeam" FormName="edit" Enhance>
|
||||||
|
<AntiforgeryToken />
|
||||||
<DataAnnotationsValidator/>
|
<DataAnnotationsValidator/>
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="7">
|
<MudItem xs="12" sm="7">
|
||||||
<MudPaper Class="pa-4">
|
<MudPaper Class="pa-4">
|
||||||
<MudSelect
|
<StudentToggleSelector Students="@_students"
|
||||||
T="Student"
|
@bind-SelectedStudents="_selectedStudents"
|
||||||
MultiSelection="true"
|
Title="Students"
|
||||||
@bind-SelectedValues="@_selectedStudents"
|
ShowFullName="true" />
|
||||||
ToStringFunc="e => e.Name"
|
<MudStack Class="mt-4">
|
||||||
Label="Students">
|
<TeamCaptainSelector Students="@_selectedStudents"
|
||||||
|
@bind-SelectedCaptain="Team.Captain"
|
||||||
@foreach (var student in _students.OrderBy(e => e.FirstName))
|
Title="Captain" />
|
||||||
{
|
|
||||||
<MudSelectItem T="Student" Value="@student">@student.Name</MudSelectItem>
|
|
||||||
}
|
|
||||||
</MudSelect>
|
|
||||||
<MudStack>
|
|
||||||
<MudText>Captain</MudText>
|
|
||||||
<MudToggleGroup T="Student" SelectionMode="SelectionMode.ToggleSelection" @bind-Value="Team.Captain" CheckMark>
|
|
||||||
@foreach (var student in _selectedStudents.OrderBy(e => e.FirstName))
|
|
||||||
{
|
|
||||||
<MudToggleItem Value="@(student)" Text="@student.Name" />
|
|
||||||
}
|
|
||||||
</MudToggleGroup>
|
|
||||||
</MudStack>
|
</MudStack>
|
||||||
<MudTextField T="string?" Label="Identifier" @bind-Value="Team.Identifier" For="@(() => Team.Identifier)" Required="false" Clearable="true"></MudTextField>
|
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
@@ -56,7 +45,7 @@ else
|
|||||||
[SupplyParameterFromForm]
|
[SupplyParameterFromForm]
|
||||||
private Team? Team { get; set; }
|
private Team? Team { get; set; }
|
||||||
|
|
||||||
private IEnumerable<Student> _selectedStudents = new HashSet<Student>();
|
private IEnumerable<Student> _selectedStudents = [];
|
||||||
private List<Student> _students = [];
|
private List<Student> _students = [];
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
@@ -66,10 +55,7 @@ else
|
|||||||
.Include(e => e.Students)
|
.Include(e => e.Students)
|
||||||
.FirstOrDefaultAsync(m => m.Id == Id);
|
.FirstOrDefaultAsync(m => m.Id == Id);
|
||||||
_students = await Context.Students.ToListAsync();
|
_students = await Context.Students.ToListAsync();
|
||||||
foreach (var s in Team.Students)
|
_selectedStudents = Team.Students.ToList();
|
||||||
{
|
|
||||||
((HashSet<Student>)_selectedStudents).Add(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Team is null)
|
if (Team is null)
|
||||||
{
|
{
|
||||||
@@ -88,7 +74,7 @@ else
|
|||||||
// To protect from overposting attacks, enable the specific properties you want to bind to.
|
// To protect from overposting attacks, enable the specific properties you want to bind to.
|
||||||
// For more information, see https://learn.microsoft.com/aspnet/core/blazor/forms/#mitigate-overposting-attacks.
|
// For more information, see https://learn.microsoft.com/aspnet/core/blazor/forms/#mitigate-overposting-attacks.
|
||||||
private async Task UpdateTeam()
|
private async Task UpdateTeam()
|
||||||
{
|
{
|
||||||
//Context.Attach(Team!).Entity = EntityState.Modified;
|
//Context.Attach(Team!).Entity = EntityState.Modified;
|
||||||
Team.Students.Clear();
|
Team.Students.Clear();
|
||||||
foreach (var s in _selectedStudents)
|
foreach (var s in _selectedStudents)
|
||||||
@@ -96,6 +82,13 @@ else
|
|||||||
Team.Students.Add(s);
|
Team.Students.Add(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update identifier for individual events
|
||||||
|
if (Team.Event.EventFormat == EventFormat.Individual && Team.Students.Count == 1)
|
||||||
|
{
|
||||||
|
Team.Captain ??= Team.Students[0];
|
||||||
|
Team.Identifier = Team.Captain.FirstName;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Context.SaveChangesAsync();
|
await Context.SaveChangesAsync();
|
||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@page "/teams/handout"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@page "/teams/handout"
|
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
|
|
||||||
+18
-4
@@ -1,5 +1,6 @@
|
|||||||
@page "/teams"
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using Microsoft.EntityFrameworkCore
|
@page "/teams"
|
||||||
|
@attribute [Authorize]
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@@ -33,7 +34,6 @@
|
|||||||
<TemplateColumn>
|
<TemplateColumn>
|
||||||
<CellTemplate>
|
<CellTemplate>
|
||||||
<CrudActions
|
<CrudActions
|
||||||
DetailsHref="@($"/teams/details?id={context.Item!.Id}")"
|
|
||||||
EditHref="@($"/teams/edit?id={context.Item!.Id}")"
|
EditHref="@($"/teams/edit?id={context.Item!.Id}")"
|
||||||
DeleteOnClick="() => DeleteTeam(context.Item!)">
|
DeleteOnClick="() => DeleteTeam(context.Item!)">
|
||||||
</CrudActions>
|
</CrudActions>
|
||||||
@@ -82,6 +82,20 @@
|
|||||||
|
|
||||||
if (result == true)
|
if (result == true)
|
||||||
{
|
{
|
||||||
|
// If deleting a numbered team (1 or 2), clear the identifier of the remaining team
|
||||||
|
if (team.Identifier == "1" || team.Identifier == "2")
|
||||||
|
{
|
||||||
|
var remainingTeam = await Context.Teams
|
||||||
|
.Include(t => t.Event)
|
||||||
|
.FirstOrDefaultAsync(t => t.Event.Id == team.Event.Id && t.Id != team.Id);
|
||||||
|
|
||||||
|
if (remainingTeam != null)
|
||||||
|
{
|
||||||
|
remainingTeam.Identifier = null;
|
||||||
|
Context.Teams.Update(remainingTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Context.Teams.Remove(team!);
|
Context.Teams.Remove(team!);
|
||||||
await Context.SaveChangesAsync();
|
await Context.SaveChangesAsync();
|
||||||
Snackbar.Add($"Delete event: Delete of Team {team}", Severity.Info);
|
Snackbar.Add($"Delete event: Delete of Team {team}", Severity.Info);
|
||||||
@@ -89,6 +103,6 @@
|
|||||||
|
|
||||||
//_isRowBlocked = false;
|
//_isRowBlocked = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
_dataGrid.ReloadServerData();
|
await _dataGrid.ReloadServerData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@page "/teams/printout"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@page "/teams/printout"
|
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
@page "/events/details"
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@inject AppDbContext context
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<PageTitle>Details</PageTitle>
|
|
||||||
|
|
||||||
<h1>Details</h1>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>EventDefinition</h2>
|
|
||||||
<hr />
|
|
||||||
@if (eventdefinition is null)
|
|
||||||
{
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
<dl class="row">
|
|
||||||
<dt class="col-sm-2">Name</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.Name</dd>
|
|
||||||
<dt class="col-sm-2">ShortName</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.ShortName</dd>
|
|
||||||
<dt class="col-sm-2">EventFormat</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.EventFormat</dd>
|
|
||||||
<dt class="col-sm-2">MinTeamSize</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.MinTeamSize</dd>
|
|
||||||
<dt class="col-sm-2">MaxTeamSize</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.MaxTeamSize</dd>
|
|
||||||
<dt class="col-sm-2">SemifinalistActivity</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.SemifinalistActivity</dd>
|
|
||||||
<dt class="col-sm-2">Notes</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.Notes</dd>
|
|
||||||
<dt class="col-sm-2">ChapterEligibilityCountState</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.ChapterEligibilityCountState</dd>
|
|
||||||
<dt class="col-sm-2">RegionalEvent</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.RegionalEvent</dd>
|
|
||||||
<dt class="col-sm-2">RegionalPresubmit</dt>
|
|
||||||
@* <dd class="col-sm-10">@eventdefinition.RegionalPresubmit</dd> *@
|
|
||||||
<dt class="col-sm-2">Presubmission</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.Presubmission</dd>
|
|
||||||
<dt class="col-sm-2">StatePretesting</dt>
|
|
||||||
@* <dd class="col-sm-10">@eventdefinition.StatePretesting</dd> *@
|
|
||||||
<dt class="col-sm-2">StatePreliminaryRound</dt>
|
|
||||||
@* <dd class="col-sm-10">@eventdefinition.StatePreliminaryRound</dd> *@
|
|
||||||
<dt class="col-sm-2">Documentation</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.Documentation</dd>
|
|
||||||
<dt class="col-sm-2">Eligibility</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.Eligibility</dd>
|
|
||||||
<dt class="col-sm-2">Theme</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.Theme</dd>
|
|
||||||
<dt class="col-sm-2">Description</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.Description</dd>
|
|
||||||
<dt class="col-sm-2">LevelOfEffort</dt>
|
|
||||||
<dd class="col-sm-10">@eventdefinition.LevelOfEffort</dd>
|
|
||||||
</dl>
|
|
||||||
<div>
|
|
||||||
<a href="@($"/eventdefinitions/edit?id={eventdefinition.Id}")">Edit</a> |
|
|
||||||
<a href="@($"/events")">Back to List</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private EventDefinition? eventdefinition;
|
|
||||||
|
|
||||||
[SupplyParameterFromQuery]
|
|
||||||
private int Id { get; set; }
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
eventdefinition = await context.Events.FirstOrDefaultAsync(m => m.Id == Id);
|
|
||||||
|
|
||||||
if (eventdefinition is null)
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo("notfound");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@attribute [Authorize]
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
|
|
||||||
<PageTitle>Home</PageTitle>
|
<PageTitle>Home</PageTitle>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
@page "/import"
|
@page "/import"
|
||||||
|
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||||
@using Core.Parsers
|
@using Core.Parsers
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using WebApp.Authentication
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
|
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
@using System.Text
|
|
||||||
@using Core.Calculation
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@page "/meeting-schedule"
|
|
||||||
@inject IConfiguration Configuration
|
|
||||||
@inject AppDbContext Context
|
|
||||||
@inject ClipboardService ClipboardService
|
|
||||||
|
|
||||||
<PageTitle>@Configuration["ChapterSettings:Shortname"] TSA Schedule @Configuration["ChapterSettings:CompetitionYear"]</PageTitle>
|
|
||||||
|
|
||||||
<MudText Typo="Typo.h3">@Configuration["ChapterSettings:Shortname"] TSA Schedule @Configuration["ChapterSettings:CompetitionYear"]</MudText>
|
|
||||||
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mt-5">
|
|
||||||
@* <MudText>Include: @string.Join(", ", _scheduledTeams) </MudText> *@
|
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="6" lg="9">
|
|
||||||
<MudText Typo="Typo.h4">Time Slots</MudText>
|
|
||||||
<MudNumericField @bind-Value="_parameters.TimeSlots"
|
|
||||||
Label="Time Slots" Min="1" Max="4"></MudNumericField>
|
|
||||||
<MudButton Class="ma-3" OnClick="Solve" Variant="Variant.Filled" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
|
|
||||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy">
|
|
||||||
</MudIconButton>
|
|
||||||
<MudTable T="Team[]" ServerData="SolveSchedule" @ref="_solutionData">
|
|
||||||
<HeaderContent>
|
|
||||||
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd>
|
|
||||||
|
|
||||||
@{
|
|
||||||
var overlaps
|
|
||||||
= TeamSchedulerSolution.GetStudentTeamOverlaps(context).ToArray();
|
|
||||||
}
|
|
||||||
@foreach (var team in context.OrderBy(e => e.ToString()))
|
|
||||||
{
|
|
||||||
var removed = !_scheduledTeams.Contains(team);
|
|
||||||
<MudItem>
|
|
||||||
<MudLink Typo="Typo.body1"
|
|
||||||
Class="d-flex align-center"
|
|
||||||
Color="Color.Default"
|
|
||||||
OnClick="@(() => ToggleRequiredTeam(team))">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
|
||||||
Size="Size.Small"
|
|
||||||
Class="@(removed ? "" : "d-none")"></MudIcon>
|
|
||||||
@team -
|
|
||||||
@foreach (var student in team.Students)
|
|
||||||
{
|
|
||||||
var overlap = overlaps.Any(o => o.Item1.Equals(student));
|
|
||||||
var color = overlap ? Color.Warning : Color.Default;
|
|
||||||
|
|
||||||
<MudText
|
|
||||||
Typo="Typo.body2"
|
|
||||||
Class="d-inline-block ml-3"
|
|
||||||
Color="color">
|
|
||||||
@student.FirstName@(overlap ? "*" : "")
|
|
||||||
</MudText>
|
|
||||||
}
|
|
||||||
</MudLink>
|
|
||||||
|
|
||||||
|
|
||||||
</MudItem>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</MudTd>
|
|
||||||
<MudTd>
|
|
||||||
@{
|
|
||||||
var unscheduled = TeamSchedulerSolution.GetStudentsNotInTimSlot(context, _students);
|
|
||||||
}
|
|
||||||
@if (unscheduled.Any())
|
|
||||||
{
|
|
||||||
|
|
||||||
<MudItem>Unscheduled</MudItem>
|
|
||||||
foreach (var student in unscheduled)
|
|
||||||
{
|
|
||||||
<MudItem Class="">
|
|
||||||
<MudText Typo="Typo.body1" HtmlTag="i">@student.FirstName </MudText>
|
|
||||||
@{
|
|
||||||
var pa = _possibleAdditions.ToArray();
|
|
||||||
}
|
|
||||||
@foreach (var unassignedTeam in _solution.StudentUnassignedTeams(student))
|
|
||||||
{
|
|
||||||
var color = pa.Contains(unassignedTeam) ? Color.Error : Color.Default;
|
|
||||||
var added = _scheduledTeams.Contains(unassignedTeam);
|
|
||||||
<MudLink Typo="Typo.body2"
|
|
||||||
Class="d-flex align-center ml-3"
|
|
||||||
Color="@color"
|
|
||||||
OnClick="@(() => ToggleRequiredTeam(unassignedTeam))">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Check"
|
|
||||||
Size="Size.Small"
|
|
||||||
Class="@(added ? "" : "d-none")"></MudIcon>
|
|
||||||
@unassignedTeam
|
|
||||||
</MudLink>
|
|
||||||
}
|
|
||||||
</MudItem>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6" lg="3">
|
|
||||||
|
|
||||||
<MudStack>
|
|
||||||
|
|
||||||
<MudButton OnClick="() => AddHighLevelOfEffort()">Add High Effort</MudButton>
|
|
||||||
<MudButton OnClick="() => AddRegionals()">Add Regionals</MudButton>
|
|
||||||
|
|
||||||
<MudButton OnClick="() => RemoveIndividual()">Remove Individual</MudButton>
|
|
||||||
<MudButton OnClick="() => RemoveLowLevelOfEffort()">Remove Low Effort</MudButton>
|
|
||||||
<MudButton OnClick="() => Invert()">Invert</MudButton>
|
|
||||||
|
|
||||||
<MudItem>@string.Join(", ", (_possibleAdditions ?? []).Select(e => e.ToString()))</MudItem>
|
|
||||||
|
|
||||||
<MudToggleGroup T="Team"
|
|
||||||
SelectionMode="SelectionMode.MultiSelection"
|
|
||||||
@bind-Values="_scheduledTeams"
|
|
||||||
Vertical="true"
|
|
||||||
CheckMark>
|
|
||||||
@foreach (var team in _teams.OrderBy(e => e.Event.Name))
|
|
||||||
{
|
|
||||||
<MudToggleItem Value="@team" Style="font-size: .75rem;">
|
|
||||||
<MudTooltip Text="@string.Join(", ", team.Students.Select(s => s.FirstName))">
|
|
||||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
|
||||||
<MudText Class="ellipsis">@team.ToString()</MudText>
|
|
||||||
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
|
|
||||||
</div>
|
|
||||||
</MudTooltip>
|
|
||||||
</MudToggleItem>
|
|
||||||
}
|
|
||||||
</MudToggleGroup>
|
|
||||||
</MudStack>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
</MudGrid>
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private Team[]? _teams;
|
|
||||||
private Student[]? _students;
|
|
||||||
MudTable<Team[]> _solutionData;
|
|
||||||
private TeamSchedulerSolution _solution;
|
|
||||||
private TeamSchedulerOptions _parameters;
|
|
||||||
bool _isSolving;
|
|
||||||
private IEnumerable<Team> _scheduledTeams = [];
|
|
||||||
private IEnumerable<Team> _possibleAdditions = [];
|
|
||||||
|
|
||||||
private async Task AddRegionals()
|
|
||||||
{
|
|
||||||
_scheduledTeams
|
|
||||||
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddHighLevelOfEffort()
|
|
||||||
{
|
|
||||||
_scheduledTeams
|
|
||||||
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RemoveIndividual()
|
|
||||||
{
|
|
||||||
_scheduledTeams
|
|
||||||
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RemoveLowLevelOfEffort()
|
|
||||||
{
|
|
||||||
_scheduledTeams
|
|
||||||
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Invert()
|
|
||||||
{
|
|
||||||
var rt = _scheduledTeams.ToArray();
|
|
||||||
_scheduledTeams
|
|
||||||
= _teams.Where(t => !rt.Contains(t));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleRequiredTeam(Team unassignedTeam)
|
|
||||||
{
|
|
||||||
if (_scheduledTeams.Contains(unassignedTeam))
|
|
||||||
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_scheduledTeams = _scheduledTeams.Concat(new[] { unassignedTeam });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
_parameters =
|
|
||||||
new TeamSchedulerOptions(
|
|
||||||
timeSlots: 2,
|
|
||||||
mustIncludeEvents:
|
|
||||||
[
|
|
||||||
// "Medical Technology", "Electrical Applications" , "RegionalTeam",
|
|
||||||
// ,"Dragster", "Flight"
|
|
||||||
],
|
|
||||||
extended:
|
|
||||||
[
|
|
||||||
// "Invention", "Construction Challenge", "Mechanical", "Mass", "Micro"
|
|
||||||
//"STEM"
|
|
||||||
//"Community", "Vlogging"// "Microcontroller"
|
|
||||||
],
|
|
||||||
omittedEvents:
|
|
||||||
[
|
|
||||||
// "Vlogging", "Junior", "Community Service Video", "Digital Photography",
|
|
||||||
// "STEM"
|
|
||||||
|
|
||||||
//"Leadership",// "Electrical", //"Construction"
|
|
||||||
// "Forensic",
|
|
||||||
//"CAD"
|
|
||||||
//"I&I Team 1", "I&I Team 2"//, "Website Design",
|
|
||||||
],
|
|
||||||
absentStudents:
|
|
||||||
[
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
_teams
|
|
||||||
= await Context.Teams
|
|
||||||
.Include(e => e.Event)
|
|
||||||
.Include(e => e.Students)
|
|
||||||
.OrderBy(e => e.Event.Name)
|
|
||||||
.ThenBy(e => e.Identifier)
|
|
||||||
.ToArrayAsync();
|
|
||||||
|
|
||||||
_students =
|
|
||||||
await Context.Students
|
|
||||||
.Include(e => e.Teams)
|
|
||||||
.ThenInclude(e => e.Captain)
|
|
||||||
.Include(e => e.EventRankings)
|
|
||||||
.ThenInclude(e => e.EventDefinition)
|
|
||||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<TableData<Team[]>> SolveSchedule(TableState arg1, CancellationToken arg2)
|
|
||||||
{
|
|
||||||
_isSolving = true;
|
|
||||||
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots);
|
|
||||||
|
|
||||||
// teamScheduler
|
|
||||||
// .ScheduleSeparate(
|
|
||||||
// _teams.First(e => e.Event.Name.Contains("Data Science")),
|
|
||||||
// _teams.First(e => e.Event.Name.Contains("Microcontroller Design"))
|
|
||||||
// );
|
|
||||||
|
|
||||||
_solution = teamScheduler.Solve();
|
|
||||||
|
|
||||||
var loe = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.LevelOfEffort);
|
|
||||||
var biggest = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.BiggestGroup);
|
|
||||||
var individual = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.IndividualEvents);
|
|
||||||
var anyNotMeetingAlready = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.AnyNotMeetingAlready);
|
|
||||||
|
|
||||||
_possibleAdditions = loe;
|
|
||||||
if (!_possibleAdditions.Any())
|
|
||||||
_possibleAdditions = biggest;
|
|
||||||
if (!_possibleAdditions.Any())
|
|
||||||
_possibleAdditions = anyNotMeetingAlready;
|
|
||||||
if (!_possibleAdditions.Any())
|
|
||||||
_possibleAdditions = individual;
|
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
|
||||||
|
|
||||||
_isSolving = false;
|
|
||||||
return new TableData<Team[]> { Items = _solution.TimeSlots};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Solve()
|
|
||||||
{
|
|
||||||
_solutionData.ReloadServerData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task CopyToClipboard()
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
foreach (var timeslot in _solution.TimeSlots)
|
|
||||||
{
|
|
||||||
var overlaps
|
|
||||||
= TeamSchedulerSolution.GetStudentTeamOverlaps(timeslot).Select(e => e.Item1).ToArray();
|
|
||||||
foreach (var scheduledTeam in timeslot.OrderBy(e => e.ToString()))
|
|
||||||
{
|
|
||||||
var t = scheduledTeam.ToString();
|
|
||||||
var s =
|
|
||||||
string.Join(", ",
|
|
||||||
scheduledTeam.Students
|
|
||||||
.OrderBy(e => e == scheduledTeam.Captain)
|
|
||||||
.ThenBy(e => e.FirstName)
|
|
||||||
.Select(e => e.FirstName + (overlaps.Contains(e) ? "*": "")));
|
|
||||||
|
|
||||||
if (scheduledTeam.Event.EventFormat is EventFormat.Individual)
|
|
||||||
sb.Append(t);
|
|
||||||
else
|
|
||||||
sb.Append($"{t} - {s}");
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
}
|
|
||||||
var unscheduled = TeamSchedulerSolution.GetStudentsNotInTimSlot(timeslot, _students);
|
|
||||||
|
|
||||||
if (unscheduled.Any())
|
|
||||||
{
|
|
||||||
sb.Append("--Unscheduled");
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
foreach (var student in unscheduled)
|
|
||||||
{
|
|
||||||
var s = student.FirstName;
|
|
||||||
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
|
||||||
var t = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
|
||||||
|
|
||||||
sb.Append($"{s} - {t}");
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ClipboardService.WriteTextAsync(sb.ToString());
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Console.WriteLine("Cannot write text to clipboard");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
@page "/students/details"
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using Core.Entities
|
|
||||||
@using Data
|
|
||||||
@inject AppDbContext context
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<PageTitle>Student Details - TSA Chapter Organizer</PageTitle>
|
|
||||||
|
|
||||||
<h1>Details</h1>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>Student</h2>
|
|
||||||
<hr />
|
|
||||||
@if (student is null)
|
|
||||||
{
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
<dl class="row">
|
|
||||||
<dt class="col-sm-2">FirstName</dt>
|
|
||||||
<dd class="col-sm-10">@student.FirstName</dd>
|
|
||||||
<dt class="col-sm-2">LastName</dt>
|
|
||||||
<dd class="col-sm-10">@student.LastName</dd>
|
|
||||||
<dt class="col-sm-2">Grade</dt>
|
|
||||||
<dd class="col-sm-10">@student.Grade</dd>
|
|
||||||
<dt class="col-sm-2">Email</dt>
|
|
||||||
<dd class="col-sm-10">@student.Email</dd>
|
|
||||||
<dt class="col-sm-2">PhoneNumber</dt>
|
|
||||||
<dd class="col-sm-10">@student.PhoneNumber</dd>
|
|
||||||
<dt class="col-sm-2">TsaYear</dt>
|
|
||||||
<dd class="col-sm-10">@student.TsaYear</dd>
|
|
||||||
<dt class="col-sm-2">StateId</dt>
|
|
||||||
<dd class="col-sm-10">@student.StateId</dd>
|
|
||||||
<dt class="col-sm-2">RegionalId</dt>
|
|
||||||
<dd class="col-sm-10">@student.RegionalId</dd>
|
|
||||||
<dt class="col-sm-2">NationalId</dt>
|
|
||||||
<dd class="col-sm-10">@student.NationalId</dd>
|
|
||||||
<dt class="col-sm-2">OfficerRole</dt>
|
|
||||||
<dd class="col-sm-10">@student.OfficerRole</dd>
|
|
||||||
</dl>
|
|
||||||
<div>
|
|
||||||
<a href="@($"/students/edit?id={student.Id}")">Edit</a> |
|
|
||||||
<a href="@($"/students")">Back to List</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private Student? student;
|
|
||||||
|
|
||||||
[SupplyParameterFromQuery]
|
|
||||||
private int Id { get; set; }
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
student = await context.Students.FirstOrDefaultAsync(m => m.Id == Id);
|
|
||||||
|
|
||||||
if (student is null)
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo("notfound");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
@page "/teams/create"
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@inject AppDbContext Context
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<PageTitle>Create Team - TSA Chapter Organizer</PageTitle>
|
|
||||||
|
|
||||||
<MudText Typo="Typo.h3">Create</MudText>
|
|
||||||
<MudText Typo="Typo.h4">Team</MudText>
|
|
||||||
<MudDivider />
|
|
||||||
|
|
||||||
<EditForm method="post" Model="Team" OnValidSubmit="AddTeam" FormName="create" Enhance>
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="12" sm="7">
|
|
||||||
<MudPaper Class="pa-4">
|
|
||||||
<MudSelect T="EventDefinition" @bind-Value="@Team.Event" Label="Event">
|
|
||||||
|
|
||||||
@foreach (var evt in _events)
|
|
||||||
{
|
|
||||||
<MudSelectItem T="EventDefinition" Value="@(evt)"></MudSelectItem>
|
|
||||||
}
|
|
||||||
</MudSelect>
|
|
||||||
<MudTextField T="string?" Label="Number" @bind-Value="Team.Identifier" For="@(() => Team.Identifier)"></MudTextField>
|
|
||||||
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.ArrowBack" Href="students">Back</MudButton>
|
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Add" OnClick="AddTeam">Add</MudButton>
|
|
||||||
</EditForm>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[SupplyParameterFromForm]
|
|
||||||
private Team Team { get; set; } = new();
|
|
||||||
|
|
||||||
private List<EventDefinition>? _events;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
_events =
|
|
||||||
await Context.Events
|
|
||||||
.OrderBy(e => e.Name)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddTeam()
|
|
||||||
{
|
|
||||||
Team.Identifier = Team.Event.Name;
|
|
||||||
Context.Teams.Add(Team);
|
|
||||||
|
|
||||||
await Context.SaveChangesAsync();
|
|
||||||
NavigationManager.NavigateTo("/teams");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
<Router AppAssembly="typeof(Program).Assembly">
|
@inject NavigationManager navigationManager
|
||||||
|
|
||||||
|
<Router AppAssembly="typeof(Program).Assembly">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
<NotAuthorized>
|
||||||
|
@{
|
||||||
|
navigationManager.NavigateTo("/login", true);
|
||||||
|
}
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
||||||
</Found>
|
</Found>
|
||||||
</Router>
|
<NotFound>
|
||||||
|
@{
|
||||||
|
navigationManager.NavigateTo("/login", true);
|
||||||
|
}
|
||||||
|
</NotFound>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ChildContent>
|
||||||
|
@ChildContent
|
||||||
|
</ChildContent>
|
||||||
|
<ErrorContent Context="ex">
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||||
|
<MudPaper Elevation="3" Class="pa-6">
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Error" Class="mr-2" />
|
||||||
|
An Error Occurred
|
||||||
|
</MudText>
|
||||||
|
@if (ShowDetails)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mt-4">
|
||||||
|
<strong>Error:</strong> @ex.Message
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-2">
|
||||||
|
<strong>Stack Trace:</strong>
|
||||||
|
<pre style="overflow-x: auto;">@ex.StackTrace</pre>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
Something went wrong. Please try refreshing the page or contact support if the problem persists.
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudAlert>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Class="mt-4"
|
||||||
|
OnClick="@(() => Navigation.NavigateTo("/", forceLoad: true))">
|
||||||
|
Return to Home
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
</MudContainer>
|
||||||
|
</ErrorContent>
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Inject] private IWebHostEnvironment Environment { get; set; } = default!;
|
||||||
|
[Inject] private NavigationManager Navigation { get; set; } = default!;
|
||||||
|
[Inject] private ILogger<AppErrorBoundary> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private bool ShowDetails => Environment.IsDevelopment();
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
Logger.LogError("Error boundary triggered");
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -16,7 +16,7 @@
|
|||||||
<MudTooltip Text="Delete">
|
<MudTooltip Text="Delete">
|
||||||
<MudIconButton Icon="@Icons.Material.Outlined.Delete"
|
<MudIconButton Icon="@Icons.Material.Outlined.Delete"
|
||||||
Color="Color.Error"
|
Color="Color.Error"
|
||||||
OnClick="() => DeleteOnClick()"/>
|
OnClick="HandleDeleteClick"/>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
}
|
}
|
||||||
</MudButtonGroup>
|
</MudButtonGroup>
|
||||||
@@ -25,5 +25,13 @@
|
|||||||
|
|
||||||
[Parameter] public string? DetailsHref { get; set; }
|
[Parameter] public string? DetailsHref { get; set; }
|
||||||
[Parameter] public string? EditHref { get; set; }
|
[Parameter] public string? EditHref { get; set; }
|
||||||
[Parameter] public Action? DeleteOnClick { get; set; }
|
[Parameter] public Func<Task>? DeleteOnClick { get; set; }
|
||||||
|
|
||||||
|
private async Task HandleDeleteClick()
|
||||||
|
{
|
||||||
|
if (DeleteOnClick != null)
|
||||||
|
{
|
||||||
|
await DeleteOnClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using MudBlazor;
|
using MudBlazor;
|
||||||
|
|
||||||
namespace WebApp.Components.Layout
|
namespace WebApp.Components.Shared.Layout
|
||||||
{
|
{
|
||||||
public static class CustomThemes
|
public static class CustomThemes
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<MudThemeProvider Theme="CustomThemes.Ceruleantheme" />
|
||||||
|
<MudPopoverProvider />
|
||||||
|
<MudDialogProvider />
|
||||||
|
|
||||||
|
@Body
|
||||||
+8
@@ -9,6 +9,14 @@
|
|||||||
<MudAppBar Class="no-print">
|
<MudAppBar Class="no-print">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@((e) => DrawerToggle())" />
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@((e) => DrawerToggle())" />
|
||||||
TSA Chapter Organizer - @Configuration["ChapterSettings:Name"]
|
TSA Chapter Organizer - @Configuration["ChapterSettings:Name"]
|
||||||
|
<MudSpacer />
|
||||||
|
<AuthorizeView>
|
||||||
|
<MudText Typo="Typo.body2">Logged in</MudText>
|
||||||
|
|
||||||
|
<form action="Auth/CookieLogout" method="post">
|
||||||
|
<button type="submit" class="btn btn-primary">Logout</button>
|
||||||
|
</form>
|
||||||
|
</AuthorizeView>
|
||||||
</MudAppBar>
|
</MudAppBar>
|
||||||
<MudDrawer @bind-Open="@_drawerOpen" Class="no-print">
|
<MudDrawer @bind-Open="@_drawerOpen" Class="no-print">
|
||||||
<NavMenu/>
|
<NavMenu/>
|
||||||
+1
-1
@@ -20,4 +20,4 @@
|
|||||||
<MudNavLink Href="/teams/assignment" Icon="@AppIcons.TeamAssignment">Team Assignment</MudNavLink>
|
<MudNavLink Href="/teams/assignment" Icon="@AppIcons.TeamAssignment">Team Assignment</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
@using System.Net.Http
|
@using System.Net.Http
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@@ -8,6 +10,18 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using WebApp
|
@using WebApp
|
||||||
@using WebApp.Components
|
@using WebApp.Components
|
||||||
|
@using WebApp.Components.Pages
|
||||||
|
@using WebApp.Components.Shared
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Components.Shared.Layout
|
||||||
|
@using WebApp.Components.Features.Authentication
|
||||||
|
@using WebApp.Components.Features.Students
|
||||||
|
@using WebApp.Components.Features.Students.Components
|
||||||
|
@using WebApp.Components.Features.Teams
|
||||||
|
@using WebApp.Components.Features.Teams.Components
|
||||||
|
@using WebApp.Components.Features.Events
|
||||||
|
@using WebApp.Components.Features.Events.Components
|
||||||
|
@using WebApp.Components.Features.MeetingSchedule
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
@using Core.Entities
|
@using Core.Entities
|
||||||
@using Data
|
@using Data
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+115
-2
@@ -1,12 +1,42 @@
|
|||||||
using Data;
|
using Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
|
using Serilog;
|
||||||
using WebApp;
|
using WebApp;
|
||||||
|
using WebApp.Authentication;
|
||||||
using WebApp.Components;
|
using WebApp.Components;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Configure Serilog
|
||||||
|
builder.Host.UseSerilog((context, configuration) =>
|
||||||
|
configuration
|
||||||
|
.ReadFrom.Configuration(context.Configuration)
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.WriteTo.Console(
|
||||||
|
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
|
||||||
|
.WriteTo.File(
|
||||||
|
path: "logs/webapp-.txt",
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 30,
|
||||||
|
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"));
|
||||||
|
|
||||||
|
// Configure authentication secrets for production (Docker, etc.)
|
||||||
|
if (builder.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
// Option 1: Load from volume-mounted secrets file
|
||||||
|
var secretsPath = "/app/secrets/auth-secrets.json";
|
||||||
|
if (File.Exists(secretsPath))
|
||||||
|
{
|
||||||
|
builder.Configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 2: Environment variables with prefix
|
||||||
|
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
|
||||||
|
}
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
|
builder.Services.AddControllersWithViews();
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
@@ -22,11 +52,72 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
|||||||
|
|
||||||
builder.Services.AddScoped<ClipboardService>();
|
builder.Services.AddScoped<ClipboardService>();
|
||||||
|
|
||||||
builder.Services.AddScoped<StateContainer>(); // Server- side
|
// State container for maintaining state per user connection (Blazor Server)
|
||||||
builder.Services.AddSingleton<StateContainer>();//Client-side
|
builder.Services.AddScoped<StateContainer>();
|
||||||
|
|
||||||
|
// Add authentication services
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<AuthenticationService>();
|
||||||
|
builder.Services.AddSingleton<LoginRateLimitService>();
|
||||||
|
builder.Services.AddHostedService<LoginRateLimitService>(sp => sp.GetRequiredService<LoginRateLimitService>());
|
||||||
|
|
||||||
|
// Add authentication options
|
||||||
|
builder.Services.AddAuthentication("Auth")
|
||||||
|
.AddCookie("Auth", options =>
|
||||||
|
{
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromMinutes(20);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
options.LoginPath = "/login";
|
||||||
|
|
||||||
|
// Enhanced security settings
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
|
options.Cookie.Name = "TSA.Auth";
|
||||||
|
});
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Check and apply database migrations
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = services.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
// Auto-apply migrations in development
|
||||||
|
logger.LogInformation("Applying database migrations (Development)...");
|
||||||
|
await context.Database.MigrateAsync();
|
||||||
|
logger.LogInformation("Database migrations applied successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Check if migrations are needed in production
|
||||||
|
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
|
||||||
|
if (pendingMigrations.Any())
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
"Database has pending migrations: {Migrations}",
|
||||||
|
string.Join(", ", pendingMigrations));
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Database migrations are pending. Run migrations before starting the application.");
|
||||||
|
}
|
||||||
|
logger.LogInformation("Database is up to date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "An error occurred while checking database migrations");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -37,6 +128,10 @@ if (!app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseRouting();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
@@ -44,4 +139,22 @@ app.UseAntiforgery();
|
|||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
// Used for AuthController
|
||||||
|
app.MapControllerRoute("default", "{controller}/{action}");
|
||||||
|
|
||||||
|
// Development-only password hash generator endpoint
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapGet("/dev/hash-password", (string password) =>
|
||||||
|
{
|
||||||
|
var hash = PasswordHashGenerator.GenerateHash(password);
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
password,
|
||||||
|
hash,
|
||||||
|
message = "Copy the hash value to your User Secrets configuration"
|
||||||
|
});
|
||||||
|
}).WithName("GeneratePasswordHash");
|
||||||
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="BlazorSortableList" Version="2.1.0" />
|
<PackageReference Include="BlazorSortableList" Version="2.1.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter" Version="9.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter" Version="9.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
|
||||||
@@ -25,7 +27,10 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
||||||
<PackageReference Include="MudBlazor" Version="8.12.0" />
|
<PackageReference Include="MudBlazor" Version="8.14.0" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4,5 +4,15 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"SQLiteDefault": "Data Source=data.db"
|
"SQLiteDefault": "Data Source=data.db"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"Authentication": {
|
||||||
|
"Users": [
|
||||||
|
{
|
||||||
|
"Email": "admin@example.com",
|
||||||
|
"PasswordHash": "$2a$11$REPLACE_WITH_ACTUAL_HASH",
|
||||||
|
"Role": "Administrator",
|
||||||
|
"DisplayName": "Administrator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Email": "advisor@example.com",
|
||||||
|
"PasswordHash": "$2a$11$REPLACE_WITH_ACTUAL_HASH",
|
||||||
|
"Role": "Advisor",
|
||||||
|
"DisplayName": "Chapter Advisor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
webapp:
|
||||||
|
image: tsa-chapter-organizer:latest
|
||||||
|
container_name: tsa-app
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "8081:8081"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
- ASPNETCORE_URLS=https://+:8081;http://+:8080
|
||||||
|
- ASPNETCORE_HTTPS_PORT=8081
|
||||||
|
|
||||||
|
# Option 2: Environment Variables approach (uncomment to use)
|
||||||
|
# - TSA_Authentication__Users__0__Email=admin@example.com
|
||||||
|
# - TSA_Authentication__Users__0__PasswordHash=$2a$11$...
|
||||||
|
# - 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$...
|
||||||
|
# - TSA_Authentication__Users__1__Role=Advisor
|
||||||
|
# - TSA_Authentication__Users__1__DisplayName=Chapter Advisor
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Option 1: Volume-mounted secrets file (recommended for easy editing)
|
||||||
|
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
|
||||||
|
|
||||||
|
# Database persistence
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# HTTPS certificate (if needed)
|
||||||
|
# - ./certs:/https:ro
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Uncomment if using HTTPS with certificate
|
||||||
|
# environment:
|
||||||
|
# - ASPNETCORE_Kestrel__Certificates__Default__Password=your-cert-password
|
||||||
|
# - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx
|
||||||
Reference in New Issue
Block a user